# MagicStarterManager

`MagicStarterManager` is the singleton behind the `MagicStarter` facade: it holds the host's user model, team resolver, navigation, callbacks, sub-themes and the view registry.

- [Introduction](#introduction)
- [Singleton Pattern](#singleton-pattern)
- [Unbound Fallback and Testing](#unbound-fallback-and-testing)
- [User Model Registration](#user-model-registration)
- [Team Resolver](#team-resolver)
- [Navigation Configuration](#navigation-configuration)
- [Navigation Theme](#navigation-theme)
- [Modal Theme](#modal-theme)
- [Sidebar Footer Builder](#sidebar-footer-builder)
- [Header Builder](#header-builder)
- [Logout Callback](#logout-callback)
- [Login Callback](#login-callback)
- [Guest Claim Callback](#guest-claim-callback)
- [Locale Application](#locale-application)
- [Unified Theme](#unified-theme)
- [Form Theme](#form-theme)
- [Auth Theme](#auth-theme)
- [Card Theme](#card-theme)
- [Page Header Theme](#page-header-theme)
    - [Host-owned page chrome](#host-page-chrome)
- [Layout Theme](#layout-theme)
- [Page Geometry](#page-geometry)
- [View Registry Integration](#view-registry-integration)
- [Default Views](#default-views)
- [MagicStarter Facade](#magicstarter-facade)
- [Related Documentation](#related-documentation)

## Introduction

The manager is the configuration hub where host apps register custom user models, team resolvers, navigation items, header builders, logout callbacks and view overrides. What a notification type looks like is not one of its seams: that lives on `magic_notifications`'s own `NotificationViewRegistry` (see [Notifications](https://anilcancakir.com/magic/docs/starter/basics/notifications.md)).

The manager lives at `lib/src/magic_starter_manager.dart`. `MagicStarterServiceProvider` binds it as a container singleton in its register phase.

## Singleton Pattern

The manager is bound into the Magic IoC container under the key `'magic_starter'` by the service provider:

```dart
app.singleton('magic_starter', () => MagicStarterManager());
```

All access goes through the `MagicStarter` facade, which resolves the singleton from the container:

```dart
static MagicStarterManager get manager {
  if (Magic.bound('magic_starter')) {
    return Magic.make<MagicStarterManager>('magic_starter');
  }
  // Falls back to a shared default manager instead of throwing.
  return _fallbackManager ??= MagicStarterManager();
}
```

> [!NOTE]
> The constructor calls `registerDefaultViews()`, so the default views and layouts are available as soon as the manager is built.

## Unbound Fallback and Testing

Every accessor on the `MagicStarter` facade delegates through `manager`, so any code that reads a theme (for example `MSCard` through `MagicStarter.cardTheme`) would fail when rendered without `MagicStarterServiceProvider` bound, in a standalone widget test or a preview catalogue entry.

`MagicStarter.manager` checks `Magic.bound('magic_starter')` first. When unbound it falls back to a shared, lazily created default `MagicStarterManager()`, whose sub-themes all hold `const` defaults, instead of throwing. The fallback also prints a one-time `kDebugMode` warning (`MagicStarterManager not bound; using defaults. Call MagicStarter... to configure.`), so a forgot-to-bind bug in a real app still surfaces during development. A manager bound in the container always wins over the fallback.

For tests that need a specific manager configuration (custom theme, team resolver, etc.), bind one explicitly with `setUpMagicStarterForTests()`:

```dart
import 'package:magic_starter/magic_starter.dart';

setUp(() {
  MagicApp.reset();
  Magic.flush();
  setUpMagicStarterForTests(); // binds a default MagicStarterManager()
});
```

Pass a pre-configured instance to bind it instead:

```dart
setUpMagicStarterForTests(
  manager: MagicStarterManager()
    ..cardTheme = const MagicStarterCardTheme(surfaceClassName: 'bg-white'),
);
```

`setUpMagicStarterForTests()` is `Magic.singleton('magic_starter', () => manager ?? MagicStarterManager())` in one call. A test that only relies on default theme values can skip binding altogether and use the fallback. In a test that exercises the fallback itself, call `MagicStarter.resetFallbackManagerForTests()` in `setUp`, because `Magic.flush()` does not clear it.

## User Model Registration

By default the manager creates `MagicStarterAuthUser` instances from API data. Host apps override this to use their own `Authenticatable` subclass:

```dart
MagicStarter.useUserModel((data) => User.fromMap(data));
```

Internally the manager stores a `UserModelFactory`, a function that takes `Map<String, dynamic>` and returns `Authenticatable`:

```dart
UserModelFactory userFactory = (data) => MagicStarterAuthUser.fromMap(data);
```

The factory is invoked via `MagicStarter.createUser(data)` whenever the plugin needs to hydrate a user model from an API response.

> [!TIP]
> Register your user model factory in your `AppServiceProvider.boot()`, so all auth flows use the correct model from the start. `MagicStarter.bootstrap()` takes it as a required argument; see [Installation](https://anilcancakir.com/magic/docs/starter/getting-started/installation.md#bootstrapping-the-starter).

## Team Resolver

The team resolver bridges the plugin's team UI with app-specific team models through three callbacks:

```dart
MagicStarter.useTeamResolver(
  currentTeam: () => User.current.currentTeam?.toMagicStarterTeam(),
  allTeams: () => User.current.allTeams
      .map((t) => t.toMagicStarterTeam())
      .toList(),
  onSwitch: (id) => MagicStarter.switchTeam('$id'),
);
```

The callbacks are wrapped in `MagicStarterTeamResolverConfig`:

| Callback | Signature | Purpose |
|:---------|:----------|:--------|
| `currentTeam` | `MagicStarterTeam? Function()` | Returns the active team (null if none) |
| `allTeams` | `List<MagicStarterTeam> Function()` | Returns all teams the user belongs to |
| `onSwitch` | `Future<void> Function(dynamic teamId)` | Handles team switching logic |

> [!NOTE]
> If the `magic_starter.features.teams` config flag is enabled but no team resolver is configured, the service provider logs a warning at boot time, `manager.isReady` returns `false` and `MagicStarter.bootstrap()` throws a `StateError`.

`MagicStarter.switchTeam(teamId)` asks the backend to switch and answers whether it accepted; on success it re-identifies the store rail as the paying subject, so a purchase after the switch is not attributed to the team that was left. A refused switch changes nothing. A failure of that re-identification is logged and does not turn the answer into `false`, because the switch already happened. `MagicStarter.currentTeamId()` answers the active team's id as a string, or `null` without a resolver or an active team.

## Navigation Configuration

Host apps register navigation items for the authenticated app layout (sidebar, bottom bar and profile dropdown):

```dart
MagicStarter.useNavigation(
  mainItems: [
    MagicStarterNavItem(
      icon: Icons.dashboard,
      labelKey: 'nav.dashboard',
      path: '/',
    ),
  ],
  systemItems: [
    MagicStarterNavItem(
      icon: Icons.people_outline,
      labelKey: 'nav.members',
      path: '/teams/members',
    ),
  ],
  bottomItems: [
    MagicStarterNavItem(
      icon: Icons.dashboard_outlined,
      labelKey: 'nav.dashboard',
      path: '/',
    ),
  ],
  profileMenuItems: [
    MagicStarterNavItem(
      icon: Icons.notifications_outlined,
      labelKey: 'nav.notifications',
      path: '/notifications',
    ),
  ],
);
```

The items are stored in `MagicStarterNavigationConfig`:

| Property | Required | Description |
|:---------|:---------|:------------|
| `mainItems` | Yes | Primary sidebar/drawer items (Dashboard, Monitors, etc.) |
| `systemItems` | No | Secondary items below the main group (Settings, Members) |
| `bottomItems` | No | Mobile bottom navigation bar items (subset of main) |
| `profileMenuItems` | No | Extra links in the profile dropdown, between "Profile Settings" and logout |

Each `MagicStarterNavItem` takes `icon`, `labelKey` and `path`, plus these optional fields:

| Property | Default | Description |
|:---------|:--------|:------------|
| `activeIcon` | `icon` | Icon drawn on the bottom bar while the item is active |
| `badgeCount` | `null` | A `ValueListenable<int?>` drawn as a count badge on the item; see [A live count on a destination](https://anilcancakir.com/magic/docs/starter/basics/views-and-layouts.md#a-live-count-on-a-destination) |
| `badgeLabelKey` | `'nav.badge_count'` | Translation key of the sentence a screen reader gets for the badge |

## Navigation Theme

`MagicStarterNavigationTheme` overrides the default `text-primary` tokens used for active nav items, the brand/logo, bottom navigation and user avatar colours, without requiring any view override.

```dart
MagicStarter.useNavigationTheme(
  MagicStarterNavigationTheme(
    activeItemClassName:
        'active:text-amber-500 active:bg-amber-500/10 dark:active:text-amber-400 dark:active:bg-amber-400/10',
    brandClassName:
        'text-lg font-bold text-amber-500 dark:text-amber-400',
    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',
    avatarTextClassName: 'text-sm font-bold text-amber-600 dark:text-amber-400',
    dropdownAvatarClassName: 'bg-gradient-to-tr from-amber-500 to-amber-300',
    dropdownAvatarTextClassName: 'text-sm font-bold text-white',
  ),
);
```

All fields are optional; an omitted field keeps its default.

| Field | Default | Controls |
|:------|:--------|:--------|
| `activeItemClassName` | `active:text-primary active:bg-primary/10 dark:active:bg-primary/10` | Sidebar/drawer active nav item |
| `hoverItemClassName` | `hover:bg-gray-100 dark:hover:bg-gray-800` | Sidebar/drawer hover state |
| `brandClassName` | `text-lg font-bold text-primary` | Brand/logo text style (supports gradient tokens) |
| `brandBuilder` | `null` | Custom brand widget builder (image, SVG, etc.); overrides `brandClassName` when set |
| `compactBrandBuilder` | `null` | Brand builder for the compact, icon-only sidebar; falls back to `brandBuilder` when unset |
| `bottomNavActiveClassName` | `active:text-primary` | Bottom nav active icon and label |
| `avatarClassName` | `bg-primary/10 dark:bg-primary/10` | Sidebar user menu avatar background |
| `avatarTextClassName` | `text-sm font-bold text-primary` | Sidebar user menu avatar initial and placeholder glyph colour |
| `dropdownAvatarClassName` | `bg-gradient-to-tr from-primary to-gray-200` | Profile dropdown trigger avatar gradient |
| `dropdownAvatarTextClassName` | `text-sm font-bold text-white` | Profile dropdown trigger avatar initial and placeholder glyph colour |

Both avatars draw the account's photo when `profile_photo_url` carries one, the first letter of the account's name when it does not, and a person glyph when there is no name to take a letter from: before the session is known, or for an account that never set one.

The theme is stored on `MagicStarterManager` as `navigationTheme` and reset to defaults by `manager.reset()`. The active theme is read at widget build time, so `useNavigationTheme()` can be called at any point before the layout is first painted.

> [!NOTE]
> When `brandBuilder` is set, `brandClassName` is ignored. The builder receives the current `BuildContext` and should return any widget: `Image.asset`, `SvgPicture.asset`, a styled `WText`, etc.

## Modal Theme

`MagicStarterModalTheme` overrides the default tokens used for every modal dialog: confirmation dialogs, password prompts and two-factor modals. Works the same way as `NavigationTheme`: register once, all modals pick up the tokens at build time.

```dart
MagicStarter.useModalTheme(
  MagicStarterModalTheme(
    containerClassName: 'rounded-2xl bg-white dark:bg-gray-900',
    titleClassName: 'text-lg font-semibold text-gray-900 dark:text-white',
    descriptionClassName: 'text-sm text-gray-500 dark:text-gray-400',
    primaryButtonClassName: 'bg-primary hover:bg-primary/90 text-white',
    dangerButtonClassName: 'bg-red-600 hover:bg-red-700 text-white',
    warningButtonClassName: 'bg-amber-500 hover:bg-amber-600 text-white',
    inputClassName: 'rounded-lg border border-gray-300 dark:border-gray-600',
    maxWidth: 480,
  ),
);
```

All fields are optional; an omitted field keeps its default.

| Field | Default | Controls |
|:------|:--------|:--------|
| `containerClassName` | Default dialog container | Outer dialog container background, border-radius, border |
| `headerClassName` | Default header | Dialog header section (sticky top) |
| `bodyClassName` | Default body | Scrollable body section |
| `footerClassName` | Default footer | Dialog footer section (sticky bottom) |
| `titleClassName` | Default title | Dialog title text style |
| `descriptionClassName` | Default description | Dialog description/subtitle text style |
| `primaryButtonClassName` | Default primary | Primary action button (confirm, save) |
| `secondaryButtonClassName` | Default secondary | Secondary action button (cancel, dismiss) |
| `dangerButtonClassName` | Default danger | Danger action button (delete, destroy) |
| `warningButtonClassName` | Default warning | Warning action button |
| `errorClassName` | Default error | Inline error message text style |
| `inputClassName` | Default input | Text input fields inside dialogs |
| `maxWidth` | `448.0` | Maximum dialog width in logical pixels (a dialog only) |
| `presentation` | `MagicStarterModalPresentation.dialog` | Whether the starter's modals open as a dialog, as a bottom sheet, or as a sheet below `dialogBreakpoint` and a dialog from it (`adaptive`) |
| `dialogBreakpoint` | `'lg'` | The `wind_kit` breakpoint `adaptive` swaps to a dialog from; read by `adaptive` only |
| `headlessBodyClassName` | `'pt-6'` | Top padding of the body of an `MSBottomSheet` drawn as a dialog with no title and no description (an overlay route under `adaptive`), where the sheet had its drag handle |

### Presentation

`presentation: MagicStarterModalPresentation.adaptive` opens every modal the starter opens, and every `MSBottomSheet.show`, as a bottom sheet below `dialogBreakpoint` and as a centred `MSDialog` (at most `maxWidth`) from it: a phone gets the sheet, a desktop window the dialog, from one setting. The screen width is read when a modal opens, so one that is already open keeps its form through a resize; `MSBottomSheet.show(dialogBreakpoint:)` is ignored, and a breakpoint the Wind theme does not carry throws a `StateError` naming `MagicStarterModalTheme.dialogBreakpoint`. Overlay routes (`RouteDefinition.overlay`) follow too: the provider installs a resolver on magic's router (`MagicRoute.useOverlayPresentation`), an `MSBottomSheet` built in a dialog route draws the dialog shell, and under `dialog` or `sheet` an overlay route stays the sheet it has always been.

`presentation: MagicStarterModalPresentation.sheet` opens the starter's modals as the same bottom sheet `MSBottomSheet` draws, at every width (the plan upgrade prompt, which opens through magic's `MagicFeedback.showCustomDialog`, stays a dialog): `MSDialog.show`, `MSConfirmDialog.show` (and its `MagicStarterConfirmDialog` alias), `MagicStarterPasswordConfirmDialog.show`, `MagicStarterStepUpDialog.show` and `MagicStarterTwoFactorModal.show`. `MSBottomSheet.show(dialogBreakpoint:)` then ignores its breakpoint and stays a sheet.

```dart
MagicStarter.useModalTheme(
  MagicStarter.modalTheme.copyWith(
    presentation: MagicStarterModalPresentation.sheet,
  ),
);
```

- Return values do not change: `true` or `false` from a confirm, the proof map or `null` from the step up.
- A sheet is full width on a phone. On a wide screen Flutter's Material 3 default caps it at 640 px; `maxWidth` applies to the dialog only.
- The setting is read when a modal opens. An `MSDialog` widget draws the sheet panel when it is built inside a bottom sheet route, and its dialog shell otherwise, so a dialog a host opens with its own `showDialog` keeps the shell.
- `useWindTheme()` keeps a modal field set before it, `presentation` included. After it, set the field with `MagicStarter.modalTheme.copyWith(...)`, since a fresh `MagicStarterModalTheme` puts the derived colours it omits back on their defaults. `useTheme()` resets the modal theme, so set it after that.

The theme is stored on `MagicStarterManager` as `modalTheme` and reset to defaults by `manager.reset()`. The active theme is read at widget build time, so `useModalTheme()` can be called at any point before a dialog is shown.

> [!TIP]
> Register your modal theme in `AppServiceProvider.boot()` alongside `useNavigationTheme()`. Both follow the same pattern: register once, and every widget reads the tokens at build time.

## Sidebar Footer Builder

Add a custom widget between the navigation items and the user menu in both the desktop sidebar and mobile drawer:

```dart
MagicStarter.useSidebarFooter((context) {
  return MyVersionBadge();
});
```

The builder receives the current `BuildContext` and should return any widget: a version badge, environment indicator, support link, etc.

When `sidebarFooterBuilder` is `null` (the default), no extra content is rendered between navigation and the user menu.

## Header Builder

Replace the default app layout header with a custom widget:

```dart
MagicStarter.useHeader((context, isDesktop) {
  return AppHeader(showMenuButton: !isDesktop);
});
```

The builder receives the current `BuildContext` and a `bool isDesktop` flag so the header can adapt between desktop sidebar and mobile drawer layouts.

When `headerBuilder` is `null`, the app layout renders its built-in header with the team selector, the notification bell and the user profile dropdown.

## Logout Callback

Override the default logout behaviour to add custom cleanup steps:

```dart
MagicStarter.useLogout(() async {
  await Notify.logoutPush();
  await SocialAuth.signOut();
  await Auth.logout();
  MagicRoute.to('/auth/login');
});
```

When set, the profile dropdown's logout item calls this callback instead of `MagicStarterAuthController.instance.logout()`. This is useful for apps that need to unregister push tokens, sign out of social providers, or perform other cleanup before navigating to the login screen.

Work that has to happen while the session token still works belongs in a before-logout hook, which runs ahead of both sign-out paths (the profile dropdown, ahead of a custom `onLogout`, and `MagicStarterAuthController.logout()`, ahead of `Auth.logout()`):

```dart
MagicStarter.beforeLogout(() => SocialAuth.signOut());
```

Hooks run in registration order, each isolated and bounded to five seconds (`MagicStarterManager.beforeLogoutTimeout`); one that throws or times out is logged and the next still runs, because a sign-out the user asked for has to happen whatever a hook's network does. The same hook is not added twice. `MagicStarterServiceProvider` registers its token-revoke hook in `boot()`, so a hook your own provider registers after that runs on a revoked token: list the provider whose hook needs the session token before `MagicStarterServiceProvider` in `providers`. A host with its own sign-out button calls `MagicStarter.manager.runBeforeLogoutHooks()` before its own `Auth.logout()`. A sign-out the app performs on its own, such as magic's `AuthInterceptor` after a failed token refresh, does not run them: the token is already dead. The provider registers two of its own, see [Before-Logout Hooks](https://anilcancakir.com/magic/docs/starter/architecture/service-provider.md#before-logout-hooks).

## Login Callback

Run custom logic after a fresh sign-in:

```dart
MagicStarter.useLogin(() async {
  await Analytics.identify(Auth.user());
});
```

`MagicStarterServiceProvider` registers an internal listener on magic's `AuthLogin` event (dispatched at the end of `BaseGuard.startSession`) that calls this callback, unawaited, and logs rather than rethrows on failure. It fires for every fresh sign-in path this package ships (password, two-factor challenge, social, guest, and phone OTP) and deliberately **not** for a cold-boot restore of a stored session or a team switch: both go through `Auth.restore()`, which dispatches `AuthRestored`, and only once an API sync confirms the user (never offline, never on a failed sync, never without a `userEndpoint` configured); neither path ever dispatches `AuthLogin`.

When `onLogin` is `null` (the default), nothing runs.

## Guest Claim Callback

Run custom logic once a guest claim settles:

```dart
MagicStarter.useGuestClaimed((GuestClaimOutcome outcome) async {
  if (outcome == GuestClaimOutcome.claimed) await Library.refresh();
});
```

Also accepted as the `onGuestClaimed` argument of `MagicStarter.bootstrap()`. With the `guest_auth` feature on, the provider's `AuthLogin` and `AuthRestored` listeners claim a pending guest record for a non-guest user and call this callback with every outcome but `none` (`claimed`, `refused`, `promoted`), each of which marks the first sign-in to a real account after a guest session on this device. It runs unawaited and a failure is logged, not rethrown; a sign-in and a restore that share one claim call it once. See [Guest Claim](https://anilcancakir.com/magic/docs/starter/basics/authentication.md#guest-claim) for the flow.

When `onGuestClaimed` is `null` (the default), nothing runs.

## Locale Application

`MagicStarterServiceProvider` also applies a signed-in user's saved `locale` attribute to the running app automatically, so a Turkish account does not land on an English shell until its next cold boot. Configured with `magic_starter.localization.apply_user_locale` (default `true`):

```dart
Map<String, dynamic> get magicStarterConfig => {
  'magic_starter': {
    'localization': {'apply_user_locale': false}, // opt out
    // ...
  },
};
```

A named static listener on `Auth.stateNotifier`, plus one immediate "once, now" read in `boot()` for the ordinary cold-boot case, reads `Auth.user()?.get<String>('locale')` and hands any non-empty value to `MagicStarterManager.applyLocale()`, the same method a [profile save](https://anilcancakir.com/magic/docs/starter/basics/profile.md) calls once it changed the language. `applyLocale()` holds a single pending target: whichever call reaches it first for a given locale wins, and a same-frame second call for the same target is a no-op, so a profile save (which itself calls `Auth.restore()` and so wakes this listener too) still issues exactly one `Lang.setLocale()` call however long the catalogue load takes.

When `Auth.check()` is `false`, the user carries no `locale`, or the toggle is off, nothing happens.

## Unified Theme

`MagicStarterTheme` wraps every sub-theme object into a single configuration. Host apps can set the entire visual identity in one call, then selectively override individual sub-themes afterward:

```dart
MagicStarter.useTheme(
  MagicStarterTheme(
    navigation: MagicStarterNavigationTheme(
      activeItemClassName: 'active:text-amber-500 active:bg-amber-500/10',
    ),
    form: MagicStarterFormTheme(
      inputClassName: 'rounded-xl border-2 border-zinc-700',
    ),
    card: MagicStarterCardTheme(
      surfaceClassName: 'bg-zinc-50 dark:bg-zinc-900',
    ),
  ),
);

// Override just one sub-theme after unified set
MagicStarter.useAuthTheme(
  MagicStarterAuthTheme(cardClassName: 'rounded-3xl bg-zinc-900 p-8'),
);
```

The unified theme supports `copyWith()` for partial overrides:

```dart
final customTheme = MagicStarter.theme.copyWith(
  form: MagicStarterFormTheme(inputClassName: 'rounded-xl'),
);
MagicStarter.useTheme(customTheme);
```

The manager maintains bidirectional sync: the `theme` getter constructs a `MagicStarterTheme` from the individual fields, while the `theme` setter distributes each sub-theme to its respective field, so `useTheme()` and the individual `use*Theme()` methods work together. Call the unified one first: a later `use*Theme()` replaces only its own sub-theme.

| Sub-theme | Class | Facade getter/setter |
|:----------|:------|:--------------------|
| Navigation | `MagicStarterNavigationTheme` | `useNavigationTheme()` / `navigationTheme` |
| Modal | `MagicStarterModalTheme` | `useModalTheme()` / `modalTheme` |
| Form | `MagicStarterFormTheme` | `useFormTheme()` / `formTheme` |
| Auth | `MagicStarterAuthTheme` | `useAuthTheme()` / `authTheme` |
| Card | `MagicStarterCardTheme` | `useCardTheme()` / `cardTheme` |
| Page Header | `MagicStarterPageHeaderTheme` | `usePageHeaderTheme()` / `pageHeaderTheme` |
| Layout | `MagicStarterLayoutTheme` | `useLayoutTheme()` / `layoutTheme` |
| Dropdown | `MagicStarterDropdownTheme` | `useDropdownTheme()` / `dropdownTheme` |
| Button | `MagicStarterButtonTheme` | `useButtonTheme()` / `buttonTheme` |
| Field | `MagicStarterFieldTheme` | `useFieldTheme()` / `fieldTheme` |

All sub-theme classes live in `lib/src/configuration/magic_starter_theme.dart`.

## Form Theme

`MagicStarterFormTheme` overrides the class names for form inputs, labels, placeholders, buttons, links, and checkboxes across all auth and profile forms.

```dart
MagicStarter.useFormTheme(
  MagicStarterFormTheme(
    inputClassName: 'w-full px-4 py-4 rounded-xl bg-zinc-900 border border-zinc-700 text-white',
    primaryButtonClassName: 'w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold',
  ),
);
```

`useFormTheme()` assigns the whole struct, so every field you leave out goes back to its default, including the ones `useWindTheme()` derived. To change one string and keep the rest, copy the active theme. Every sub-theme has the same `copyWith`:

```dart
MagicStarter.useFormTheme(
  MagicStarter.formTheme.copyWith(inputClassName: 'w-full py-[11px] ...'),
);
MagicStarter.useModalTheme(
  MagicStarter.modalTheme.copyWith(inputClassName: 'w-full py-[11px] ...'),
);
```

## Auth Theme

`MagicStarterAuthTheme` overrides the class names for the auth form card, title, subtitle, error banner, social divider, and link styles on login/register/forgot/reset pages.

```dart
MagicStarter.useAuthTheme(
  MagicStarterAuthTheme(
    cardClassName: 'rounded-3xl bg-zinc-900 border border-zinc-700 p-8',
    titleClassName: 'text-3xl font-black text-white text-center',
  ),
);
```

## Card Theme

`MagicStarterCardTheme` overrides the class names for `MSCard` variant backgrounds, border radius, padding, and title styles.

```dart
MagicStarter.useCardTheme(
  MagicStarterCardTheme(
    surfaceClassName: 'bg-zinc-900 border border-zinc-700',
    borderRadius: 'rounded-xl',
  ),
);
```

## Page Header Theme

`MagicStarterPageHeaderTheme` overrides the class names for the `MSPageHeader` container, title, subtitle, action container and compact bar, plus two layout switches.

```dart
MagicStarter.usePageHeaderTheme(
  MagicStarterPageHeaderTheme(
    titleClassName: 'text-3xl font-black text-white',
  ),
);
```

### `inlineActions`, and the one combination that overflows

The header stacks below `sm` and lays out as a row above it. An app that wants a
row at EVERY width, so a phone header keeps its action beside the title instead
of dropping it under the subtitle, overrides `containerClassName`:

```dart
MagicStarter.usePageHeaderTheme(
  const MagicStarterPageHeaderTheme(
    containerClassName: 'w-full flex flex-row items-start justify-between gap-3 ...',
    // Required with the override above, and easy to miss.
    inlineActions: true,
  ),
);
```

**Setting the container alone overflows.** `inlineActions` does two things: it
selects `containerInlineClassName`, and it gives the title row `flex-1 min-w-0`
instead of `sm:flex-1`. Without the second half the title column is
`flex-initial`, a loose fit, so a long title takes its intrinsic width and runs
past the actions rather than shrinking. `line-clamp-2` on the title does not
help, for the same reason `truncate` does not without a constrained box.

`MSPageHeader.inlineActions` is `bool?` and defaults to this field, so a single
screen can still opt out with `inlineActions: false`. `MSPageScaffold` does not
expose the argument, which is why the theme is the only reachable switch for a
scaffold consumer.

### Large title

`largeTitle: true` makes every `MSPageHeader` a large title and every
`MSPageScaffold` collapse it into a compact bar on scroll, the starter's own
pages included. A widget argument still wins (`size:` on the header,
`largeTitle:` on the scaffold).

```dart
MagicStarter.usePageHeaderTheme(
  const MagicStarterPageHeaderTheme(
    largeTitle: true,
    largeTitleClassName: 'text-4xl lg:text-3xl font-bold text-fg',
    compactBarClassName: 'px-2 bg-white/80 dark:bg-gray-900/80 border-b border-color-border',
    compactBarOpaqueClassName: 'px-2 bg-surface-container border-b border-color-border',
    compactTitleClassName: 'text-base font-semibold text-fg truncate',
    compactBarBlurSigma: 24,
  ),
);
```

The bar is glass: `compactBarClassName` should be translucent, and
`compactBarBlurSigma` blurs what scrolls under it (`0` adds no blur layer).
Under high contrast (`MediaQuery.highContrast`, iOS Increase Contrast) it paints
`compactBarOpaqueClassName` with no blur. A semantic alias carries no opacity
step, so `MagicStarterTheme.fromWind` sets both fills to the opaque
`bg-surface-container`; a host after glass passes its own translucent fill.

### Host-owned page chrome

`manager.pageChrome` is a `MagicStarterPageChrome`, a `ChangeNotifier` that lives
as long as the manager (`reset()` clears it, it does not replace it). A host that
draws the page title and back control in its own bar registers a predicate:

```dart
MagicStarter.useHostPageChrome((context) => MediaQuery.sizeOf(context).width < 768);
```

Wherever it answers true, `MSPageHeader` draws only its `actions` (right aligned,
nothing at all without them, no heading role) and reports a
`MagicStarterPageChromeEntry` (`title`, `subtitle`, `backLabel`, `backFallback`,
value equality) keyed by its own state; `MSPageScaffold` then builds neither the
large title nor the compact bar. The predicate gets a context inside the page and
is asked again when its dependencies (the `MediaQuery` it reads) or the header's
widget change. A header reports only while its `ModalRoute` is the current one:
it withdraws when a route is pushed over it and the moment its own route is
popped, before the exit transition ends, and reports again when the page above
is popped. So `current` names the top page, and is null when that page has no
`MSPageHeader`. A header under no route reports whenever the predicate answers
true.

Every starter page follows it, the two `magic_notifications` screens included:
the starter mounts the inbox and the preferences with a `headerBuilder` that
draws their header as an `MSPageHeader` (`lib/src/routes/notification_routes.dart`),
so the inbox reports its title and keeps its mark-all-as-read action, and the
preferences report the settings hub as their `backFallback`.

| Member | Contract |
|:-------|:---------|
| `hostOwns(context)` | The predicate's answer; `false` while none is registered |
| `current` | The entry of the owner whose first report is the most recent; an update keeps its place, so the older of two pages mounted together under an instant swap neither takes over nor clears the newer one when it withdraws |
| `report(owner, entry)` / `withdraw(owner)` | Called by the header from `didChangeDependencies`, `didUpdateWidget` and `dispose`, and `withdraw` also when its route is popped; it reports only while its route is current |
| listeners | Notified after the frame in which `current` changed, never during a build; a change undone within the frame notifies nobody |

The host's bar listens and goes back with the fallback the page reported:

```dart
ListenableBuilder(listenable: MagicStarter.pageChrome, builder: (context, _) {
  final MagicStarterPageChromeEntry? entry = MagicStarter.pageChrome.current;
  return TopBar(title: entry?.title, onBack: () => MagicRoute.back(fallback: entry?.backFallback));
});
```

## Layout Theme

`MagicStarterLayoutTheme` overrides `wind_kit` class names and dimensions for the app layout shell: sidebar, header, the content area and its background, drawer background, brand bar, and bottom navigation. It also carries the two breakpoints and the compact width that decide when the sidebar appears and when it renders as an icon rail.

```dart
MagicStarter.useLayoutTheme(
  MagicStarterLayoutTheme(
    sidebarWidth: 280,
    sidebarClassName: 'h-full flex flex-col bg-zinc-900 border-r border-zinc-700',
    contentBackgroundLightColor: 'zinc',
    contentBackgroundLightShade: 50,
  ),
);
```

## Page Geometry

`pageContainerClassName` is the `wind_kit` geometry every page in the app shares: the width cap, the horizontal edge margins, and the vertical rhythm. `MSPageContainer` reads it, and `MSPageScaffold` composes `MSPageContainer`, so every page in this package and every page in the host app resolve to the same numbers.

```dart
// In AppServiceProvider.boot()
MagicStarter.manager.pageContainerClassName =
    'max-w-6xl px-4 sm:px-5 lg:px-8 pt-6 sm:pt-8 pb-24';
```

The geometry belongs to the host, not to this package. Magic Starter's account pages render inside the host's own shell (see [Layout Theme](#layout-theme) and the `layout.app` view key), so a width chosen here is a guess about someone else's layout. Set one geometry and every page, starter-owned or host-owned, lines up by construction.

Carry the whole geometry in the one string. A cap that agrees while the padding does not still reads as two different pages.

It defaults to `MagicStarterManager.defaultPageContainerClassName` (`max-w-7xl px-4 lg:px-8 pt-6 sm:pt-8 pb-16`), so an app that configures nothing still gets a complete, capped, edge-padded page.

> [!NOTE]
> The vertical padding matters as much as the cap. The app layout's content region is a bare scroll view with no padding of its own, so a geometry with no `pt-*` glues the page header to the top edge of the viewport. If the host floats anything over its content region (a FAB, for instance), the bottom pad has to clear it.

Per-page tuning goes through `MSPageContainer`'s `className`, which is appended after the shared geometry:

```dart
MSPageContainer(
  className: 'pb-0', // This page ends with its own footer bar.
  child: pageBody,
)
```

## View Registry Integration

The manager holds a private `MagicStarterViewRegistry` instance, exposed via the `view` getter:

```dart
final MagicStarterViewRegistry _viewRegistry = MagicStarterViewRegistry();

MagicStarterViewRegistry get view => _viewRegistry;
```

The facade provides a shortcut: `MagicStarter.view` delegates to `manager.view`. All view and layout registration, lookup, and building flows through this single registry instance.

> [!TIP]
> Register overrides in your `AppServiceProvider.boot()`. `register()` on the registry replaces a key, and `registerDefaultViews()` skips keys that already exist, so an override wins in either order. The registry API is in [View Registry](https://anilcancakir.com/magic/docs/starter/architecture/view-registry.md).

## Default Views

`registerDefaultViews()` runs in the constructor and fills the registry with the package's views, layouts and modals. It registers a key only when it is absent, so an override registered earlier is never replaced. A view whose feature is off is not registered:

| Key | View | Registered when |
|:----|:-----|:----------------|
| `auth.login`, `auth.register`, `auth.forgot_password`, `auth.reset_password` | `MagicStarterLoginView`, `MagicStarterRegisterView`, `MagicStarterForgotPasswordView`, `MagicStarterResetPasswordView` | Always |
| `auth.verify_email` | `MagicStarterVerifyEmailView` | `hasEmailVerificationFeatures()` |
| `auth.two_factor_challenge` | `MagicStarterTwoFactorChallengeView` | `hasTwoFactorFeatures()` |
| `auth.handoff` | `MagicStarterHandoffView` | `hasHandoffFeatures()` |
| `auth.otp_verify` | `MagicStarterOtpVerifyView` | `hasPhoneOtpFeatures()` |
| `settings.hub`, `profile.profile`, `settings.appearance`, `settings.security.password` | `MagicStarterSettingsHubView`, `MagicStarterProfileSubPageView`, `MagicStarterAppearanceView`, `MagicStarterPasswordView` | Always |
| `settings.language` | `MagicStarterLanguageView` | `hasExtendedProfileFeatures()` |
| `settings.timezone` | `MagicStarterTimezoneView` | `hasTimezoneFeatures()` |
| `settings.newsletter` | `MagicStarterNewsletterView` | `hasNewsletterFeatures()` |
| `settings.changelog` | `MagicStarterChangelogView` | `hasReleasesFeatures()` |
| `settings.security.two_factor` | `MagicStarterTwoFactorView` | `hasTwoFactorFeatures()` |
| `settings.security.sessions` | `MagicStarterSessionsView` | `hasSessionsFeatures()` |
| `settings.security.connected_accounts` | `MagicStarterConnectedAccountsView` | `hasSocialLoginFeatures()` |
| `teams.create`, `teams.settings`, `teams.invitation_accept` | `MagicStarterTeamCreateView`, `MagicStarterTeamSettingsView`, `MagicStarterTeamInvitationAcceptView` | `hasTeamFeatures()` |
| `teams.billing` | `MagicStarterBillingView` | `hasBillingFeatures()` |
| `support.index`, `support.create`, `support.thread` | `MagicStarterSupportIndexView`, `MagicStarterSupportCreateView`, `MagicStarterSupportThreadView` | `hasSupportFeatures()` |
| `support.feedback` | `MagicStarterFeedbackView` | `hasSupportFeatures()` and `hasSupportFeedbackFeatures()` |
| `support.chat`, `support.chat_panel` | `MagicStarterSupportChatLauncher`, `MagicStarterSupportChatPanel` | `hasSupportFeatures()` and `hasSupportChatFeatures()` |

`notifications.list` and `notifications.preferences` are not among them. Both screens live on `magic_notifications`'s own `Notify.view`, which `registerMagicStarterNotificationRoutes()` re-registers wrapped in this package's page geometry; see [Notifications](https://anilcancakir.com/magic/docs/starter/basics/notifications.md). The default layout and modal keys are in [View Registry](https://anilcancakir.com/magic/docs/starter/architecture/view-registry.md).

## MagicStarter Facade

`MagicStarter` is a static class (private constructor `MagicStarter._()`) that delegates every call to the manager singleton. It is the API host apps use:

| Facade method | Does |
|:--------------|:-----|
| `MagicStarter.bootstrap(...)` | Registers the identity contract (user factory, logout, locales, optional login and guest-claim callbacks, optional team trio) in one call |
| `MagicStarter.useUserModel(factory)` | `manager.userFactory = factory` |
| `MagicStarter.createUser(data)` | `manager.userFactory(data)` |
| `MagicStarter.useTeamResolver(...)` | `manager.teamResolver = config` |
| `MagicStarter.switchTeam(id)`, `MagicStarter.currentTeamId()` | Switch the active team, read its id |
| `MagicStarter.useNavigation(...)` | `manager.navigationConfig = config` |
| `MagicStarter.useTheme(theme)` | `manager.theme = theme` (sets every sub-theme) |
| `MagicStarter.useWindTheme(windTheme)` | `manager.applyWindTheme(windTheme)`: derives the colour-bearing sub-themes from a `WindThemeData` and keeps the fields the host set; see [wind_kit Theme Adoption](https://anilcancakir.com/magic/docs/starter/guides/wind-theme-adoption.md) |
| `MagicStarter.useNavigationTheme(theme)` | `manager.navigationTheme = theme` |
| `MagicStarter.useModalTheme(theme)` | `manager.modalTheme = theme` |
| `MagicStarter.useFormTheme(theme)` | `manager.formTheme = theme` |
| `MagicStarter.useAuthTheme(theme)` | `manager.authTheme = theme` |
| `MagicStarter.useCardTheme(theme)` | `manager.cardTheme = theme` |
| `MagicStarter.usePageHeaderTheme(theme)` | `manager.pageHeaderTheme = theme` |
| `MagicStarter.useHostPageChrome(owns)` | `manager.pageChrome.ownsWhen = owns` |
| `MagicStarter.pageChrome` | `manager.pageChrome` |
| `MagicStarter.useLayoutTheme(theme)` | `manager.layoutTheme = theme` |
| `MagicStarter.useDropdownTheme(theme)` | `manager.dropdownTheme = theme` |
| `MagicStarter.useButtonTheme(theme)` | `manager.buttonTheme = theme` |
| `MagicStarter.useFieldTheme(theme)` | `manager.fieldTheme = theme` |
| `MagicStarter.useSidebarFooter(builder)` | `manager.sidebarFooterBuilder = builder` |
| `MagicStarter.useHeader(builder)` | `manager.headerBuilder = builder` |
| `MagicStarter.useLogout(callback)` | `manager.onLogout = callback` |
| `MagicStarter.beforeLogout(hook)` | Adds a before-logout hook |
| `MagicStarter.useLogin(callback)` | `manager.onLogin = callback` |
| `MagicStarter.useGuestClaimed(callback)` | `manager.onGuestClaimed = callback` |
| `MagicStarter.useSocialAuth(socialAuth)` | `manager.socialAuth = socialAuth` |
| `MagicStarter.usePageContext(provider)` | `manager.pageContextProvider = provider`: what the customer is looking at, sent with each support message (see [Support](https://anilcancakir.com/magic/docs/starter/basics/support.md)) |
| `MagicStarter.useLocaleOptions(locales)` | `manager.localeOptions = options` |
| `MagicStarter.useGuestAuthEntry(builder)` | `manager.guestAuthEntryBuilder = builder` (stored only: no starter screen reads it) |
| `MagicStarter.useNewsletterLabel(label)` | `manager.newsletterLabel = label` (read by the register view as its newsletter checkbox label, not by the profile newsletter page) |
| `MagicStarter.view` | `manager.view` |
| `MagicStarter.isReady` | `manager.isReady` |

The `reset()` method on the manager restores every property to its default, clears the view registry and registers the default views again. Tests call it in `setUp()` for isolation.

## Related Documentation

- [Service Provider](https://anilcancakir.com/magic/docs/starter/architecture/service-provider.md): binds the manager and registers the Gate abilities.
- [View Registry](https://anilcancakir.com/magic/docs/starter/architecture/view-registry.md): the string-keyed view factory the manager holds.
- [Installation](https://anilcancakir.com/magic/docs/starter/getting-started/installation.md): `MagicStarter.bootstrap()` in the host's `AppServiceProvider`.
- [Views and Layouts](https://anilcancakir.com/magic/docs/starter/basics/views-and-layouts.md): the app shell the layout theme styles.
- [Magic service container](https://anilcancakir.com/magic/docs/architecture/service-container): singleton and factory bindings.
