Marionette CoUI Binding โ Templates#
Drop-in code templates for wiring Marionette into a Flutter app that uses CoUI.
1. main.dart โ full binding setup#
import 'package:coui_flutter/coui_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:marionette_flutter/marionette_flutter.dart';
import 'package:marionette_logger/marionette_logger.dart';
import 'package:logger/logger.dart';
const bool _isFlutterTest =
bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
void main() {
if (kDebugMode && !_isFlutterTest) {
final logCollector = LoggerLogCollector();
MarionetteBinding.ensureInitialized(
MarionetteConfiguration(
logCollector: logCollector,
isInteractiveWidget: _isCoUIInteractive,
extractText: _extractCoUIText,
),
);
Logger.level = Level.debug;
final _ = Logger(
output: MultiOutput([ConsoleOutput(), logCollector]),
);
} else {
WidgetsFlutterBinding.ensureInitialized();
}
runApp(const App());
}
bool _isCoUIInteractive(Type type) =>
// Buttons
type == PrimaryButton ||
type == SecondaryButton ||
type == OutlineButton ||
type == GhostButton ||
type == DestructiveButton ||
type == LinkButton ||
type == TextButton ||
type == IconButton ||
// Inputs
type == Input ||
type == Textarea ||
type == DatePicker ||
type == Select ||
// Toggles
type == Toggle ||
type == Switch ||
type == Checkbox ||
type == Radio ||
type == Slider ||
// Navigation primitives that are tappable
type == TabItem ||
type == AccordionItem ||
type == MenuItem;
String? _extractCoUIText(Element element) {
final widget = element.widget;
// Buttons โ labels live in child Text widgets
if (widget is PrimaryButton ||
widget is SecondaryButton ||
widget is OutlineButton ||
widget is GhostButton ||
widget is DestructiveButton ||
widget is LinkButton ||
widget is TextButton) {
return _readDescendantText(element);
}
// Inputs โ prefer the current value, fall back to placeholder
if (widget is Input) {
final ctrl = widget.controller;
if (ctrl != null && ctrl.text.isNotEmpty) return ctrl.text;
return widget.placeholder;
}
if (widget is Textarea) {
final ctrl = widget.controller;
if (ctrl != null && ctrl.text.isNotEmpty) return ctrl.text;
return widget.placeholder;
}
return null;
}
String? _readDescendantText(Element root) {
final buffer = StringBuffer();
void visit(Element e) {
final w = e.widget;
if (w is Text && w.data != null) {
if (buffer.isNotEmpty) buffer.write(' ');
buffer.write(w.data);
return;
}
if (w is RichText) {
final plain = w.text.toPlainText();
if (plain.isNotEmpty) {
if (buffer.isNotEmpty) buffer.write(' ');
buffer.write(plain);
}
return;
}
e.visitChildren(visit);
}
root.visitChildren(visit);
final out = buffer.toString().trim();
return out.isEmpty ? null : out;
}
2. pubspec.yaml additions#
dev_dependencies:
marionette_flutter: ^0.6.0 # binding for the app
marionette_logger: ^0.6.0 # if you use the `logger` package
marionette_mcp: ^0.6.0 # MCP server โ global activation also fine
marionette_flutter,marionette_logger, andmarionette_mcp(plusmarionette_cli/marionette_logging) are released in lockstep from one monorepo โ always pin them to the same version.connectchecks the binding version against the server version and errors on a mismatch.
3. Claude Code .mcp.json (per-project)#
{
" mcpServers " : {
" marionette " : {
" command " : " marionette_mcp " ,
" args " : []
}
}
}
4. Smoke-test prompt#
Connect to the running app via Marionette (URI: < paste from `flutter run` > ).
For each tab in the bottom NavigationBar, tap it, wait for the route to
settle, call get_interactive_elements, and take_screenshots. Save the
results to .claude/docs/marionette/smoke- < timestamp > /. If any tab throws,
collect get_logs and stop with the captured error.
5. Widgetbook regression prompt#
The app is currently running CoUI Widgetbook on the connected VM Service.
Use NavigationSidebar in the Widgetbook UI to walk every UseCase in order.
For each UseCase:
1. take_screenshots โ screens/ < usecase > _idle.png
2. get_interactive_elements
3. For each interactive element, perform its default action
(button โ tap, input โ enter " marionette-test " , toggle โ flip).
4. take_screenshots โ screens/ < usecase > _after.png
5. If the two screenshots are identical, flag the UseCase as
" non-responsive " in summary.md.
Stop after 100 UseCases or on first unhandled error.
6. Custom extension with schema (0.6.0+)#
For app-specific actions Marionette can't derive from the widget tree โ reading BLoC state, driving a
PageController/TabController directly, seeding a fixture โ register an extension once in
main.dart (inside the same kDebugMode guard as MarionetteBinding.ensureInitialized):
import 'package:marionette_flutter/marionette_flutter.dart';
void _registerAppExtensions() {
registerMarionetteExtension(
'goToTab',
(args) async {
final index = int.tryParse(args['index'] ?? '');
if (index == null) {
return MarionetteExtensionResult.invalidParams('index must be an int');
}
appTabController.animateTo(index);
return MarionetteExtensionResult.success({'ok': 'true'});
},
inputSchema: ExtensionInputSchema({
'index': ExtensionParam.integer(description: 'Tab index to jump to'),
}),
);
}
-
Params are flat scalars only (
ExtensionParam.string/.integer/.number/.boolean) โ no nested objects/arrays. -
With
inputSchema, the extension appears as its own validated tool (name sanitized to[a-z0-9_-], e.g.goToTabโgo_to_tab); without it, it's only reachable vialist_custom_extensions/call_custom_extension. -
Don't prefix the name with
ext.flutter.โ added automatically, and doing it yourself throwsArgumentError.