# Views and Layouts

- [Introduction](#introduction)
- [MagicStatefulView](#magicstatefulview)
- [View Lifecycle](#view-lifecycle)
- [State Rendering](#state-rendering)
- [Form Handling](#form-handling)
- [AppLayout](#applayout)
- [GuestLayout](#guestlayout)
- [Page Chrome](#page-chrome)
- [Wind UI System](#wind-ui-system)
- [Dark Mode](#dark-mode)
- [View Registry](#view-registry)
- [Layout Registry](#layout-registry)
- [Overriding the App Shell](#overriding-the-app-shell)
- [Feature-Gated Rendering](#feature-gated-rendering)
- [Zero Business Logic](#zero-business-logic)
- [Reusable Widgets](#reusable-widgets)

## Introduction

Magic Starter's view layer follows a strict separation of concerns: views handle rendering, controllers handle state and business logic. Every page-level view extends `MagicStatefulView<ControllerType>`, which binds the view to its controller singleton and provides lifecycle hooks. Two layout shells — `AppLayout` for authenticated pages and `GuestLayout` for auth pages — wrap all views. The entire UI is built with Wind UI components exclusively, with no direct Material widget usage beyond `Icons.*` references.

All views and layouts are registered in a string-keyed registry, making every screen overridable by the host application.

## MagicStatefulView

Page-level views extend `MagicStatefulView<ControllerType>` with a state class that extends `MagicStatefulViewState`:

```dart
class MagicStarterLoginView
    extends MagicStatefulView<MagicStarterAuthController> {
  const MagicStarterLoginView({super.key});

  @override
  State<MagicStarterLoginView> createState() => _MagicStarterLoginViewState();
}

class _MagicStarterLoginViewState extends MagicStatefulViewState<
    MagicStarterAuthController, MagicStarterLoginView> {

  @override
  void onInit() {
    controller.clearErrors();
    controller.setEmpty();
  }

  @override
  Widget build(BuildContext context) {
    return controller.renderState(
      (_) => _buildForm(),
      onEmpty: _buildForm(),
      onError: (message) => _buildForm(errorMessage: message),
    );
  }
}
```

The `MagicStatefulViewState` base class provides access to the controller singleton via `controller` and lifecycle hooks via `onInit()` and `onClose()`.

> [!NOTE]
> Views are NOT widgets like `MSUserProfileDropdown` — those extend `StatelessWidget` or `StatefulWidget` directly. Only full-page views that need a controller binding use `MagicStatefulView`.

## View Lifecycle

Two lifecycle hooks are available in `MagicStatefulViewState`:

**`onInit()`** — Called once when the state is initialized. Use it to clear errors, set initial state, or trigger data fetches:

```dart
@override
void onInit() {
  controller.clearErrors();
  controller.setEmpty();
}
```

**`onClose()`** — Called when the state is disposed. Use it to clean up form data or local notifiers:

```dart
@override
void onClose() => form.dispose();
```

> [!TIP]
> For views with multiple forms (like the profile settings page), dispose all `MagicFormData` instances in `onClose()`. Field names must not collide across forms in the same view.

## State Rendering

Views delegate state-based rendering to `controller.renderState()`, which selects the appropriate builder based on the controller's current `MagicStateMixin` state:

```dart
@override
Widget build(BuildContext context) {
  return controller.renderState(
    (_) => _buildForm(),
    onEmpty: _buildForm(),
    onError: (message) => _buildForm(errorMessage: message),
  );
}
```

The first positional argument handles the success state (receives the state value). Named parameters:

| Parameter | When | Typical use |
|-----------|------|-------------|
| `onEmpty` | No data loaded yet | Show empty form or placeholder |
| `onError` | Controller called `setError()` | Show form with error banner |

Extract loading state at the top of your build method for conditional UI:

```dart
Widget _buildForm({String? errorMessage}) {
  final isLoading = controller.isLoading;

  return WDiv(
    className: 'flex flex-col gap-4 p-6',
    children: [
      if (errorMessage != null)
        WText(errorMessage, className: 'text-sm text-red-500'),
      // ... form fields
      WButton(
        onTap: isLoading ? null : _submit,
        className: 'w-full py-3 rounded-lg bg-primary text-white',
        child: WText(isLoading ? trans('common.loading') : trans('auth.login')),
      ),
    ],
  );
}
```

## Form Handling

Forms are declared as `MagicFormData` instances with empty string defaults for text fields and typed defaults for non-string fields:

```dart
late final form = MagicFormData(
  {
    'email': '',
    'phone': '',
    'password': '',
    'remember_me': false,
  },
  controller: controller,
);
```

Access values with type-safe extraction:

```dart
// String fields
final email = form.get('email');

// Non-string fields — use value<T>()
final rememberMe = form.value<bool>('remember_me');
```

Form fields render with `WFormInput`:

```dart
WFormInput(
  form: form,
  name: 'email',
  label: trans('auth.email'),
  keyboardType: TextInputType.emailAddress,
)
```

Validate and submit:

```dart
Future<void> _submit() async {
  if (!form.validate()) return;
  await controller.doLogin(
    email: form.get('email'),
    password: form.get('password'),
    rememberMe: form.value<bool>('remember_me'),
  );
}
```

> [!NOTE]
> Route values needed before calling the controller (like password reset tokens) should be extracted in the view's `didChangeDependencies`, through `MagicRouter.instance.queryParameterOf(context, 'token')` or `pathParameterOf(context, ...)`, which answer for the page the view sits in.

## AppLayout

`MagicStarterAppLayout` is the authenticated shell used for all protected pages. It provides a responsive layout with:

- **Desktop:** Sidebar with brand, team selector, navigation items, and user menu
- **Mobile:** Hamburger menu with drawer, header bar, and bottom navigation

```dart
MagicStarter.view.registerLayout(
  'layout.app',
  (child) => MagicStarterAppLayout(child: child),
);
```

The layout automatically starts notification polling when mounted (if notifications are enabled) and rebuilds on auth state changes via `MagicStarterAppLayout.refreshNotifier` and `Auth.stateNotifier`.

Key customization points:

```dart
// Custom header — replaces the default mobile header
MagicStarter.useHeader((context, isDesktop) {
  return MyCustomHeader(showMenuButton: !isDesktop);
});

// Custom navigation items
MagicStarter.useNavigation(
  mainItems: [
    MagicStarterNavItem(
      icon: Icons.dashboard_outlined,
      labelKey: 'nav.dashboard',
      path: '/',
    ),
  ],
  systemItems: [...],
  bottomItems: [...],
);

// Sidebar footer — custom widget between nav items and user menu
MagicStarter.useSidebarFooter((context) {
  return MyVersionBadge();
});

// Custom logo/brand — replaces default app name text
// MagicStarter.useNavigationTheme(brandBuilder: ...) handles this

// Custom navigation colors and brand
MagicStarter.useNavigationTheme(
  MagicStarterNavigationTheme(
    activeItemClassName:
        'active:text-amber-500 active:bg-amber-500/10 dark:active:text-amber-400 dark:active:bg-amber-400/10',
    brandBuilder: (context) => Image.asset('assets/logo.png', height: 28),
    bottomNavActiveClassName: 'active:text-amber-500 dark:active:text-amber-400',
    avatarClassName: 'bg-amber-500/10 dark:bg-amber-400/10',
  ),
);
```

> [!TIP]
> `MagicStarterAppLayout.refreshNotifier` is a static `ValueNotifier<int>` that triggers layout rebuilds. It is bumped automatically by auth state changes. Do not poke it manually unless you have a specific reason to force a layout rebuild.

### Where the sidebar starts, and where it grows labels

Three fields on `MagicStarterLayoutTheme` decide the shell's shape. The first two are breakpoints, named as keys of the Wind theme's `screens` map (`sm md lg xl 2xl` by default) rather than as pixel counts; the third is a width in logical pixels:

| Field | Default | What it decides |
|-------|---------|-----------------|
| `navigationBreakpoint` | `'lg'` | Breakpoint from which the sidebar replaces the drawer and the bottom bar |
| `sidebarExpandedBreakpoint` | `'lg'` | Breakpoint from which the sidebar carries labels beside its icons |
| `sidebarCompactWidth` | `80` | Width in logical pixels of the sidebar while it is compact |

Between the two breakpoints the sidebar renders compact: `sidebarCompactWidth` wide, icons only, and every text label dropped, because a label at that width would be clipped rather than shortened. That is the app name in the brand bar, the nav item labels, the system section's name, and the user's name and email, which falls back to the same avatar trigger the mobile header mounts. What a host supplies itself, `brandBuilder` and `sidebarFooterBuilder`, is not reshaped: only the host knows whether its own widget fits an icon column. The rail's brand bar does centre the brand on the icon line, by appending `justify-around` to `brandBarClassName`, which with one child centres it while leaving Wind to bound a brand wider than the rail. A host whose wordmark does not fit sets `MagicStarterNavigationTheme.compactBrandBuilder` to a glyph, and the compact rail shows that instead.

A name the Wind theme does not carry throws a `StateError` naming the field and listing the valid keys. `wScreenIs` answers false for an unknown key, so a typo would otherwise pin the shell to its narrow form at every width with nothing to read.

The shipped defaults leave the two breakpoints equal, so the compact form never fires until a host lowers one of them:

```dart
// An icon rail from 640 px, labels from 1024 px.
MagicStarter.useLayoutTheme(
  const MagicStarterLayoutTheme(
    navigationBreakpoint: 'sm',
    sidebarExpandedBreakpoint: 'lg',
  ),
);
```

Take care lowering `sidebarCompactWidth`. Its floor is the compact team selector rather than the nav icon: `MSTeamSelector`'s compact trigger is `mx-3 p-2` around a `w-8` avatar, which is 72 logical pixels, and `sidebarClassName`'s own `border-r` takes one more out of the box. 72 was measured overflowing by exactly 1 pixel, which is why the default is 80.

### Letting the viewer collapse the sidebar

Two more fields hand the compact form to the viewer on a window wide enough for labels:

| Field | Default | What it decides |
|-------|---------|-----------------|
| `sidebarCollapsible` | `false` | Whether a toggle above the user menu collapses the labelled sidebar to its compact form and back |
| `sidebarCollapsedByDefault` | `false` | Whether a collapsible sidebar starts compact before the viewer has chosen |

The toggle appears only at or above `sidebarExpandedBreakpoint`. Below it the sidebar is compact because the window cannot spare `sidebarWidth`, so a toggle there would promise an expansion the shell will not make. `sidebarCollapsedByDefault` is ignored unless the sidebar is collapsible: a default the viewer cannot undo would be a rail they are stuck with, and `sidebarExpandedBreakpoint` already expresses that.

The viewer's choice is remembered through Magic's `Cache` under `magic_starter.sidebar_collapsed`, written with a ten year TTL because the cache has no `forever` and its own default of an hour would hand the viewer the default back the next morning. A host that binds no cache keeps the choice in memory for the life of the shell. A remembered choice wins over `sidebarCollapsedByDefault`; changing the default moves only the viewers who never touched the toggle.

The toggle reads `nav.collapse_sidebar` and `nav.expand_sidebar`, and names itself with the second through `semanticLabel` while it is icon-only. Both keys ship in the install stub; a host installed before 0.0.34 adds them to its own language files.

```dart
// Icons only until the viewer asks for labels, with a glyph for the rail.
MagicStarter.useLayoutTheme(
  const MagicStarterLayoutTheme(
    sidebarCollapsible: true,
    sidebarCollapsedByDefault: true,
  ),
);

MagicStarter.useNavigationTheme(
  MagicStarterNavigationTheme(
    brandBuilder: (context) => const MyLogoLockup(),
    compactBrandBuilder: (context) => const MyLogoGlyph(),
  ),
);
```

### A focus ring for a keyboard or a remote

`MagicStarterNavigationTheme.focusItemClassName` is applied to every navigation item the shell draws: the sidebar and drawer items beside their active and hover classNames, and the bottom bar's items too, so a small window is lit the same way a wide one is. Each token carries the `focus:` prefix; the item already sits inside a `WAnchor`, so Wind lights them the moment it holds primary focus. It defaults to `''`, which is the shipped shell's no-ring behaviour, and an app driven by arrow keys or a television remote wants it set:

```dart
MagicStarter.useNavigationTheme(
  const MagicStarterNavigationTheme(
    focusItemClassName: 'focus:ring-2 focus:ring-primary focus:ring-offset-2',
  ),
);
```

### A live count on a destination

A `MagicStarterNavItem` takes a `badgeCount`, a `ValueListenable<int?>`. The shell listens to it, so the host moves the number by updating the notifier and never rebuilds the navigation config: the badge appears, changes and goes away on its own.

```dart
// Owned by whatever loads the number: a controller, a repository, a stream.
final ValueNotifier<int?> unticked = ValueNotifier<int?>(null);

MagicStarter.useNavigation(
  mainItems: [
    MagicStarterNavItem(
      icon: Icons.shopping_cart_outlined,
      labelKey: 'nav.shopping',
      path: '/shopping',
      badgeCount: unticked,
      badgeLabelKey: 'nav.shopping_open',
    ),
  ],
);

unticked.value = 4; // the badge reads 4
unticked.value = 0; // and is gone
```

The badge is hidden while the value is `null` or not above zero, and reads `99+` above 99. The sidebar and the drawer draw it at the trailing edge of the row, the compact rail and the bottom bar on the corner of the icon; it takes no room from the item, so nothing moves when it shows. On the compact rail the pill overhangs the icon by 16 px, so a `sidebarCompactWidth` under 52 would cut it at the edge of the rail; the default is 80. The profile menu ignores it.

A badge is not colour alone. The number is drawn, and a screen reader gets a sentence in place of it, added to the item's own label: `badgeLabelKey` names a catalogue line with both plural forms and a `:count` placeholder, read with the real count even above 99. It defaults to `nav.badge_count`, which the install stub ships (`"One item|:count items"`); name your own key when the number counts something specific:

```json
{
  "nav": {
    "shopping_open": "One line to buy|:count lines to buy"
  }
}
```

An item holding a notifier is not `const`, so the list that carries it is `final`; items without a badge stay `const`.

The look is the neutral `MSBadge`. Recolour it through the navigation theme, whose tokens are appended to the badge's own, with a `dark:` peer on every colour (a semantic alias such as `bg-primary` already carries one):

```dart
MagicStarter.useNavigationTheme(
  const MagicStarterNavigationTheme(
    badgeClassName: 'bg-primary text-on-primary',
  ),
);
```

### The box the route child is handed

The shell wraps the route child in one `WDiv`, and two fields on `MagicStarterLayoutTheme` own it:

| Field | Default | What it decides |
|-------|---------|-----------------|
| `contentClassName` | `'flex-1 min-h-0'` | The content area's own className |
| `contentScrollPrimary` | `false` | Whether that area attaches to the ambient `PrimaryScrollController` |

The default is a box of bounded height that does not scroll, so **each routed page scrolls itself**. Every view this package ships already does, through `MSPageScaffold` (which brings its own `SingleChildScrollView(primary: false)`) or, for the invitation card, its own scroll view. A fill-shaped screen, an `h-full` column whose body takes the slack and scrolls inside itself (a television guide, a media catalogue), lays out under it as well.

A page of your own that is taller than the window needs its own vertical scroll: put it through `MSPageScaffold`, or wrap it:

```dart
MagicRoute.page(
  '/reports',
  // `primary: false`, as MSPageScaffold does: the page owns its controller.
  () => const SingleChildScrollView(primary: false, child: ReportsView()),
);
```

> [!WARNING]
> The default used to be `'flex-1 overflow-y-auto'` with `contentScrollPrimary: true`, and you can still set that pair. Know what it costs before you do. In a go_router shell the route child is the nested Navigator, so a scrolling content box scrolls the Navigator and lays its Overlay out under an unbounded height. A page left under a `.stacked()` route is then never laid out again, and its second rebuild while hidden (a controller notify, a resize, a locale switch) fails `_debugRelayoutBoundaryAlreadyMarkedNeedsLayout` in debug and leaves the tree inconsistent from there. The same setting also gives a fill-shaped screen an unbounded height, which it cannot lay out. See [#160](https://github.com/fluttersdk/magic_starter/issues/160) and [flutter/flutter#193247](https://github.com/flutter/flutter/issues/193247).

Set the two fields together. Wind reads `scrollPrimary` only where it builds a scroll view, so beside a non-scrolling className it claims nothing and is inert; it matters the moment the className scrolls. Two `primary: true` scrollables in one tree contend for the single controller.

### Hiding the bottom nav on a page

Wrap the page, not the route group, in `MagicStarterHideBottomNav`:

```dart
MagicRoute.page(
  '/chat/:id',
  () => MagicStarterHideBottomNav(child: ChatView()),
).stacked();
```

The phone's bottom bar collapses in step with that page's transition and returns as it leaves, an iOS back swipe included. The page stays in the same shell as the rest of the app, so nothing is rebuilt on the way in. A page registers once magic's route middleware has mounted it, so the bar starts collapsing a frame or two into the transition.

A host layout that draws its own bottom nav joins in with two widgets: `MagicStarterBottomNavScope` at the layout's root, and its nav inside `MagicStarterBottomNavSlot`:

```dart
class MyAppShell extends StatelessWidget {
  const MyAppShell({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return MagicStarterBottomNavScope(
      child: Column(
        children: [
          Expanded(child: child),
          MagicStarterBottomNavSlot(child: MyBottomNav()),
        ],
      ),
    );
  }
}
```

The slot's height is the nav's times how much of it is showing, so the content above grows into the room the bar gives up. Read that fraction with `MagicStarterBottomNavScope.visibilityOf(context)` when the layout needs it, for instance to hand the collapsed bar's share of the bottom inset back to its content, as `MagicStarterAppLayout` does. Place the scope inside the layout, never around it: magic tells shells apart by the layout widget's runtime type.

### An immersive route

`MagicStarterHideChrome` drops all of the chrome, so a player, a viewfinder or a map paints the whole window:

```dart
MagicRoute.group(
  layout: (child) => MagicStarterHideChrome(
    child: MagicStarter.view.makeLayout('layout.app', child: child),
  ),
  layoutId: 'app.immersive',
  routes: () { ... },
);
```

No sidebar, no drawer, no header and no bottom bar. The route also gets the window with no safe-area inset, since a surface that sizes itself wants the whole of it.

The group needs an id of its own, and a separate id is a separate shell: entering the route builds this layout and leaving it builds the app shell again, so the layout's state (the sidebar's collapsed choice, notification polling, the auth listeners) is rebuilt on each crossing. That is intended for an immersive route, which has no chrome to carry across. A page that only wants the bottom bar gone belongs in the app shell under `MagicStarterHideBottomNav`.

## GuestLayout

`MagicStarterGuestLayout` is a minimal centered wrapper for authentication pages (login, register, forgot password, reset password). It constrains content to 480px max width and provides scrolling:

```dart
MagicStarter.view.registerLayout(
  'layout.guest',
  (child) => MagicStarterGuestLayout(child: child),
);
```

The layout uses `wColor()` for theme-aware background colors and wraps content in `WDiv(className: 'p-4 lg:p-8')` for responsive padding.

> [!NOTE]
> Guest routes use `RouteTransition.none` — there is no animation between auth screens (login to register, etc.). This is intentional for a seamless form-flow experience.

## Page Chrome

The layout shell owns the sidebar, the header, and the scroll region. What is left is page chrome: how wide the content column is, how far it sits from the edges, and where the page title goes. Two components own that, and every authenticated page goes through one of them.

`MSPageScaffold` is the full treatment. Give it a title and a list of sections, and it renders the page surface, its own vertical scroll, the shared content geometry, a unified `MSPageHeader`, and a `gap-6` column for the sections:

```dart
MSPageScaffold(
  title: trans('teams.settings'),
  subtitle: trans('teams.settings_subtitle'),
  children: [
    MSCard(title: 'General', child: generalForm),
    MSCard(title: 'Members', child: memberList),
  ],
)
```

A sub-page adds a back affordance, and a top-level page can put an action next to the title:

```dart
MSPageScaffold(
  title: trans('notifications.title'),
  actions: [markAllAsReadButton],
  children: [MSCard(noPadding: true, child: notificationList)],
)

MSPageScaffold(
  title: trans('profile.settings'),
  backLabel: trans('magic_starter.nav.settings'),
  backFallback: MagicStarterConfig.settingsHubRoute(),
  children: [profileForm],
)
```

`MSPageContainer` is the geometry alone: width cap, edge margins, vertical rhythm, and a horizontal safe-area guard. Reach for it when the page brings its own header or scrolls its own way, which is the usual shape in a host app:

```dart
MSPageContainer(
  children: [
    MSPageHeader(title: 'Monitors', actions: [newMonitorButton]),
    monitorTable,
  ],
)
```

Neither component decides the numbers. They read `MagicStarter.manager.pageContainerClassName`, which the host sets once (see [Page Geometry](https://magic.fluttersdk.com/packages/starter/architecture/manager#page-geometry)). That is what makes a starter account page and a host domain page line up inside the same shell.

> [!WARNING]
> Do not open a page with its own `WDiv(className: 'p-4 lg:p-6 flex flex-col gap-6')`. It looks harmless and it is how the settings, team, and notification pages ended up at three different widths in the same app: one capped at `max-w-7xl`, one at `max-w-6xl`, and one at nothing at all. A page that needs to tune one axis passes `className` to `MSPageContainer` instead.

## Wind UI System

All Magic Starter views use Wind UI components exclusively. The framework provides Tailwind-like utility classes through a `className` property:

| Component | Purpose | Example |
|-----------|---------|---------|
| `WDiv` | Container/layout | `WDiv(className: 'flex flex-col gap-4 p-6')` |
| `WText` | Typography | `WText('Title', className: 'text-lg font-bold text-gray-900')` |
| `WFormInput` | Form field | `WFormInput(form: form, name: 'email')` |
| `WButton` | Interactive button | `WButton(onTap: submit, className: 'bg-primary text-white')` |
| `WIcon` | Icon display | `WIcon(Icons.mail_outline, className: 'text-xl text-gray-500')` |
| `WAnchor` | Tap target | `WAnchor(onTap: () => ..., child: ...)` |
| `WSpacer` | Spacing | `WSpacer(className: 'h-4')` |
| `WPopover` | Overlay dropdown | `WPopover(triggerBuilder: ..., contentBuilder: ...)` |

For long class lists, use triple-quoted strings:

```dart
WDiv(
  className: '''
    rounded-2xl bg-white dark:bg-gray-800
    border border-gray-200 dark:border-gray-700
    shadow-sm p-6
  ''',
  child: ...,
)
```

> [!NOTE]
> Never use Material widgets (`Container`, `Text`, `ElevatedButton`, etc.) in Magic Starter views. The only exception is `Icons.*` for icon data references and `MSSwitch.adaptive` where Wind UI does not yet provide a toggle.

## Dark Mode

Always pair light and dark mode classes. Wind UI supports the `dark:` prefix for dark mode variants:

```dart
WText(
  'Hello',
  className: 'text-gray-900 dark:text-white',
)

WDiv(
  className: '''
    bg-white dark:bg-gray-800
    border border-gray-200 dark:border-gray-700
  ''',
)
```

Common pairings used throughout Magic Starter:

| Light | Dark |
|-------|------|
| `bg-white` | `dark:bg-gray-800` |
| `bg-gray-50` | `dark:bg-gray-900` |
| `text-gray-900` | `dark:text-white` |
| `text-gray-500` | `dark:text-gray-400` |
| `text-gray-400` | `dark:text-gray-500` |
| `border-gray-200` | `dark:border-gray-700` |
| `border-gray-100` | `dark:border-gray-700` |
| `hover:bg-gray-100` | `dark:hover:bg-gray-800` |

> [!TIP]
> Theme toggling is available in the user profile dropdown menu via `context.windTheme.toggleTheme()`. Check the current mode with `context.windIsDark`.

## View Registry

All views are registered by string key in `MagicStarterViewRegistry`. The host application can override any screen by re-registering under the same key:

```dart
// Register a view
MagicStarter.view.register(
  'auth.login',
  () => const MagicStarterLoginView(),
);

// Override with custom view
MagicStarter.view.register(
  'auth.login',
  () => const MyCustomLoginView(),
);

// Build a view by key
final widget = MagicStarter.view.make('auth.login');
```

Built-in view keys:

| Key | View |
|-----|------|
| `auth.login` | Login page |
| `auth.register` | Registration page |
| `auth.forgot_password` | Forgot password page |
| `auth.reset_password` | Reset password page |
| `auth.verify_email` | Email verification page (the mailed link) |
| `auth.otp_verify` | OTP verification page |
| `auth.two_factor_challenge` | Two-factor challenge page |
| `profile.settings` | Profile settings page |
| `teams.create` | Team creation page |
| `teams.settings` | Team settings page |
| `teams.invitation_accept` | Team invitation acceptance page |

> [!NOTE]
> `MagicStarter.view.make(key)` throws `StateError` when the key is not registered. Always ensure views are registered before routes reference them.

> [!NOTE]
> `notifications.list` and `notifications.preferences` are NOT registered on `MagicStarter.view`. Those two screens belong to `magic_notifications` and are registered on its own `Notify.view` (same shape: `register` / `has` / `make` / `slot` / `buildSlot` / `clear`); see [Notifications](https://anilcancakir.com/magic/docs/starter/v1.0/basics/notifications.md).

## Layout Registry

Layouts follow the same registry pattern with a `child` parameter for wrapping content:

```dart
// Register a layout
MagicStarter.view.registerLayout(
  'layout.app',
  (child) => MagicStarterAppLayout(child: child),
);

// Override with custom layout
MagicStarter.view.registerLayout(
  'layout.app',
  (child) => MyCustomAppLayout(child: child),
);

// Build a layout wrapping content
final widget = MagicStarter.view.makeLayout(
  'layout.guest',
  child: loginView,
);
```

Built-in layout keys:

| Key | Layout |
|-----|--------|
| `layout.app` | Authenticated layout with sidebar/nav |
| `layout.guest` | Centered guest layout for auth pages |

> [!TIP]
> To test views in isolation, register a minimal layout: `MagicStarter.view.registerLayout('layout.guest', (child) => child);`

## Overriding the App Shell

All account, profile, team, and settings routes render through the `layout.app` key. Out of the box, `MagicStarterServiceProvider` registers `MagicStarterAppLayout` under that key, so a fresh install already renders those routes in a working shell (sidebar/nav on desktop, drawer + bottom nav on mobile) with no extra setup.

An app that already has its own navigation chrome (its own sidebar, its own bottom nav) does not want starter routes rendering inside a second, different shell on top of its own. That is the case this seam exists for: re-register `layout.app` with your own shell and every starter route (login-gated pages, profile settings, team management, notifications) renders inside it instead:

```dart
MagicStarter.view.registerLayout(
  'layout.app',
  (child) => MyAppShell(child: child),
);
```

Call this from your own `AppServiceProvider`'s `boot()` method, alongside other startup customization such as `MagicStarter.useNavigation()` or `MagicStarter.useHeader()`.

`registerLayout()` always overwrites whatever is currently registered under the key: the default registration in `MagicStarterManager` is conditional (`if (!hasLayout(key))`), but `registerLayout()` itself is not. Since the default is registered synchronously the first time the `magic_starter` singleton is resolved (before your own code runs), your override always lands after it and always wins, whether you call `registerLayout()` early or late in your app's boot sequence. There is no ordering to get wrong.

Your own authenticated routes join the same shell. Magic merges route groups into ONE go_router `ShellRoute` only when they carry the same `layoutId`, so give your group `MagicStarterLayoutIds.app` (and build its layout through `makeLayout` if you like):

```dart
MagicRoute.group(
  layout: (child) => MagicStarter.view.makeLayout('layout.app', child: child),
  middleware: ['auth'],
  layoutId: MagicStarterLayoutIds.app,
  routes: () {
    MagicRoute.page('/', () => const DashboardView());
  },
);
```

A group with another id, or none, becomes a second shell, and every crossing between it and the starter's profile, team, notification or support pages rebuilds and re-animates the whole layout.

> [!NOTE]
> Do not add a second layout key for this. `layout.app` is the only authenticated-shell key the starter resolves; overriding it in place is the supported pattern, not adding a new one alongside it.

## Feature-Gated Rendering

Use conditional spreads to show or hide UI sections based on feature flags at build time:

```dart
WDiv(
  className: 'flex flex-col gap-4',
  children: [
    _buildEmailField(),
    _buildPasswordField(),
    if (MagicStarterConfig.hasRegistrationFeatures()) ...[
      WSpacer(className: 'h-2'),
      WAnchor(
        onTap: () => MagicRoute.to(MagicStarterConfig.registerPath()),
        child: WText(
          trans('auth.register_link'),
          className: 'text-sm text-primary',
        ),
      ),
    ],
    if (MagicStarterConfig.hasSocialLoginFeatures() &&
        MagicStarter.socialAuth != null) ...[
      const MSSocialDivider(),
      MagicStarterSocialButtons(
        socialAuth: MagicStarter.socialAuth!,
        onSelected: controller.doSocialSignIn,
        isLoading: controller.isLoading,
        busyProvider: controller.pendingSocialProvider,
      ),
    ],
  ],
)
```

> [!NOTE]
> Feature checks in views are read-only queries against the config. The view never toggles features — that is the domain of the CLI configure command or direct config edits.

## Zero Business Logic

Views must contain zero business logic. All rules:

- **No async operations** in views — `_submit()` calls a controller method and returns
- **No state decisions** — the controller decides what happens after a form submit (navigation, error handling)
- **No HTTP calls** — all API communication lives in controllers via `Http.post()`, `Http.get()`, etc.
- **No direct navigation** — controllers use `NavigatesRoutes` mixin; views never call `context.go()`
- **Local UI state only** — password visibility toggles (`_obscurePassword`), phone/email toggle in "both" mode, section-level loading spinners via local `ValueNotifier<bool>` fields

```dart
// CORRECT: view delegates to controller
Future<void> _submit() async {
  if (!form.validate()) return;
  await controller.doLogin(
    email: form.get('email'),
    password: form.get('password'),
  );
}

// WRONG: view makes API call
Future<void> _submit() async {
  final response = await Http.post('/auth/login', data: {...}); // Never do this
}
```

---

**Related Links:**

- [Controllers](https://magic.fluttersdk.com/packages/starter/basics/controllers)
- [Notifications](https://magic.fluttersdk.com/packages/starter/basics/notifications)
- [Routes](https://magic.fluttersdk.com/packages/starter/basics/routes)
- [Configuration](https://magic.fluttersdk.com/packages/starter/getting-started/configuration)
- [Wind UI](https://magic.fluttersdk.com/packages/wind)

---

## Reusable Widgets

Magic Starter exports standalone UI widgets that consumer apps can import and use directly without duplicating them locally. None of these widgets depend on internal controllers — they accept plain callbacks.

All widgets are exported from `package:magic_starter/magic_starter.dart`.

### MSPageHeader

Full-width page header with responsive `sm:flex-row` layout and a `border-b` separator. All parameters beyond `title` are optional:

```dart
MSPageHeader(
  title: trans('projects.title'),
  subtitle: trans('projects.manage_subtitle'),  // optional
  leading: const BackButton(),                  // optional
  actions: [                                    // optional
    WButton(onTap: _onCreate, child: WText(trans('projects.new'))),
  ],
)
```

Detail view with status badge and always-inline layout:

```dart
MSPageHeader(
  title: 'Task Details',
  leading: Icon(Icons.arrow_back),
  titleSuffix: StatusBadge(status: 'done'),
  inlineActions: true,
  actions: [WButton(onTap: () {}, child: WText('Edit'))],
)
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `title` | `String` | ✅ | Main heading text |
| `subtitle` | `String?` | — | Secondary line below the title |
| `leading` | `Widget?` | — | Widget placed before the title (e.g. back button) |
| `actions` | `List<Widget>?` | — | Row of trailing action widgets (e.g. buttons) |
| `titleSuffix` | `Widget?` | — | Optional widget rendered inline after the title (e.g. status badge). Stays on the same row as the title text. |
| `subtitleSuffix` | `Widget?` | - | Optional widget on the subtitle line, right after the subtitle (e.g. a pill beside a URL). Wraps under the subtitle when the line cannot hold both; renders nothing without a `subtitle`. |
| `inlineActions` | `bool` | — | When `true`, forces single-row layout on all screen sizes (no mobile stacking). Useful for detail views where actions must stay inline with the title. |

### MSCard

Card wrapper with an optional `title` slot, `noPadding` mode for full-bleed content, and three visual variants:

| Variant | Background | Border | Shadow |
|---------|-----------|--------|--------|
| `CardVariant.surface` _(default)_ | `bg-white dark:bg-gray-800` | ✅ `border-gray-200` | — |
| `CardVariant.inset` | `bg-gray-50 dark:bg-gray-900` | ✅ `border-gray-200` | — |
| `CardVariant.elevated` | `bg-white dark:bg-gray-800` | — | ✅ `shadow-md` |

```dart
// Default padded surface card with title
MSCard(
  title: 'Team Members',
  child: memberList,
)

// Full-bleed elevated card (e.g. data table)
MSCard(
  variant: CardVariant.elevated,
  noPadding: true,
  child: dataTable,
)

// Inset danger-zone card
MSCard(
  variant: CardVariant.inset,
  title: 'Danger Zone',
  child: deleteButton,
)
```

When `noPadding` is `true` and a `title` is provided, the title automatically receives `px-6 pt-6 pb-3` spacing so it aligns with full-bleed row content that uses `px-6`.

### MagicStarterConfirmDialog

Generic confirmation dialog with variant-driven styling. Uses `MagicStarterDialogShell` internally for sticky header/footer layout. All classNames are read from `MagicStarter.manager.modalTheme` at build time.

```dart
final confirmed = await MagicStarterConfirmDialog.show(
  context,
  title: trans('teams.remove_member_label'),
  description: trans('teams.confirm_remove_member'),
  confirmLabel: trans('teams.remove'),
  variant: ConfirmDialogVariant.danger,
  onConfirm: () async {
    await TeamService.removeMember(memberId);
  },
);

if (confirmed) _refreshMembers();
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `title` | `String` | **required** | Dialog title text |
| `description` | `String?` | `null` | Optional description below the title |
| `confirmLabel` | `String?` | `trans('common.confirm')` | Confirm button label |
| `cancelLabel` | `String?` | `trans('common.cancel')` | Cancel button label |
| `variant` | `ConfirmDialogVariant` | `.primary` | Button styling variant |
| `onConfirm` | `Future<void> Function()?` | `null` | Async action on confirm — dialog shows loading state |

**Variants:**

| Variant | Use case | Confirm button style |
|---------|----------|---------------------|
| `ConfirmDialogVariant.primary` | Neutral confirmations | `theme.primaryButtonClassName` |
| `ConfirmDialogVariant.danger` | Destructive actions (delete, remove, revoke) | `theme.dangerButtonClassName` |
| `ConfirmDialogVariant.warning` | Caution actions (leave, archive) | `theme.warningButtonClassName` |

Returns `true` when confirmed, `false` when cancelled or dismissed.

### MagicStarterPasswordConfirmDialog

Standalone password-confirmation dialog. Pass an `onConfirm` callback that returns `null` on success or an error string to display inline. The dialog stays open on error; it closes automatically on success and returns `true`.

```dart
final confirmed = await MagicStarterPasswordConfirmDialog.show(
  context,
  title: trans('projects.delete_title'),
  description: trans('projects.delete_description'),
  onConfirm: (password) async {
    // Return null to confirm, or an error string on failure.
    return await ProjectService.delete(id, password: password);
  },
);

if (confirmed) _removeProject();
```

Use with no `onConfirm` if you only need a confirmation gate (e.g. before a local-only destructive action):

```dart
final confirmed = await MagicStarterPasswordConfirmDialog.show(context);
```

> [!NOTE]
> Without an `onConfirm` callback, the dialog closes with `true` as soon as the user taps Confirm (no async validation is performed).

### MagicStarterTwoFactorModal

Multi-step 2FA wizard modal. Step 1 displays the QR code and OTP input; Step 2 displays recovery codes with a copy button. The modal advances to Step 2 only when `onConfirm` returns `true`.

```dart
final success = await MagicStarterTwoFactorModal.show(
  context,
  setupData: {
    'secret': '...',
    'qr_svg': '...',         // raw SVG string
    'recovery_codes': [...], // list of strings
  },
  onConfirm: (code) async {
    return await TwoFactorService.confirmSetup(code);
  },
);
```

| `setupData` key | Type | Description |
|-----------------|------|-------------|
| `secret` | `String` | Manual entry key shown below the QR code |
| `qr_svg` | `String` | Raw SVG markup rendered via `WSvg` with `preserve-colors` |
| `recovery_codes` | `List<dynamic>` | Backup codes displayed on Step 2 |

The modal can also be used for standalone re-authentication (e.g. before a sensitive action) by supplying minimal `setupData` with only the fields the flow needs.

### Other Exported Widgets

| Widget | Description |
|--------|-------------|
| `MagicStarterAuthFormCard` | Centered card wrapper (max 480 px) for auth-adjacent screens — invite accept, onboarding, etc. Accepts `title`, `subtitle`, optional `errorMessage`, and a theme-toggle button. |
| `MagicStarterTimezoneSelect` | Searchable timezone dropdown backed by `GET /timezones?search=...`. Debounces search at 300 ms and always includes the pre-selected value in options. Pages through the endpoint on scroll and resets its cursor when the menu reopens, since the reopen restores the unfiltered list; a response whose list the reopen already replaced is dropped rather than written. |
| `MSTeamSelector` | Current-team switcher dropdown. Requires `MagicStarter.teamResolver` to be registered. `compact` mode hides the team name label. |
| `MSAvatar` | A photo with a fallback, clipped to whatever shape `className` asks for. Owns no size, shape or colour, because those differ per surface; the fallback is a WIDGET so a host keeps its own initials rule. Falls back on an empty url and on a load error. |
| `MSUserProfileDropdown` | Circular avatar menu showing signed-in user info, profile links, theme toggle, and logout. Supports a custom `triggerBuilder`. |
| `MSSocialDivider` | Horizontal "Or continue with" divider for auth forms. No parameters — pure presentation. |
| `MagicStarterHideBottomNav` | Wraps a page and collapses the phone's bottom navigation in step with that page's route transition. Does nothing without a `MagicStarterBottomNavScope` above it. See [Hiding the bottom nav on a page](#hiding-the-bottom-nav-on-a-page). |
| `MagicStarterBottomNavScope` | A layout's root, so the bar and the routed page share it. `visibilityOf(context)` reads how much of the bar is showing, `0` to `1`. |
| `MagicStarterBottomNavSlot` | Wraps a layout's bottom nav; its height follows the scope's visibility. |
| `MagicStarterLayoutIds` | The `layoutId` values of the starter's route groups; `MagicStarterLayoutIds.app` is the authenticated shell a host's own group must share. See [Overriding the App Shell](#overriding-the-app-shell). |
| `MagicStarterHideChrome` | `InheritedWidget` that signals `MagicStarterAppLayout` to hide the whole shell chrome: sidebar, drawer, header and bottom bar, plus the safe-area inset and the shell's scroll container. Wrap a route group's layout with it, read with `MagicStarterHideChrome.of(context)`. See [An immersive route](#applayout). |

> [!NOTE]
> The bell-icon dropdown is not in this table. It moved to `magic_notifications` as `NotificationDropdown`; see [Notifications](https://anilcancakir.com/magic/docs/starter/v1.0/basics/notifications.md).
