kaisel skill
This project uses kaisel — a Flutter
router built on sealed routes, pattern matching, and a
stack-as-state model. No string paths. No code generation.
The inversion (read this first)
Before any API, the mental model: sealed routes are the source of truth;
URLs are a serialization layer produced by a codec when needed. This
inverts the assumption you'd carry over from go_router (paths are
canonical) or from auto_route (annotations generate the typed surface).
In kaisel, the typed sealed class AppRoute is primary. URLs come out of
a codec. If you write code that treats URLs as the primary representation,
it will technically work but it will fight the library at every turn —
get this orientation right before reaching for any specific API.
Three things follow from the inversion that are worth internalising:
- Routes are data, not behaviour. A
KaiselRoute subclass holds
fields. Equality comes from props. No build method on the route
itself; rendering is a separate pageBuilder function over the
sealed type.
- The stack is the state. Auth state, modal state, branch state —
all expressed as the stack. There's no parallel "is logged in?" flag
that the router consults; the router's stack either has
LoginRoute
or it has ShellHost and the cross-fade between them is a
transition, not a state machine elsewhere.
- Exhaustiveness is your friend. Dart 3's
switch over a sealed
type errors at compile time if you forget a variant. Every place that
handles AppRoute — page builders, codecs, transition wrappers —
gets this guarantee. Lean into it.
Deep-dive references
Read these only when the topic at hand demands the depth.
| File |
When to read |
| NAVIGATION.md |
Choosing between push, pushForResult, pop, back / historyGo, set, replaceTop, pushOrReplaceTop, run |
| SHELLS.md |
Branched shells with per-branch typing; single-branch shells; chrome builders |
| ADAPTIVE.md |
Adaptive page builders, absorbing pages, master-detail layouts |
| MODAL_FLOWS.md |
Typed modal flows via router.run<T>(...), nested flows, dismissal |
| TRANSITIONS.md |
Page transitions via pageWrapper; route-pair pattern matching |
| CODEC.md |
URL ↔ stack roundtripping; deep linking; browser back |
| GUARDS.md |
The guard pipeline; auth, feature flags, entitlement gating |
| MODULES.md |
Feature modules, RouteModule, modular codec composition |
| MIGRATION.md |
Converting an app from go_router, auto_route, or Flutter's Navigator (1.0 / 2.0) |
Key types
| Type |
Purpose |
KaiselRoute |
Base class for every route. Subclasses are sealed data carriers. |
KaiselRouterConfig<R> |
A RouterConfig bundling router + delegate (+ URL parser/provider when given a codec) for MaterialApp.router(routerConfig:). Hold as a top-level final; .router exposes the bundled KaiselRouter<R>. |
KaiselRouter<R> |
Holds the stack of routes for type R. Mutated via push, pushForResult<T>, pop, set, replaceTop, pushOrReplaceTop, run<T>. |
KaiselRouterDelegate<R> |
RouterDelegate that drives Flutter's Router from a KaiselRouter. Takes a builder, optional pageWrapper, optional modalBuilder. |
KaiselPageBuilder<R> |
Widget Function(BuildContext, R). Pattern-match on the route to produce the screen. |
KaiselPageWrapper<R> |
Page<Object?> Function(KaiselPageWrapperContext<R>). Wrap the widget in a Page subclass to pick a transition. |
KaiselModalBuilder |
Required when using run<T>. Describes how a modal flow's UI overlays the main stack. |
KaiselGuard<R> |
FutureOr<List<R>> Function(List<R> current, List<R> proposed). Filters every stack mutation. |
KaiselConfigCodec<R> |
URL ↔ KaiselConfig<R> mapping. The single place strings live. |
KaiselBranchedShell |
A shell with N branches, each with its own typed KaiselRouter. Per-branch state preserved by default. |
KaiselBranch<R> |
One branch inside a KaiselBranchedShell. Pass KaiselBranch.adaptive for absorbing pages. |
KaiselBranchSpec<R> |
Declarative branch for KaiselBranchedShell.specs. lazy: true builds branches on first visit (kept alive); KaiselBranchSpec.deferred(loadLibrary:, placeholder:, errorBuilder:) code-splits a branch behind a deferred as import. |
KaiselModalRoute<T> |
Abstract base for routes used with run<T>. Carries the typed completion contract. |
1. Defining routes
sealed class AppRoute extends KaiselRoute {
const AppRoute();
}
final class Home extends AppRoute {
const Home();
}
final class ProductList extends AppRoute {
const ProductList({this.category});
final String? category;
@override
List<Object?> get props => [category];
}
final class ProductDetail extends AppRoute {
const ProductDetail(this.id);
final String id;
@override
List<Object?> get props => [id];
}
Rules:
- Every route has a
const constructor when it can. Routes without
parameters are const; routes with parameters are const whenever
their fields are themselves const-compatible.
- Override
props whenever the route has fields. Equality comes from
props; without it, two ProductDetail('sku-42') instances are
unequal and the stack will treat them as distinct entries.
- Sealed type at the root. Pattern matching downstream depends on
exhaustiveness, which requires the base to be
sealed.
2. Wiring up the router
Hold a KaiselRouterConfig<R> as a top-level final and hand it
straight to MaterialApp.router(routerConfig:). It bundles the router,
the delegate, and — when you give it a codec: — the URL parser and a
PlatformRouteInformationProvider. No StatefulWidget, no manual
delegate, no hand-rolled parser, no dispose.
final _config = KaiselRouterConfig<AppRoute>(
initial: const Home(),
builder: (context, route) => switch (route) {
Home() => const HomeScreen(),
ProductList(:final category) => ProductListScreen(category: category),
ProductDetail(:final id) => ProductDetailScreen(id: id),
},
// optional: guards:, pageWrapper:, modalBuilder:, observers:, codec:, fallback:
);
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(routerConfig: _config);
}
}
Omit codec: and you get a URL-less, delegate-only app. Pass codec:
(plus an optional fallback:) and the config wires the
KaiselRouteInformationParser and a PlatformRouteInformationProvider
for you — the app is URL-addressable. The bundled router is reachable as
_config.router (a KaiselRouter<AppRoute>) for imperative navigation
outside the widget tree. Call _config.dispose() only when a State
owns its lifecycle; a top-level final lives for the whole app.
For a raw GlobalKey<NavigatorState> (a third-party SDK, or Navigator.of
overlays without a BuildContext), pass navigatorKey: to the config or read
_config.navigatorKey / _config.navigator. For navigation, prefer
_config.router — the key is for raw navigator access only.
Navigator observers. Pass observers: () => [MyAnalyticsObserver()] to
attach NavigatorObservers (analytics, Sentry, RouteObserver). It's a
builder, not a list: a NavigatorObserver belongs to a single
Navigator, and kaisel has many — the main stack plus one per shell branch,
module, and active flow — so the builder is called once per navigator to
give each its own fresh instance (return new instances each call). That means
one observer per tab in a shell app; for a single unified "current screen"
stream instead, listen to the router(s) directly — the stack is observable
state (router.addListener(...)).
Observers read route.settings.name; kaisel sets it from each route's
routeName getter (defaults to the runtime type, e.g. 'ProductDetail'; named
routeName not name to avoid clashing with a domain field) and puts the route
in settings.arguments. Override routeName with a string literal for a
custom screen name — and you must, for stable names under --obfuscate (the
runtime type name is minified).
The switch is exhaustive. Add a new sealed variant and the compiler
points at every page builder, codec, and transition wrapper that needs
to handle it. That's the type safety the library is designed to give
you in load-bearing form, not just on paper.
Lower tier — the explicit form. Constructing a KaiselRouter, a
KaiselRouterDelegate, and a KaiselRouteInformationParser by hand
still works, and is the right tool when a State must own each piece's
lifecycle:
class _AppState extends State<App> {
late final KaiselRouter<AppRoute> _router;
late final KaiselRouterDelegate<AppRoute> _delegate;
@override
void initState() {
super.initState();
_router = KaiselRouter<AppRoute>(initial: const Home());
_delegate = KaiselRouterDelegate<AppRoute>(
router: _router,
builder: (context, route) => switch (route) {
Home() => const HomeScreen(),
ProductList(:final category) => ProductListScreen(category: category),
ProductDetail(:final id) => ProductDetailScreen(id: id),
},
);
}
@override
void dispose() {
_delegate.dispose();
_router.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerDelegate: _delegate,
routeInformationParser: _NoopParser(_router),
);
}
}
3. Navigating
The idiomatic default is the typed context.router<R>() — the verb is then
compile-checked against the family, so a wrong-family route is a compile
error, and you also get the full KaiselRouter<R> surface (stack, pop,
run, …):
// From any widget inside the delegate's tree:
context.router<AppRoute>().push(const ProductDetail('sku-42'));
context.router<AppRoute>().pop();
context.router<AppRoute>().replaceTop(const ProductList());
context.router<AppRoute>().set(const [Home(), ProductList()]);
final result = await context.router<AppRoute>().run<bool>(const ConfirmFlow());
context.router<R>() resolves to the nearest enclosing router — the modal
flow's router if inside a flow, the branch's router if inside a shell branch,
otherwise the main router. The type parameter disambiguates which family.
For brevity, the terse context.* verbs drop the type parameter:
context.push(const ProductDetail('sku-42'));
context.pop();
context.pushOrReplaceTop(const ProductDetail('sku-99'));
final quantity = await context.run<int>(const AddCardFlow());
These resolve the nearest router whose route type accepts the argument by
walking up the tree at runtime. The deliberate trade: a wrong-family route
throws at runtime rather than failing to compile — so reach for them when
the terseness clearly earns that trade (a single-router screen, say).
push/pop/replaceTop/pushOrReplaceTop/set are non-generic; only
run<T> carries a result type.
For decisions between push, replaceTop, pushOrReplaceTop, set,
and run<T>, read NAVIGATION.md.
4. Parity callout
Be honest about gaps before assuming kaisel can drop into any existing
codebase as a one-for-one replacement.
- Browser back integration on the web. Works via the codec, but less
polished than go_router's native integration. Test on a migration
branch if web is the primary target.
- Pre-built page transitions. No library of named transitions. Wire
them via the
pageWrapper mechanism — see
TRANSITIONS.md.
See each package's CHANGELOG.md for current status.
5. Adding a new screen — checklist
- Define the route. Extend the sealed base, add
const constructor,
override props if it has fields.
- Handle it in the page builder. Add a
switch arm pattern-matching
the new variant. The compiler will already be telling you the existing
builder is non-exhaustive.
- Add it to the codec if the route should be deep-linkable. Update
both
decode (URL → route) and encode (route → URL). See
CODEC.md.
- Add a guard rule if access to this screen is conditional. See
GUARDS.md.
- Custom transition? Update the
pageWrapper with a new pattern arm.
See TRANSITIONS.md.
Common mistakes
| Mistake |
Fix |
Forgetting props on a route with fields |
Override List<Object?> get props => [...]. Without it, ProductDetail('a') != ProductDetail('a'), breaking equality-based stack operations. |
Treating push of same-type-on-top as the right call in adaptive layouts |
Use pushOrReplaceTop. Otherwise selecting a different detail stacks duplicates instead of swapping in place. See ADAPTIVE.md. |
Pushing a KaiselModalRoute<T> onto the main stack via push |
Use run<T>(...). Pushing it "works" mechanically but loses the typed completion contract. See MODAL_FLOWS.md. |
Using is checks inside a switch arm instead of pattern destructuring |
Replace if (route is ProductDetail) { route.id } with case ProductDetail(:final id):. The compiler enforces exhaustiveness when you do this. |
Calling context.router<AppRoute>() from inside a shell branch and expecting the branch's router |
The resolver returns the nearest router. From inside a branch, pass the branch's specific type: context.router<ProductRoute>(). |
| Holding stale references to routers after the shell disposes |
Don't store KaiselRouter instances outside their owning StatefulWidget's state. The shell's dispose cleans them up; references held elsewhere become stale notifiers. |
Trying to compose two MaterialApp.routers side-by-side to migrate incrementally from another router |
Don't. The migration is big-bang. See packages/kaisel/doc/migration/README.md. |
1---2name: kaisel3description: Implement and reason about Flutter routing using the kaisel library — a Dart 3-native router built on sealed routes, pattern matching, and a stack-as-state model. Use this skill when the user is using or considering kaisel, migrating to it from go_router, auto_route, or Flutter's Navigator (named, imperative, or 2.0), or when their code shows kaisel imports. Triggers on: kaisel, KaiselRoute, KaiselRouter, KaiselRouterDelegate, KaiselBranchedShell, KaiselBranch, KaiselShell, KaiselPageScope, KaiselModalRoute, KaiselConfigCodec, KaiselStackCodec, KaiselGuard, KaiselAbsorbingPage, KaiselStandalonePage, KaiselMasterDetailScaffold, KaiselPageWrapper, KaiselPageWrapperContext, KaiselStackContext, BranchedShellRouter, ShellRouter, RouteModule, ModuleStackCodec, package:kaisel, push, pushForResult, pop, back, historyGo, pushOrReplaceTop, replaceTop, set, run, pageWrapper, modalBuilder, chromeBuilder, sealed route, codec, route guard, modal flow, branched shell, adaptive layout, master-detail, route transition, deep l4---56# kaisel skill78This project uses [kaisel](https://pub.dev/packages/kaisel) — a Flutter9router built on **sealed routes**, **pattern matching**, and a10**stack-as-state** model. No string paths. No code generation.1112## The inversion (read this first)1314Before any API, the mental model: **sealed routes are the source of truth;15URLs are a serialization layer produced by a codec when needed**. This16inverts the assumption you'd carry over from go_router (paths are17canonical) or from auto_route (annotations generate the typed surface).18In kaisel, the typed `sealed class AppRoute` is primary. URLs come out of19a codec. If you write code that treats URLs as the primary representation,20it will technically work but it will fight the library at every turn —21get this orientation right before reaching for any specific API.2223Three things follow from the inversion that are worth internalising:24251. **Routes are data, not behaviour.** A `KaiselRoute` subclass holds26 fields. Equality comes from `props`. No `build` method on the route27 itself; rendering is a separate `pageBuilder` function over the28 sealed type.292. **The stack is the state.** Auth state, modal state, branch state —30 all expressed as the stack. There's no parallel "is logged in?" flag31 that the router consults; the router's stack either has `LoginRoute`32 or it has `ShellHost` and the cross-fade between them is a33 transition, not a state machine elsewhere.343. **Exhaustiveness is your friend.** Dart 3's `switch` over a sealed35 type errors at compile time if you forget a variant. Every place that36 handles `AppRoute` — page builders, codecs, transition wrappers —37 gets this guarantee. Lean into it.3839## Deep-dive references4041Read these only when the topic at hand demands the depth.4243| File | When to read |44|:-----|:-------------|45| [NAVIGATION.md](./NAVIGATION.md) | Choosing between `push`, `pushForResult`, `pop`, `back` / `historyGo`, `set`, `replaceTop`, `pushOrReplaceTop`, `run` |46| [SHELLS.md](./SHELLS.md) | Branched shells with per-branch typing; single-branch shells; chrome builders |47| [ADAPTIVE.md](./ADAPTIVE.md) | Adaptive page builders, absorbing pages, master-detail layouts |48| [MODAL_FLOWS.md](./MODAL_FLOWS.md) | Typed modal flows via `router.run<T>(...)`, nested flows, dismissal |49| [TRANSITIONS.md](./TRANSITIONS.md) | Page transitions via `pageWrapper`; route-pair pattern matching |50| [CODEC.md](./CODEC.md) | URL ↔ stack roundtripping; deep linking; browser back |51| [GUARDS.md](./GUARDS.md) | The guard pipeline; auth, feature flags, entitlement gating |52| [MODULES.md](./MODULES.md) | Feature modules, `RouteModule`, modular codec composition |53| [MIGRATION.md](./MIGRATION.md) | Converting an app from go_router, auto_route, or Flutter's Navigator (1.0 / 2.0) |5455## Key types5657| Type | Purpose |58|:-----|:--------|59| `KaiselRoute` | Base class for every route. Subclasses are sealed data carriers. |60| `KaiselRouterConfig<R>` | A `RouterConfig` bundling router + delegate (+ URL parser/provider when given a `codec`) for `MaterialApp.router(routerConfig:)`. Hold as a top-level `final`; `.router` exposes the bundled `KaiselRouter<R>`. |61| `KaiselRouter<R>` | Holds the stack of routes for type `R`. Mutated via `push`, `pushForResult<T>`, `pop`, `set`, `replaceTop`, `pushOrReplaceTop`, `run<T>`. |62| `KaiselRouterDelegate<R>` | `RouterDelegate` that drives Flutter's `Router` from a `KaiselRouter`. Takes a `builder`, optional `pageWrapper`, optional `modalBuilder`. |63| `KaiselPageBuilder<R>` | `Widget Function(BuildContext, R)`. Pattern-match on the route to produce the screen. |64| `KaiselPageWrapper<R>` | `Page<Object?> Function(KaiselPageWrapperContext<R>)`. Wrap the widget in a `Page` subclass to pick a transition. |65| `KaiselModalBuilder` | Required when using `run<T>`. Describes how a modal flow's UI overlays the main stack. |66| `KaiselGuard<R>` | `FutureOr<List<R>> Function(List<R> current, List<R> proposed)`. Filters every stack mutation. |67| `KaiselConfigCodec<R>` | URL ↔ `KaiselConfig<R>` mapping. The single place strings live. |68| `KaiselBranchedShell` | A shell with N branches, each with its own typed `KaiselRouter`. Per-branch state preserved by default. |69| `KaiselBranch<R>` | One branch inside a `KaiselBranchedShell`. Pass `KaiselBranch.adaptive` for absorbing pages. |70| `KaiselBranchSpec<R>` | Declarative branch for `KaiselBranchedShell.specs`. `lazy: true` builds branches on first visit (kept alive); `KaiselBranchSpec.deferred(loadLibrary:, placeholder:, errorBuilder:)` code-splits a branch behind a `deferred as` import. |71| `KaiselModalRoute<T>` | Abstract base for routes used with `run<T>`. Carries the typed completion contract. |7273## 1. Defining routes7475```dart76sealed class AppRoute extends KaiselRoute {77 const AppRoute();78}7980final class Home extends AppRoute {81 const Home();82}8384final class ProductList extends AppRoute {85 const ProductList({this.category});86 final String? category;87 @override88 List<Object?> get props => [category];89}9091final class ProductDetail extends AppRoute {92 const ProductDetail(this.id);93 final String id;94 @override95 List<Object?> get props => [id];96}97```9899**Rules:**100101- Every route has a `const` constructor when it can. Routes without102 parameters are `const`; routes with parameters are `const` whenever103 their fields are themselves `const`-compatible.104- Override `props` whenever the route has fields. Equality comes from105 `props`; without it, two `ProductDetail('sku-42')` instances are106 unequal and the stack will treat them as distinct entries.107- Sealed type at the root. Pattern matching downstream depends on108 exhaustiveness, which requires the base to be `sealed`.109110## 2. Wiring up the router111112Hold a `KaiselRouterConfig<R>` as a top-level `final` and hand it113straight to `MaterialApp.router(routerConfig:)`. It bundles the router,114the delegate, and — when you give it a `codec:` — the URL parser and a115`PlatformRouteInformationProvider`. No `StatefulWidget`, no manual116delegate, no hand-rolled parser, no `dispose`.117118```dart119final _config = KaiselRouterConfig<AppRoute>(120 initial: const Home(),121 builder: (context, route) => switch (route) {122 Home() => const HomeScreen(),123 ProductList(:final category) => ProductListScreen(category: category),124 ProductDetail(:final id) => ProductDetailScreen(id: id),125 },126 // optional: guards:, pageWrapper:, modalBuilder:, observers:, codec:, fallback:127);128129class App extends StatelessWidget {130 const App({super.key});131 @override132 Widget build(BuildContext context) {133 return MaterialApp.router(routerConfig: _config);134 }135}136```137138Omit `codec:` and you get a URL-less, delegate-only app. Pass `codec:`139(plus an optional `fallback:`) and the config wires the140`KaiselRouteInformationParser` and a `PlatformRouteInformationProvider`141for you — the app is URL-addressable. The bundled router is reachable as142`_config.router` (a `KaiselRouter<AppRoute>`) for imperative navigation143outside the widget tree. Call `_config.dispose()` only when a `State`144owns its lifecycle; a top-level `final` lives for the whole app.145146For a raw `GlobalKey<NavigatorState>` (a third-party SDK, or `Navigator.of`147overlays without a `BuildContext`), pass `navigatorKey:` to the config or read148`_config.navigatorKey` / `_config.navigator`. For navigation, prefer149`_config.router` — the key is for raw navigator access only.150151**Navigator observers.** Pass `observers: () => [MyAnalyticsObserver()]` to152attach `NavigatorObserver`s (analytics, Sentry, `RouteObserver`). It's a153**builder**, not a list: a `NavigatorObserver` belongs to a single154`Navigator`, and kaisel has many — the main stack plus one per shell branch,155module, and active flow — so the builder is called **once per navigator** to156give each its own fresh instance (return new instances each call). That means157one observer per tab in a shell app; for a single unified "current screen"158stream instead, listen to the router(s) directly — the stack is observable159state (`router.addListener(...)`).160161Observers read `route.settings.name`; kaisel sets it from each route's162`routeName` getter (defaults to the runtime type, e.g. `'ProductDetail'`; named163`routeName` not `name` to avoid clashing with a domain field) and puts the route164in `settings.arguments`. Override `routeName` with a string **literal** for a165custom screen name — and you must, for stable names under `--obfuscate` (the166runtime type name is minified).167168The `switch` is exhaustive. Add a new sealed variant and the compiler169points at every page builder, codec, and transition wrapper that needs170to handle it. That's the type safety the library is designed to give171you in load-bearing form, not just on paper.172173**Lower tier — the explicit form.** Constructing a `KaiselRouter`, a174`KaiselRouterDelegate`, and a `KaiselRouteInformationParser` by hand175still works, and is the right tool when a `State` must own each piece's176lifecycle:177178```dart179class _AppState extends State<App> {180 late final KaiselRouter<AppRoute> _router;181 late final KaiselRouterDelegate<AppRoute> _delegate;182183 @override184 void initState() {185 super.initState();186 _router = KaiselRouter<AppRoute>(initial: const Home());187 _delegate = KaiselRouterDelegate<AppRoute>(188 router: _router,189 builder: (context, route) => switch (route) {190 Home() => const HomeScreen(),191 ProductList(:final category) => ProductListScreen(category: category),192 ProductDetail(:final id) => ProductDetailScreen(id: id),193 },194 );195 }196197 @override198 void dispose() {199 _delegate.dispose();200 _router.dispose();201 super.dispose();202 }203204 @override205 Widget build(BuildContext context) {206 return MaterialApp.router(207 routerDelegate: _delegate,208 routeInformationParser: _NoopParser(_router),209 );210 }211}212```213214## 3. Navigating215216The idiomatic default is the typed `context.router<R>()` — the verb is then217compile-checked against the family, so a wrong-family route is a compile218error, and you also get the full `KaiselRouter<R>` surface (`stack`, `pop`,219`run`, …):220221```dart222// From any widget inside the delegate's tree:223context.router<AppRoute>().push(const ProductDetail('sku-42'));224context.router<AppRoute>().pop();225context.router<AppRoute>().replaceTop(const ProductList());226context.router<AppRoute>().set(const [Home(), ProductList()]);227final result = await context.router<AppRoute>().run<bool>(const ConfirmFlow());228```229230`context.router<R>()` resolves to the nearest enclosing router — the modal231flow's router if inside a flow, the branch's router if inside a shell branch,232otherwise the main router. The type parameter disambiguates which family.233234For brevity, the terse `context.*` verbs drop the type parameter:235236```dart237context.push(const ProductDetail('sku-42'));238context.pop();239context.pushOrReplaceTop(const ProductDetail('sku-99'));240final quantity = await context.run<int>(const AddCardFlow());241```242243These resolve the nearest router whose route type *accepts* the argument by244walking up the tree at runtime. The deliberate trade: a wrong-family route245throws at **runtime** rather than failing to compile — so reach for them when246the terseness clearly earns that trade (a single-router screen, say).247`push`/`pop`/`replaceTop`/`pushOrReplaceTop`/`set` are non-generic; only248`run<T>` carries a result type.249250> For decisions between `push`, `replaceTop`, `pushOrReplaceTop`, `set`,251> and `run<T>`, read [NAVIGATION.md](./NAVIGATION.md).252253## 4. Parity callout254255Be honest about gaps before assuming kaisel can drop into any existing256codebase as a one-for-one replacement.257258- **Browser back integration on the web.** Works via the codec, but less259 polished than go_router's native integration. Test on a migration260 branch if web is the primary target.261- **Pre-built page transitions.** No library of named transitions. Wire262 them via the `pageWrapper` mechanism — see263 [TRANSITIONS.md](./TRANSITIONS.md).264265See each package's `CHANGELOG.md` for current status.266267## 5. Adding a new screen — checklist2682691. **Define the route.** Extend the sealed base, add `const` constructor,270 override `props` if it has fields.2712. **Handle it in the page builder.** Add a `switch` arm pattern-matching272 the new variant. The compiler will already be telling you the existing273 builder is non-exhaustive.2743. **Add it to the codec** if the route should be deep-linkable. Update275 both `decode` (URL → route) and `encode` (route → URL). See276 [CODEC.md](./CODEC.md).2774. **Add a guard rule** if access to this screen is conditional. See278 [GUARDS.md](./GUARDS.md).2795. **Custom transition?** Update the `pageWrapper` with a new pattern arm.280 See [TRANSITIONS.md](./TRANSITIONS.md).281282## Common mistakes283284| Mistake | Fix |285|:--------|:----|286| Forgetting `props` on a route with fields | Override `List<Object?> get props => [...]`. Without it, `ProductDetail('a') != ProductDetail('a')`, breaking equality-based stack operations. |287| Treating `push` of same-type-on-top as the right call in adaptive layouts | Use `pushOrReplaceTop`. Otherwise selecting a different detail stacks duplicates instead of swapping in place. See [ADAPTIVE.md](./ADAPTIVE.md). |288| Pushing a `KaiselModalRoute<T>` onto the main stack via `push` | Use `run<T>(...)`. Pushing it "works" mechanically but loses the typed completion contract. See [MODAL_FLOWS.md](./MODAL_FLOWS.md). |289| Using `is` checks inside a switch arm instead of pattern destructuring | Replace `if (route is ProductDetail) { route.id }` with `case ProductDetail(:final id):`. The compiler enforces exhaustiveness when you do this. |290| Calling `context.router<AppRoute>()` from inside a shell branch and expecting the branch's router | The resolver returns the *nearest* router. From inside a branch, pass the branch's specific type: `context.router<ProductRoute>()`. |291| Holding stale references to routers after the shell disposes | Don't store `KaiselRouter` instances outside their owning `StatefulWidget`'s state. The shell's `dispose` cleans them up; references held elsewhere become stale notifiers. |292| Trying to compose two `MaterialApp.router`s side-by-side to migrate incrementally from another router | Don't. The migration is big-bang. See `packages/kaisel/doc/migration/README.md`. |