create-page
Skill0 starsChanged 56 days agoMITView on GitHub
Build a page and its MobX page state — Provider root, context.read access, HookWidget for local state, AppNavigator for navigation, Observer for reactive rebuilds, and the loading/empty/error branches. Use when adding or modifying any screen, modal or route-level widget.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- agentconfig/agentconfig.orgtheir own version, first seen Nov 8, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- agentconfig/agentconfig.org★ 7same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository18 other files it gives its agents.
- payload/AGENTS.mdAGENTS.md
- add-design-tokenSkill
- add-localizationSkill
- add-platform-channelSkill
- add-routeSkill
- create-adrSkill
- create-api-providerSkill
- create-ds-componentSkill
- create-dtoSkill
- create-featureSkill
- create-storeSkill
- create-use-caseSkill
- handle-errorsSkill
- plan-featureSkill
- qa-featureSkill
- triage-bug-reportSkill
- write-maestro-flowSkill
- write-testsSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 184
Every widget an E2E flow touches carries a stable identifier, and every interactive widget carries a Semantics label from LocaleKeys …
- Very specific: line 188
Use TestId (lib/core/ui/test_id.dart), which applies both from one string — the Semantics(identifier:) Maestro matches, and the Key widget …
- Very specific: line 238
Never pass BuildContext into a helper.
- Very specific: line 276
Use the primitives in lib/core/ui/geist_motion.dart — PressScale for tap feedback, FadeSlideIn for entry, AnimatedCount for numbers.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Create a page + page state
Governed by ADR-0009 (Provider access), ADR-0010 (HookWidget), ADR-0018 (widget construction), ADR-0019 (hook selection), ADR-0008 (navigation), ADR-0002 (state vs store).
Concepts behind this playbook: state-management-guide, performance-guide.
The page state
@injectable, MobX, lives at lib/features/{feature}/view/{feature}_page_state.dart. It never touches DioService — it calls stores and use cases.
part 'home_page_state.g.dart';
@injectable
class HomePageState = _HomePageStateBase with _$HomePageState;
abstract class _HomePageStateBase with Store {
final HomeStore _homeStore;
final AppNavigator _appNavigator;
final OpenTodoDetailsUseCase _openTodoDetailsUseCase;
_HomePageStateBase(
this._homeStore,
this._appNavigator,
this._openTodoDetailsUseCase,
);
// The page reads the store through these, never the store itself
// (ADR-0001). MobX tracks through a plain getter.
List<TodoDto> get todos => _homeStore.todos;
bool get isLoading => _homeStore.isLoading;
String? get error => _homeStore.error;
@readonly
TodoFilter _filter = TodoFilter.all;
@computed
List<TodoDto> get visibleTodos =>
_homeStore.todos.where(_filter.matches).toList();
@action
void onFilterChanged(TodoFilter filter) => _filter = filter;
Future<void> init() async => _homeStore.loadTodos();
@action
Future<void> onTodoTap(String id) async => _openTodoDetailsUseCase(id);
@action
Future<void> onLogoutPressed() async {
await _homeStore.logout();
await _appNavigator.pushAndPopAll(const LoginRoute());
}
void dispose() {}
}Trimmed from home_page_state.dart, which also injects ShowAddTodoModalUseCase and has @computed doneCount.
- Never expose the store (no
HomeStore get store). The page reads what it renders through the state's getters, so it never depends on a store directly. - Screen-level UI state that derived data depends on, such as a filter, is a
@readonlyfield with an@actionhandler on the state, not auseStatein the page. What is derived from it is@computed, never computed inbuild(). - Handler names read as events:
onXPressed,onXTap,onXChanged. init()is called by theProvider, not bybuild().dispose()always exists — cancel reactions, dispose controllers.
The page
Two widgets. The outer one is the Provider root; the inner one reads the state.
@RoutePage()
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Provider<HomePageState>(
create: (_) => getIt<HomePageState>()..init(),
dispose: (_, state) => state.dispose(),
child: const _HomePageContent(),
);
}
}
class _HomePageContent extends HookWidget {
const _HomePageContent();
@override
Widget build(BuildContext context) {
final g = context.geist;
final state = context.read<HomePageState>();
final controller = useScrollController();
// …
}
}Provider(create:) is the only place getIt<>() may be called from widget code (ADR-0007).
Hard rules
- Never pass state as a widget parameter. Descendants call
context.read<HomePageState>(). Pass plain data and callbacks down, never the state object. - Never
context.router. Navigation goes throughAppNavigatorin the state class. HookWidgetwhenever the widget needs a controller, effect, or local UI state.StatelessWidgetfor pure presentation.StatefulWidgetonly for APIs hooks cannot express.- Hooks are for local widget state and lifecycle only. Business logic, anything shared between widgets, and anything that outlives the screen belong in the state class or a store — never in a
useState. - No widget-returning methods, no
BuildContextparameters. See Splitting a largebuild()below. - Pick the hook by rebuild scope. See Choosing a hook below.
Observerscoped tight. Wrap the smallest subtree that reads observables — not the wholeScaffold. A page-wideObserverrebuilds the app bar on every list change.- Design-system tokens only —
context.geist.*,GeistTextStyles.*,GeistRadius.*,GeistDuration.*,kSpacingNpx. No rawColor,TextStyle(…), orBoxShadowstacks (ADR-0013). - Every string via
LocaleKeys(ADR-0012).
The four branches — all of them, every time
An Observer over remote data renders four states. Skipping one is the most common defect this repo produces.
Observer(
builder: (_) {
if (state.isLoading && state.todos.isEmpty) return const _SkeletonList();
if (state.error != null) return _ErrorState(onRetry: state.init);
if (state.todos.isEmpty) return const _EmptyState();
return _TodoList(todos: state.visibleTodos);
},
)- Loading —
skeletonizer, never a bare centred spinner on first load. - Error — a message plus a retry affordance that actually re-runs the request.
- Empty — distinct from loading and from error; says what the user can do next.
- Content.
Each branch needs a test identifier (below) so the Maestro failure and edge flows can assert it.
Lists whose rows are removed or inserted
A list whose rows can be removed or inserted mid-list (swipe to delete, reorder, an optimistic insert) keys each row with ValueKey(item.id) and gives the builder a callback that maps the key back to the row's current index:
| Builder | Callback |
SliverList.separated, ListView.separated | findItemIndexCallback |
SliverList.builder, ListView.builder | findChildIndexCallback |
Without the callback, Flutter matches children by position. Remove row 2, and the element at position 2 is handed row 3's widget. The keys differ, so that element is thrown away and row 3 is built from scratch, and so is every row below it. Their state is lost and their entry animations play again.
Illustrative: onTodoDismissed and HomeKeys.todoRow do not exist in this repo.
SliverList.separated(
itemCount: todos.length,
separatorBuilder: (_, _) => const Gap(kSpacing8px),
itemBuilder: (_, index) {
final todo = todos[index];
return Dismissible(
key: ValueKey(todo.id),
direction: DismissDirection.endToStart,
onDismissed: (_) => state.onTodoDismissed(todo.id),
child: _TodoCard(todo: todo).withTestId(HomeKeys.todoRow(todo.id)),
);
},
findItemIndexCallback: (key) {
final index = todos.indexWhere((todo) => ValueKey(todo.id) == key);
return index == -1 ? null : index;
},
)- The key goes on the widget the builder returns, here the
Dismissible, not on a child inside it. ValueKey(item.id), neverValueKey(index)(the index shifts with the removal) orUniqueKey()(it never matches).onDismissedmust take the row out of the list before the next frame, orDismissiblethrows. The store removes it before its firstawait(create-store).- A row's test id includes the item id, so a flow can assert that exact row before and after:
static String todoRow(String id) => 'home_todo_row_$id';in the keys file. - Lowering
dismissThresholdsso a swipe dismisses sooner is a product decision, not a test fix (write-maestro-flow). - Widget test: the element of a row below the removed one survives the removal (
write-tests).
Test identifiers
Every widget an E2E flow touches carries a stable identifier, and every interactive widget carries a Semantics label from LocaleKeys (ADR-0015).
A plain Key is not enough. Verified on device: Flutter does not export a Key to the native accessibility tree, so Maestro cannot see it — the widget shows up with an empty resource-id and every id: selector fails. Only Semantics(identifier:) sets the native accessibility identifier.
Use TestId (lib/core/ui/test_id.dart), which applies both from one string — the Semantics(identifier:) Maestro matches, and the Key widget tests find:
// lib/features/home/view/home_keys.dart — plain Strings
class HomeKeys {
HomeKeys._();
static const addTodoFab = 'home_add_todo_fab';
static const todoList = 'home_todo_list';
static const emptyState = 'home_empty_state';
static const errorState = 'home_error_state';
static const errorRetry = 'home_error_retry';
}// wrap the branch or the control
return const _EmptyState().withTestId(HomeKeys.emptyState);
_NewTodoFab(visible: v, onPressed: p).withTestId(HomeKeys.addTodoFab)
TestId(HomeKeys.todoList, child: RefreshIndicator(...))Widget tests then use find.byKey(const Key(HomeKeys.emptyState)); flows use id: "home_empty_state". The string is the contract across both — do not rename it without updating the flow.
Where the ids live:
- A feature widget uses its feature's
{feature}_keys.dart. - A shared widget in
lib/core/ui/belongs to no feature, so it keeps its ids in a{widget}_keys.dartnext to it:lib/core/ui/error_notice_keys.dartforerror_notice.dart.
A TextField is the one case where the inner widget also wants the raw Key (so find.byKey targets the field itself rather than the wrapper); tag the wrapper with TestId for Maestro and put key: const Key(...) on the TextField too.
Splitting a large build()
ADR-0018. When build() grows, extract a widget class, never a method.
// ❌ a method that returns a widget: no const, no Observer boundary,
// rebuilds with the parent, cannot be pumped on its own
Widget _buildHeader() => Text(LocaleKeys.homePage_title.tr());
// ✅ a real widget, file-private
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) => Text(LocaleKeys.homePage_title.tr());
}Never pass BuildContext into a helper. Helpers take plain data; the widget that owns the context uses it directly, and anything that has to act from outside a build phase goes through AppNavigator (ADR-0008).
The only exceptions are framework builder: callbacks whose signature the framework fixes (ListView.builder, LayoutBuilder, ValueListenableBuilder, showModalBottomSheet, …) and extension methods on BuildContext such as context.geist and context.read<T>().
Choosing a hook
ADR-0019. Pick by what should rebuild, not by habit.
| Need | Use |
| Local value that only part of the widget reads | useValueNotifier + ValueListenableBuilder |
| Local value that legitimately changes the whole widget | useState |
| A controller | useTextEditingController, useScrollController, useFocusNode, useAnimationController |
Anything allocated in build() — derived lists, parsed values, formatters | useMemoized(() => …, [keys]) |
| Side effect with cleanup | useEffect |
// ❌ useState from a scroll listener: every direction change rebuilds
// the whole page (app bar, filters, list Observer) to move one button
final fabVisible = useState(true);
// ...
floatingActionButton: _NewTodoFab(visible: fabVisible.value, ...),
// ✅ only the FAB rebuilds
final fabVisible = useValueNotifier(true);
// ...
floatingActionButton: ValueListenableBuilder<bool>(
valueListenable: fabVisible,
builder: (_, visible, _) => _NewTodoFab(visible: visible, ...),
),That was a real defect on the Home page, fixed alongside this rule.
A useMemoized with a missing key returns a stale value. Treat the key list with the same care as a useEffect dependency list.
Motion
Use the primitives in lib/core/ui/geist_motion.dart — PressScale for tap feedback, FadeSlideIn for entry, AnimatedCount for numbers. They already respect MediaQuery.disableAnimationsOf. Durations come from GeistDuration; never hardcode a Duration in lib/.
Checklist
Providerroot +_Contentchild;getItonly insidecreate:- State injected, never passed as a parameter; the state exposes getters, never its store
- No
context.router;AppNavigatorin the state Observerscoped to the smallest reactive subtree- No
Widget _buildX()methods; noBuildContextpassed into helpers useValueNotifierwhere only part of the widget reads a value;useMemoizedfor values built inbuild()- Loading / error / empty / content all handled, each tagged with
TestId - Rows that can be removed or inserted:
ValueKey(item.id)on the returned widget, plusfindItemIndexCallback(.separated) orfindChildIndexCallback(.builder) - Tokens +
LocaleKeysonly - Ids are Strings in
{feature}_keys.dart(alib/core/ui/widget:{widget}_keys.dartnext to it), applied withTestId— never a bareKey - Semantics labels on interactive widgets, sourced from
LocaleKeys melos run buildafter adding@RoutePageor MobX annotations
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

