How Finnish It builds and tests their GenUI catalog with Widgetbook
The Flutter GenUI SDK is in alpha and Widgetbook v4 is in beta. APIs shown here can change. The Dart snippets in this article were compiled against genui 0.9.2 and json_schema_builder 0.1.5.
What is GenUI, and why Flutter
GenUI (generative UI) is a pattern where the model returns a user interface instead of text. Given a prompt, the model outputs a structured description of a UI, and your app renders it from a fixed set of widgets you defined. When the user interacts with the rendered UI, that input goes back to the model, which can return an updated UI.
Flutter is well suited to GenUI for three reasons:
One widget layer renders on mobile, web, and desktop, so a single catalog covers every target.
Widgets are strongly typed, so their constructors map cleanly onto the data schemas the model has to fill.
Hot reload makes iterating on catalog widgets fast, which matters because the catalog is where most of your GenUI work goes.
The Flutter SDK for this isflutter/genui. It ships as genui (core, which implements the A2UI protocol, currently v0.9), genui_a2a (integrates the core with an A2UI streaming backend), genai_primitives (shared generative-AI primitives), and json_schema_builder (the schema DSL, exported as S). The model output is JSON-based, parsed into UI messages, and rendered on a surface.
Meet Finnish It, GenUI in production
Finnish It, founded by Flutter & Dart Google Developer Expert Cagatay Ulusoy, is one of the first GenUI Flutter apps in production. This spring, the app was featured in two Google keynotes, the Google Cloud Next developer keynote and the Flutter keynote at Google I/O.
“Finnish It” is a language-learning app designed specifically for learners of the Finnish language. One of the app's key features is its ability to generate dynamic practice images on demand. The user refines a scene through a short wizard (season, time of day, setting, elements), and the model builds the picture from those choices. Learners articulate descriptions of the picture in Finnish, after which the application evaluates the input to provide real-time pedagogical feedback. The refinement UI itself is generative.
Cagatay documented his app GenUI journey in a three-part article on Medium: From Structured Outputs to A2UI Surfaces. He started with structured outputs (a GenerationConfig with responseMimeType: 'application/json' and a response schema), then moved to A2UI surfaces, where Gemini emits createSurface and updateComponents messages instead of a single JSON blob. That move is exactly where a widget catalog stops being an implementation detail and becomes the contract between the model and the app. The rest of this article uses his catalog as the running example.
Why the catalog matters
The catalog is the closed set of widgets the model is allowed to use. The model can compose and nest them and supply their data, but it cannot invent new ones. This is the safety boundary of a GenUI app: output quality is bounded by the widgets in the catalog.
Finnish It's refinement flow is a five-step wizard, one A2UI surface per step, driven by a single catalog, ImageDescriptionRefinementCatalog (catalogId: 'com.finnishit.image_description.refinement'). It exposes four domain widgets plus one layout primitive: ImageOptionTiles, ImageSingleChoice, ImageFramingMeter, ImageTagCloud, and Column. That closed set is everything the model can render across the whole wizard.
In the SDK, a Catalog holds CatalogItems. Each CatalogItem has three parts:
name: the identifier the model references.
dataSchema: a JSON schema describing the widget's data. It is the contract between the model and your widget. (Built with S from json_schema_builder, so remember to import that package; genui does not re-export S.)
widgetBuilder: builds the real Flutter widget from that data, reading it off the CatalogItemContext.
Here is one of Finnish It's items, ImageOptionTiles, the tile selector the model emits for each scene axis:
final _schema = S.object(description:'A compact tile selector for visual scene axes such as season, time of ''day, or the broad indoor/outdoor setting. Not for setting subtypes. ''Use concise localized labels.',properties:{'question':S.string(description:'Question text shown above the tiles.'),'options':S.list(description:'Two to four mutually exclusive visual options.',items:S.object(properties:{'id':S.string(description:'Stable lowercase id used by the client for deterministic icon mapping.',enumValues:_optionTileIds,),'label':S.string(description:'Short localized option label.'),'sublabel':S.string(description:'Optional one- or two-word localized supporting label.',),},required:['id','label'],),minItems:2,maxItems:4,),'selectedOption':A2uiSchemas.stringReference(description:'Reference under /scene/* where the selected localized label is stored.',),},required:['question','options','selectedOption'],);extension type _ImageOptionTilesData.fromMap(Map<String,Object?> _json){String get question=>_json['question']as String;List<Map<String,Object?>> get options=>(_json['options']as List<Object?>)
.whereType<Map>()
.map((e)=>e.cast<String,Object?>())
.toList();JsonMap? get selectedOption =>_json['selectedOption']as JsonMap?;}final imageOptionTiles = CatalogItem(name:'ImageOptionTiles',dataSchema:_schema,widgetBuilder:(context){// Catalog items are intentionally thin adapters:// 1. decode the model-authored component data,// 2. bind to the generated DataModel path,// 3. render an existing design-system widget.final data = _ImageOptionTilesData.fromMap(context.dataas Map<String,Object?>,);final valueRef = data.selectedOption;final entries = data.options.map(_entryFromOption).toList();returnBoundString(dataContext: context.dataContext,value: valueRef,builder:(builderContext,selectedValue){final selectedEntry = _entryMatchingSelection(entries,selectedValue);returnImageCatalogSection(child: TileSelectorWidget(question: data.question,entries: entries,selectedId: selectedEntry?.id,onSelected:(id){final path = valueRef?['path']as String?;if(path == null){return;}final selected = entries.firstWhere((entry)=>entry.id == id);context.dataContext.update(DataPath(path),selected.label);},),);},);},);TileSelectorEntry _entryFromOption(Map<String,Object?> option){final id = (option['id']as String?) ?? '';returnTileSelectorEntry(id: id,label:(option['label']as String?) ?? id,sublabel: option['sublabel']as String?,icon: _iconForOption(id),);}
final _schema = S.object(description:'A compact tile selector for visual scene axes such as season, time of ''day, or the broad indoor/outdoor setting. Not for setting subtypes. ''Use concise localized labels.',properties:{'question':S.string(description:'Question text shown above the tiles.'),'options':S.list(description:'Two to four mutually exclusive visual options.',items:S.object(properties:{'id':S.string(description:'Stable lowercase id used by the client for deterministic icon mapping.',enumValues:_optionTileIds,),'label':S.string(description:'Short localized option label.'),'sublabel':S.string(description:'Optional one- or two-word localized supporting label.',),},required:['id','label'],),minItems:2,maxItems:4,),'selectedOption':A2uiSchemas.stringReference(description:'Reference under /scene/* where the selected localized label is stored.',),},required:['question','options','selectedOption'],);extension type _ImageOptionTilesData.fromMap(Map<String,Object?> _json){String get question=>_json['question']as String;List<Map<String,Object?>> get options=>(_json['options']as List<Object?>)
.whereType<Map>()
.map((e)=>e.cast<String,Object?>())
.toList();JsonMap? get selectedOption =>_json['selectedOption']as JsonMap?;}final imageOptionTiles = CatalogItem(name:'ImageOptionTiles',dataSchema:_schema,widgetBuilder:(context){// Catalog items are intentionally thin adapters:// 1. decode the model-authored component data,// 2. bind to the generated DataModel path,// 3. render an existing design-system widget.final data = _ImageOptionTilesData.fromMap(context.dataas Map<String,Object?>,);final valueRef = data.selectedOption;final entries = data.options.map(_entryFromOption).toList();returnBoundString(dataContext: context.dataContext,value: valueRef,builder:(builderContext,selectedValue){final selectedEntry = _entryMatchingSelection(entries,selectedValue);returnImageCatalogSection(child: TileSelectorWidget(question: data.question,entries: entries,selectedId: selectedEntry?.id,onSelected:(id){final path = valueRef?['path']as String?;if(path == null){return;}final selected = entries.firstWhere((entry)=>entry.id == id);context.dataContext.update(DataPath(path),selected.label);},),);},);},);TileSelectorEntry _entryFromOption(Map<String,Object?> option){final id = (option['id']as String?) ?? '';returnTileSelectorEntry(id: id,label:(option['label']as String?) ?? id,sublabel: option['sublabel']as String?,icon: _iconForOption(id),);}
final _schema = S.object(description:'A compact tile selector for visual scene axes such as season, time of ''day, or the broad indoor/outdoor setting. Not for setting subtypes. ''Use concise localized labels.',properties:{'question':S.string(description:'Question text shown above the tiles.'),'options':S.list(description:'Two to four mutually exclusive visual options.',items:S.object(properties:{'id':S.string(description:'Stable lowercase id used by the client for deterministic icon mapping.',enumValues:_optionTileIds,),'label':S.string(description:'Short localized option label.'),'sublabel':S.string(description:'Optional one- or two-word localized supporting label.',),},required:['id','label'],),minItems:2,maxItems:4,),'selectedOption':A2uiSchemas.stringReference(description:'Reference under /scene/* where the selected localized label is stored.',),},required:['question','options','selectedOption'],);extension type _ImageOptionTilesData.fromMap(Map<String,Object?> _json){String get question=>_json['question']as String;List<Map<String,Object?>> get options=>(_json['options']as List<Object?>)
.whereType<Map>()
.map((e)=>e.cast<String,Object?>())
.toList();JsonMap? get selectedOption =>_json['selectedOption']as JsonMap?;}final imageOptionTiles = CatalogItem(name:'ImageOptionTiles',dataSchema:_schema,widgetBuilder:(context){// Catalog items are intentionally thin adapters:// 1. decode the model-authored component data,// 2. bind to the generated DataModel path,// 3. render an existing design-system widget.final data = _ImageOptionTilesData.fromMap(context.dataas Map<String,Object?>,);final valueRef = data.selectedOption;final entries = data.options.map(_entryFromOption).toList();returnBoundString(dataContext: context.dataContext,value: valueRef,builder:(builderContext,selectedValue){final selectedEntry = _entryMatchingSelection(entries,selectedValue);returnImageCatalogSection(child: TileSelectorWidget(question: data.question,entries: entries,selectedId: selectedEntry?.id,onSelected:(id){final path = valueRef?['path']as String?;if(path == null){return;}final selected = entries.firstWhere((entry)=>entry.id == id);context.dataContext.update(DataPath(path),selected.label);},),);},);},);TileSelectorEntry _entryFromOption(Map<String,Object?> option){final id = (option['id']as String?) ?? '';returnTileSelectorEntry(id: id,label:(option['label']as String?) ?? id,sublabel: option['sublabel']as String?,icon: _iconForOption(id),);}
Two details the compiler enforces, worth calling out because they are easy to get wrong: Catalog takes its items as a positional list (Catalog([...]), not Catalog(components: [...])), and the component discriminator is injected for you from name, so your schema lists only the widget's own properties.
Now look at selectedOption. It is not a callback and not a plain value. It is a stringReference binding into the surface's DataModel. As Cagatay puts it, tapping a tile "does not call Gemini again". The widget updates the local DataModel. A tap that stores a value is data. A "show different options" request that asks the model to regenerate is intent, sent as a separate UserActionEvent. The schema carries the widget's data and its bindings, never its behavior. That is also how Widgetbook works.
The schemas are passed to the model in the system prompt. The model returns A2UI messages that a SurfaceController (the runtime you create with your Catalog) validates and applies, updating the DataModel and the surface definitions, and a Surface widget renders. A Conversation wires that controller to a Transport that carries messages to and from the model. Every widget the model can produce corresponds to a CatalogItem, and its complete input space is defined by its dataSchema.
Why Widgetbook is a natural GenUI catalog
A GenUI catalog is a set of widgets, each declared with its inputs, so the model knows what it can render and what data to pass. Widgetbook is already that: your widgets as components, their properties as typed args. The catalog GenUI needs is the one you already keep in Widgetbook. A CatalogItem and a story are two entries for the same widget. ImageOptionTiles is one CatalogItem and one story.
GenUI SDK
Widgetbook v4
CatalogItem.name
component / Meta(Widget.new)
dataSchema property
typed Arg (StringArg, BoolArg, …)
schema range / optional field
Arg values and Modes
widgetBuilder
the widget the story renders
So the same widget you register in the catalog is the one you already build, catalog, and test in Widgetbook. While building the catalog items, you can preview every item in isolation, across states, themes, and devices: an empty option list, a long question, the maximum number of tiles. You can also document how each item should be used, and that documentation serves two audiences: humans reading the catalog, and the agent deciding which widget to emit. In v4, DocBlocks generate it from your Dart comments and story metadata.
Here, you can see the Finnish It Widgetbook:
The workflow:
Build each widget as a Widgetbook story in isolation, where it is cataloged automatically.
Let an agent generate the GenUI dataSchema from the story's typed args, which already describe the widget's input space.
Keep the two in sync: the catalog entry and the schema both derive from the same story.
Testing the catalog with Widgetbook v4 and Widgetbook Cloud
GenUI has one hard testing problem: the model's output is non-deterministic, so you cannot reliably snapshot-test a whole generated screen. The same prompt can produce different compositions.
The catalog widgets are the right test target. Each is deterministic given its data, and the catalog is the complete set the model can use. Test every widget across the range its schema allows, every value ImageOptionTiles can be handed, and you have covered everything the model can render.
Widgetbook v4 makes each catalog widget the unit of test. Meta(Widget.new) anchors stories to a component, and build_runner turns the widget's constructor into typed _Args, one StringArg, BoolArg, or EnumArg per value property. Those args mirror the dataSchema, so listing them lists what the model can send.
One property never becomes an arg: a callback. ImageOptionTiles reports a selection through a callback, and you cannot tweak a function from an args panel. That is the same reason the selection is a stringReference in the schema, not a function. Both the schema and the story model the widget's data and wire behavior separately. When a widget has callbacks, you point Meta at a small "input" class that exposes the data plus a plain flag, and map that flag to real callbacks in defaults.
part 'image_refinement_catalog.stories.g.dart';/// Describes how this component appears in Widgetbook's generated catalog.////// The story generator reads this top-level [ComponentMeta] when build runner/// creates `image_refinement_catalog.stories.g.dart`. [ComponentMeta.name] is/// the component label, [ComponentMeta.path] places it under the Image/// Generation category, and [ComponentMeta.docsBuilder] replaces the ordinary/// Dart-comment description with the `ImageOptionTiles` description from the/// production GenUI catalog schema.final component = ComponentMeta(name:'ImageOptionTiles',path:'[Image Generation]/Refinement',docsBuilder:genUiCatalogDocsBuilder('ImageOptionTiles'),);/// Defines the widget and Args types that Widgetbook generates for this file.////// The first constructor tear-off tells Widgetbook that every story builds an/// [ImageOptionTilesStoryHost]. `argsType` deliberately points at a different/// constructor: Widgetbook inspects [ImageOptionTilesStoryInput] and generates/// the typed `_Args`, `_Story`, `_Scenario`, and `_Defaults` APIs used below./// Because the Args type is not the widget constructor, [defaults] maps those/// generated values into the host explicitly.constmeta = Meta(ImageOptionTilesStoryHost.new,argsType: ImageOptionTilesStoryInput.new,);/// The model-authored values exposed as Widgetbook Args.////// [interactive] is deliberately a plain boolean instead of a callback. GenUI/// schemas describe data, while the app owns behavior. The story's shared/// defaults map this flag to a real callback, keeping the Args panel aligned/// with the same boundary used by the production catalog adapter.class ImageOptionTilesStoryInput {constImageOptionTilesStoryInput({this.question = 'Choose the season for the generated scene',this.options = seasonEntries,this.selectedOption = 'summer',this.interactive = true,});finalStringquestion;finalList<TileSelectorEntry> options;finalString? selectedOption;finalboolinteractive;}/// Stateful preview adapter for the production [TileSelectorWidget].////// The production GenUI catalog stores selection in its DataModel. Widgetbook/// has no GenUI session, so this host provides only the equivalent ephemeral/// selection state and forwards changes through [onSelected].class ImageOptionTilesStoryHost extendsStatefulWidget{constImageOptionTilesStoryHost({requiredthis.question,requiredthis.options,requiredthis.selectedOption,requiredthis.onSelected,super.key,});finalStringquestion;finalList<TileSelectorEntry> options;finalString? selectedOption;finalValueChanged<String>? onSelected;
@overrideState<ImageOptionTilesStoryHost> createState()=>_ImageOptionTilesStoryHostState();}class _ImageOptionTilesStoryHostState extendsState<ImageOptionTilesStoryHost> {late String? _selectedOption = widget.selectedOption;
@overridevoiddidUpdateWidget(ImageOptionTilesStoryHost oldWidget){super.didUpdateWidget(oldWidget);if(oldWidget.selectedOption != widget.selectedOption){_selectedOption = widget.selectedOption;}}void_onSelected(String id){final callback = widget.onSelected;if(callback == null){return;}setState(()=>_selectedOption = id);callback(id);}
@overrideWidget build(BuildContext context){returnImageCatalogSection(child: IgnorePointer(ignoring: widget.onSelected == null,child: TileSelectorWidget(question: widget.question,entries: widget.options,selectedId: _selectedOption,onSelected: _onSelected,),),);}}/// Shared construction keeps every story and scenario on the same production/// rendering path. The callback itself is not an Arg because functions are not/// model-authored GenUI data.final defaults = _Defaults(builder:(context,args)=> ImageOptionTilesStoryHost(question: args.question,options: args.options,selectedOption: args.selectedOption,onSelected: args.interactive ? (_){} : null,),);final $imageOptionTiles = _Story(name:'visual axes',args: _Args(question: StringArg('Choose the season for the generated scene'),options:Arg.fixed(seasonEntries),selectedOption:NullableStringArg('summer'),interactive:BoolArg(true),),scenarios:[_Scenario(name:'Default',args: _Args.fixed(question:'Choose the season for the generated scene',options: seasonEntries,selectedOption:'summer',interactive:true,),),_Scenario(name:'Long localized question',args: _Args.fixed(question:'Which season should shape the light, atmosphere, colors, and ''visible details in the generated Finnish scene?',options: seasonEntries,selectedOption:'autumn',interactive:true,),),_Scenario(name:'Empty options',args: _Args.fixed(question:'Choose the season for the generated scene',options:const[],selectedOption:null,interactive:false,),),_Scenario(name:'Maximum four tiles',args: _Args.fixed(question:'What time of day should the scene show?',options: timeOfDayEntries,selectedOption:'day',interactive:true,),),_Scenario(name:'Select another tile',args: _Args.fixed(question:'Choose the season for the generated scene',options: seasonEntries,selectedOption:'summer',interactive:true,),run:(tester,args)async{await tester.tap(find.text('Winter'));awaittester.pumpAndSettle();final rendered = tester.widget<TileSelectorWidget>(find.byType(TileSelectorWidget),);expect(rendered.selectedId,'winter');},),],);constseasonEntries = [TileSelectorEntry(id:'spring',label:'Spring',sublabel:'fresh light',icon: Icons.local_florist_rounded,),TileSelectorEntry(id:'summer',label:'Summer',sublabel:'bright day',icon: Icons.wb_sunny_rounded,),TileSelectorEntry(id:'autumn',label:'Autumn',sublabel:'soft color',icon: Icons.eco_rounded,),TileSelectorEntry(id:'winter',label:'Winter',sublabel:'snowy',icon: Icons.ac_unit_rounded,),];consttimeOfDayEntries = [TileSelectorEntry(id:'dawn',label:'Dawn',sublabel:'soft light',icon: Icons.wb_twilight_rounded,),TileSelectorEntry(id:'day',label:'Day',sublabel:'clear light',icon: Icons.light_mode_rounded,),TileSelectorEntry(id:'dusk',label:'Dusk',sublabel:'warm glow',icon: Icons.nights_stay_rounded,),TileSelectorEntry(id:'night',label:'Night',sublabel:'dark sky',icon: Icons.dark_mode_rounded,),];
part 'image_refinement_catalog.stories.g.dart';/// Describes how this component appears in Widgetbook's generated catalog.////// The story generator reads this top-level [ComponentMeta] when build runner/// creates `image_refinement_catalog.stories.g.dart`. [ComponentMeta.name] is/// the component label, [ComponentMeta.path] places it under the Image/// Generation category, and [ComponentMeta.docsBuilder] replaces the ordinary/// Dart-comment description with the `ImageOptionTiles` description from the/// production GenUI catalog schema.final component = ComponentMeta(name:'ImageOptionTiles',path:'[Image Generation]/Refinement',docsBuilder:genUiCatalogDocsBuilder('ImageOptionTiles'),);/// Defines the widget and Args types that Widgetbook generates for this file.////// The first constructor tear-off tells Widgetbook that every story builds an/// [ImageOptionTilesStoryHost]. `argsType` deliberately points at a different/// constructor: Widgetbook inspects [ImageOptionTilesStoryInput] and generates/// the typed `_Args`, `_Story`, `_Scenario`, and `_Defaults` APIs used below./// Because the Args type is not the widget constructor, [defaults] maps those/// generated values into the host explicitly.constmeta = Meta(ImageOptionTilesStoryHost.new,argsType: ImageOptionTilesStoryInput.new,);/// The model-authored values exposed as Widgetbook Args.////// [interactive] is deliberately a plain boolean instead of a callback. GenUI/// schemas describe data, while the app owns behavior. The story's shared/// defaults map this flag to a real callback, keeping the Args panel aligned/// with the same boundary used by the production catalog adapter.class ImageOptionTilesStoryInput {constImageOptionTilesStoryInput({this.question = 'Choose the season for the generated scene',this.options = seasonEntries,this.selectedOption = 'summer',this.interactive = true,});finalStringquestion;finalList<TileSelectorEntry> options;finalString? selectedOption;finalboolinteractive;}/// Stateful preview adapter for the production [TileSelectorWidget].////// The production GenUI catalog stores selection in its DataModel. Widgetbook/// has no GenUI session, so this host provides only the equivalent ephemeral/// selection state and forwards changes through [onSelected].class ImageOptionTilesStoryHost extendsStatefulWidget{constImageOptionTilesStoryHost({requiredthis.question,requiredthis.options,requiredthis.selectedOption,requiredthis.onSelected,super.key,});finalStringquestion;finalList<TileSelectorEntry> options;finalString? selectedOption;finalValueChanged<String>? onSelected;
@overrideState<ImageOptionTilesStoryHost> createState()=>_ImageOptionTilesStoryHostState();}class _ImageOptionTilesStoryHostState extendsState<ImageOptionTilesStoryHost> {late String? _selectedOption = widget.selectedOption;
@overridevoiddidUpdateWidget(ImageOptionTilesStoryHost oldWidget){super.didUpdateWidget(oldWidget);if(oldWidget.selectedOption != widget.selectedOption){_selectedOption = widget.selectedOption;}}void_onSelected(String id){final callback = widget.onSelected;if(callback == null){return;}setState(()=>_selectedOption = id);callback(id);}
@overrideWidget build(BuildContext context){returnImageCatalogSection(child: IgnorePointer(ignoring: widget.onSelected == null,child: TileSelectorWidget(question: widget.question,entries: widget.options,selectedId: _selectedOption,onSelected: _onSelected,),),);}}/// Shared construction keeps every story and scenario on the same production/// rendering path. The callback itself is not an Arg because functions are not/// model-authored GenUI data.final defaults = _Defaults(builder:(context,args)=> ImageOptionTilesStoryHost(question: args.question,options: args.options,selectedOption: args.selectedOption,onSelected: args.interactive ? (_){} : null,),);final $imageOptionTiles = _Story(name:'visual axes',args: _Args(question: StringArg('Choose the season for the generated scene'),options:Arg.fixed(seasonEntries),selectedOption:NullableStringArg('summer'),interactive:BoolArg(true),),scenarios:[_Scenario(name:'Default',args: _Args.fixed(question:'Choose the season for the generated scene',options: seasonEntries,selectedOption:'summer',interactive:true,),),_Scenario(name:'Long localized question',args: _Args.fixed(question:'Which season should shape the light, atmosphere, colors, and ''visible details in the generated Finnish scene?',options: seasonEntries,selectedOption:'autumn',interactive:true,),),_Scenario(name:'Empty options',args: _Args.fixed(question:'Choose the season for the generated scene',options:const[],selectedOption:null,interactive:false,),),_Scenario(name:'Maximum four tiles',args: _Args.fixed(question:'What time of day should the scene show?',options: timeOfDayEntries,selectedOption:'day',interactive:true,),),_Scenario(name:'Select another tile',args: _Args.fixed(question:'Choose the season for the generated scene',options: seasonEntries,selectedOption:'summer',interactive:true,),run:(tester,args)async{await tester.tap(find.text('Winter'));awaittester.pumpAndSettle();final rendered = tester.widget<TileSelectorWidget>(find.byType(TileSelectorWidget),);expect(rendered.selectedId,'winter');},),],);constseasonEntries = [TileSelectorEntry(id:'spring',label:'Spring',sublabel:'fresh light',icon: Icons.local_florist_rounded,),TileSelectorEntry(id:'summer',label:'Summer',sublabel:'bright day',icon: Icons.wb_sunny_rounded,),TileSelectorEntry(id:'autumn',label:'Autumn',sublabel:'soft color',icon: Icons.eco_rounded,),TileSelectorEntry(id:'winter',label:'Winter',sublabel:'snowy',icon: Icons.ac_unit_rounded,),];consttimeOfDayEntries = [TileSelectorEntry(id:'dawn',label:'Dawn',sublabel:'soft light',icon: Icons.wb_twilight_rounded,),TileSelectorEntry(id:'day',label:'Day',sublabel:'clear light',icon: Icons.light_mode_rounded,),TileSelectorEntry(id:'dusk',label:'Dusk',sublabel:'warm glow',icon: Icons.nights_stay_rounded,),TileSelectorEntry(id:'night',label:'Night',sublabel:'dark sky',icon: Icons.dark_mode_rounded,),];
part 'image_refinement_catalog.stories.g.dart';/// Describes how this component appears in Widgetbook's generated catalog.////// The story generator reads this top-level [ComponentMeta] when build runner/// creates `image_refinement_catalog.stories.g.dart`. [ComponentMeta.name] is/// the component label, [ComponentMeta.path] places it under the Image/// Generation category, and [ComponentMeta.docsBuilder] replaces the ordinary/// Dart-comment description with the `ImageOptionTiles` description from the/// production GenUI catalog schema.final component = ComponentMeta(name:'ImageOptionTiles',path:'[Image Generation]/Refinement',docsBuilder:genUiCatalogDocsBuilder('ImageOptionTiles'),);/// Defines the widget and Args types that Widgetbook generates for this file.////// The first constructor tear-off tells Widgetbook that every story builds an/// [ImageOptionTilesStoryHost]. `argsType` deliberately points at a different/// constructor: Widgetbook inspects [ImageOptionTilesStoryInput] and generates/// the typed `_Args`, `_Story`, `_Scenario`, and `_Defaults` APIs used below./// Because the Args type is not the widget constructor, [defaults] maps those/// generated values into the host explicitly.constmeta = Meta(ImageOptionTilesStoryHost.new,argsType: ImageOptionTilesStoryInput.new,);/// The model-authored values exposed as Widgetbook Args.////// [interactive] is deliberately a plain boolean instead of a callback. GenUI/// schemas describe data, while the app owns behavior. The story's shared/// defaults map this flag to a real callback, keeping the Args panel aligned/// with the same boundary used by the production catalog adapter.class ImageOptionTilesStoryInput {constImageOptionTilesStoryInput({this.question = 'Choose the season for the generated scene',this.options = seasonEntries,this.selectedOption = 'summer',this.interactive = true,});finalStringquestion;finalList<TileSelectorEntry> options;finalString? selectedOption;finalboolinteractive;}/// Stateful preview adapter for the production [TileSelectorWidget].////// The production GenUI catalog stores selection in its DataModel. Widgetbook/// has no GenUI session, so this host provides only the equivalent ephemeral/// selection state and forwards changes through [onSelected].class ImageOptionTilesStoryHost extendsStatefulWidget{constImageOptionTilesStoryHost({requiredthis.question,requiredthis.options,requiredthis.selectedOption,requiredthis.onSelected,super.key,});finalStringquestion;finalList<TileSelectorEntry> options;finalString? selectedOption;finalValueChanged<String>? onSelected;
@overrideState<ImageOptionTilesStoryHost> createState()=>_ImageOptionTilesStoryHostState();}class _ImageOptionTilesStoryHostState extendsState<ImageOptionTilesStoryHost> {late String? _selectedOption = widget.selectedOption;
@overridevoiddidUpdateWidget(ImageOptionTilesStoryHost oldWidget){super.didUpdateWidget(oldWidget);if(oldWidget.selectedOption != widget.selectedOption){_selectedOption = widget.selectedOption;}}void_onSelected(String id){final callback = widget.onSelected;if(callback == null){return;}setState(()=>_selectedOption = id);callback(id);}
@overrideWidget build(BuildContext context){returnImageCatalogSection(child: IgnorePointer(ignoring: widget.onSelected == null,child: TileSelectorWidget(question: widget.question,entries: widget.options,selectedId: _selectedOption,onSelected: _onSelected,),),);}}/// Shared construction keeps every story and scenario on the same production/// rendering path. The callback itself is not an Arg because functions are not/// model-authored GenUI data.final defaults = _Defaults(builder:(context,args)=> ImageOptionTilesStoryHost(question: args.question,options: args.options,selectedOption: args.selectedOption,onSelected: args.interactive ? (_){} : null,),);final $imageOptionTiles = _Story(name:'visual axes',args: _Args(question: StringArg('Choose the season for the generated scene'),options:Arg.fixed(seasonEntries),selectedOption:NullableStringArg('summer'),interactive:BoolArg(true),),scenarios:[_Scenario(name:'Default',args: _Args.fixed(question:'Choose the season for the generated scene',options: seasonEntries,selectedOption:'summer',interactive:true,),),_Scenario(name:'Long localized question',args: _Args.fixed(question:'Which season should shape the light, atmosphere, colors, and ''visible details in the generated Finnish scene?',options: seasonEntries,selectedOption:'autumn',interactive:true,),),_Scenario(name:'Empty options',args: _Args.fixed(question:'Choose the season for the generated scene',options:const[],selectedOption:null,interactive:false,),),_Scenario(name:'Maximum four tiles',args: _Args.fixed(question:'What time of day should the scene show?',options: timeOfDayEntries,selectedOption:'day',interactive:true,),),_Scenario(name:'Select another tile',args: _Args.fixed(question:'Choose the season for the generated scene',options: seasonEntries,selectedOption:'summer',interactive:true,),run:(tester,args)async{await tester.tap(find.text('Winter'));awaittester.pumpAndSettle();final rendered = tester.widget<TileSelectorWidget>(find.byType(TileSelectorWidget),);expect(rendered.selectedId,'winter');},),],);constseasonEntries = [TileSelectorEntry(id:'spring',label:'Spring',sublabel:'fresh light',icon: Icons.local_florist_rounded,),TileSelectorEntry(id:'summer',label:'Summer',sublabel:'bright day',icon: Icons.wb_sunny_rounded,),TileSelectorEntry(id:'autumn',label:'Autumn',sublabel:'soft color',icon: Icons.eco_rounded,),TileSelectorEntry(id:'winter',label:'Winter',sublabel:'snowy',icon: Icons.ac_unit_rounded,),];consttimeOfDayEntries = [TileSelectorEntry(id:'dawn',label:'Dawn',sublabel:'soft light',icon: Icons.wb_twilight_rounded,),TileSelectorEntry(id:'day',label:'Day',sublabel:'clear light',icon: Icons.light_mode_rounded,),TileSelectorEntry(id:'dusk',label:'Dusk',sublabel:'warm glow',icon: Icons.nights_stay_rounded,),TileSelectorEntry(id:'night',label:'Night',sublabel:'dark sky',icon: Icons.dark_mode_rounded,),];
Scenarios live in the story file. _Args.fixed(...) pins concrete values and produces a snapshot (note it takes raw values, not StringArg/BoolArg wrappers, which are for the interactive _Args). A run: callback can drive a WidgetTester to assert behavior. Add scenarios for the inputs the schema allows, including the ones you would not write by hand: for ImageOptionTiles, a long question, an empty option list, the maximum number of tiles. The model can send any of these, so test them. For more on running these tests automatically and giving your agent a self-healing UI feedback loop, see Agentic UI engineering for Flutter.
Regenerate and run from inside widgetbook/:
$ dart run build_runner build -d$ flutter test
$ dart run build_runner build -d$ flutter test
$ dart run build_runner build -d$ flutter test
With Widgetbook Cloud, you can run all these tests on CI and automatically catch and review every visual change on your pull request. A schema change that makes ImageOptionTiles overflow with eight tiles shows up as a diff on the PR, not as a broken generated screen in production.
Because the model can only emit widgets from the catalog, and every widget in the catalog is visually and interactively tested across its schema's range, you have confidence in the generated UI even though you can't test every generated screen.
In Cagatay's words:
“GenUI doesn’t replace the widget layer; it amplifies it. Think of your GenUI catalog as your approved design system handed to the LLM to serve as its entire vocabulary. If your design-system widgets are small, reusable, theme-aware, and built for distinct purposes, you have already completed most of the work with GenUI. Widgetbook allows you to visualize, organize, and stress-test your catalog. As a developer who has been passionately exploring different GenUI use cases, Widgetbook has been invaluable in helping me to structure and visualize screens that my users have yet to generate.”
Summary
A GenUI catalog is the set of widgets a model is allowed to render. In Flutter, that catalog is the same set of widgets you already build in Widgetbook. A CatalogItem and a story are two views of one widget, and both model the widget's data, not its callbacks. Finnish It is a working example: its ImageOptionTiles, ImageSingleChoice, ImageFramingMeter, and ImageTagCloud are the closed set the model can render, and each one is a story under test. Because model output is non-deterministic, test the widgets rather than the generated screens: Widgetbook v4 turns each widget's constructor into typed args and its stories into snapshot tests, and Widgetbook Cloud runs visual, interaction, and accessibility regression on every push.