magic_starter 0.0.35 copy "magic_starter: ^0.0.35" to clipboard
magic_starter: ^0.0.35 copied to clipboard

Starter kit for Magic Framework. Auth, Profile, Teams, Notifications — 14 opt-in features with overridable views.

Changelog #

All notable changes to this project will be documented in this file.

[Unreleased] #

0.0.35 - 2026-09-22 #

Changed #

  • The sidebar toggle states that it does not wait on its write, and a test ties its labels to the stub. Two review notes on #151 closed after it merged: the toggle's onTap now wraps the preference write in unawaited, so dropping the future reads as the decision it is (the rail moves on setState; a failed write still surfaces in the zone), and a test asserts nav.collapse_sidebar and nav.expand_sidebar exist in assets/stubs/install/en.stub, which the widget tests could not see because they find the raw keys. No behaviour change. (lib/src/ui/layouts/magic_starter_app_layout.dart, test/ui/layouts/magic_starter_app_layout_test.dart)

  • Every sibling floor names this batch's release. magic moves ^0.0.15 to ^0.0.16, magic_notifications ^0.3.3 to ^0.3.4, magic_payments ^0.0.3 to ^0.0.4 and fluttersdk_wind ^1.6.2 to ^1.6.3; fluttersdk_artisan stays at ^0.0.16, still the newest. The old ranges already admitted the new versions, so a fresh pub get resolves nothing differently; what changes is that the floors name the releases this package is verified against. The install command's magicNotificationsConstraint moves with the notifications floor, and the issue template's version placeholder, four releases stale, names this one. wind 1.6.3 makes bg-transparent resolve for the first time, and seven defaults here carry it (secondaryButtonClassName, themeToggleClassName and guestButtonClassName in both theme presets, and the button recipe's ghost intent): where one follows another background token, transparent now wins as it was written to. magic 0.0.16 widens file_picker to admit 13; the profile photo goes through Pick.image, which is image_picker, so nothing here calls it. (pubspec.yaml, lib/src/cli/commands/magic_starter_install_command.dart, .github/ISSUE_TEMPLATE/bug_report.yml)

Fixed #

  • The brand className examples no longer hide the brand. MagicStarterNavigationTheme's class doc, the README and doc/architecture/manager.md showed gradient text as bg-gradient-to-r ... bg-clip-text text-transparent. Wind has no bg-clip-text, and until wind 1.6.3 it had no working text-transparent either, so the example rendered a gradient box behind ordinary text. From 1.6.3 the same className hides the label, so an adopter who copied it gets an empty brand bar. The examples are a plain text colour now, and the field's doc says gradient text goes through brandBuilder with a ShaderMask. (lib/src/configuration/magic_starter_theme.dart, README.md, doc/architecture/manager.md)

0.0.34 - 2026-09-22 #

Added #

  • The viewer can collapse the sidebar, and a collapsed sidebar can carry its own brand. Three fields, every default unchanged. One thing does move for a host already on the compact band: its rail brand is now centred (below).

    MagicStarterLayoutTheme.sidebarCollapsible (default false) adds a toggle above the user menu that collapses the labelled sidebar to its compact, icon-only form and expands it again. It appears only at or above sidebarExpandedBreakpoint: below it the window already decides, and a toggle there would promise an expansion the shell will not make. .sidebarCollapsedByDefault (default false) is the state before the viewer has chosen, and is ignored on a sidebar that is not collapsible, since a default the viewer cannot undo is a rail they are stuck with.

    The choice is remembered through Magic's Cache under magic_starter.sidebar_collapsed when the host binds one, with a ten year TTL: the cache has no forever, and the file store's own default of an hour would hand the viewer the default back the next morning. Without a cache the choice lives for the life of the shell.

    MagicStarterNavigationTheme.compactBrandBuilder (default null) is the brand the compact rail shows, for a host whose wordmark does not fit 80 logical pixels. Unset, the rail shows brandBuilder as before.

    The compact brand bar now centres its brand, as the compact rows centre their icons. The shipped bar is justify-between, which put a lone brand on the bar's left padding: measured at 1.5 pixels off the icon line, and visibly off on a consumer's rail. The shell appends justify-around to brandBarClassName on the rail only. With one child it centres exactly as justify-center would, and unlike it the row still distributes space, so Wind keeps wrapping the brand the way it did before: a brand wider than the rail stays bounded, and one Wind can read as a flex child (a WDiv or WText whose own className is flex-1, or an Expanded) passes through. A custom widget whose root is a flex-1 WDiv is still one Wind cannot read, and still takes its wrapper, so such a brand should not claim a flex share at its root. A Flexible added by the shell did the first and crashed the second with "Incorrect use of ParentDataWidget"; a review caught it before release.

    Reading the remembered choice is gated on sidebarCollapsible, because every Cache.get dispatches a hit-or-miss event and a host without the toggle would log a miss on every shell mount.

    The toggle reads two new keys, nav.collapse_sidebar and nav.expand_sidebar, and names itself through semanticLabel while it is icon-only. Both ship in the install stub; a host installed earlier adds them to its own language files, or the labelled form shows the raw key. (lib/src/configuration/magic_starter_theme.dart, lib/src/ui/layouts/magic_starter_app_layout.dart, assets/stubs/install/en.stub, doc/basics/views-and-layouts.md, doc/architecture/manager.md)

  • A guest can sign in or create an account from the user menu and the settings hub. A guest session is signed in, so the menu offered it profile, settings and logout and nothing that turns it into an account, and the hub offered only the in-place upgrade. For a user whose is_guest is true, MSUserProfileDropdown now starts with Sign in (the login route) and Create account (the profile route's upgrade, the door the hub's upgrade row opens, so the guest keeps its data), and the hub's Account group adds a Sign in row under the upgrade. The menu reads is_guest; the hub row follows the starter.delete-account gate like the upgrade row beside it, so a host that redefines that ability moves both rows together. Every label is an existing stub key: auth.sign_in, magic_starter.titles.register, auth.already_have_account. (lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart, lib/src/ui/views/settings/magic_starter_settings_hub_view.dart)

Fixed #

  • A guest reaches the login and registration pages instead of being sent home. RedirectIfAuthenticated redirected on Auth.check(), which a guest session passes, so every door a guest had to an account landed on the home route before the page built. It now lets a user whose is_guest is true through; an account is still sent home. (lib/src/middleware/redirect_if_authenticated.dart)
  • "Continue as guest" keeps a guest's session instead of replacing it. With the login page open to a guest, that button is the guest's way back, and it posted /auth/guest again, which revokes every token of a returning guest, including one a host keeps to claim the guest's data when it signs in to an account. doGuestLogin now goes home without a request when the user is already a guest. Found by a review of this release. (lib/src/http/controllers/magic_starter_guest_auth_controller.dart)

0.0.33 - 2026-09-22 #

Added #

  • The app layout's content area is a theme field now, so a host with fill-shaped screens can render at all. MagicStarterLayoutTheme.contentClassName (default 'flex-1 overflow-y-auto') is the className of the one WDiv the shell wraps the route child in, and .contentScrollPrimary (default true) is whether that area attaches to the ambient PrimaryScrollController. Both defaults are what the shell has always passed, so nothing moves for an existing host.

    The default scrolls the child and therefore hands it 0 <= h <= Infinity. That suits a page as tall as its content and is wrong for a fill-shaped screen: an h-full column whose body takes the slack and scrolls inside itself resolves its height against infinity and fails to lay out. Wind names it in debug (h-full inside a vertical scroll resolves to an unbounded height) and a release build reports the raw infinite-size failure instead. Such a host now sets 'flex-1 min-h-0' with contentScrollPrimary: false and owns its own scrolling.

    The flag is a second field rather than a value derived from the className, for two measured reasons. Wind reads scrollPrimary only inside the branch that builds a scroll view, so leaving it true beside a non-scrolling className claims nothing and is inert; it earns its keep when the className scrolls horizontally, where a hardcoded true would attach a horizontal viewport to the vertical primary controller. And deriving it would mean restating Wind's own overflow branch order here, a copy that goes silently wrong the first time Wind reorders it.

    Worth stating because the field exposes it: every one of this package's own views goes through MSPageScaffold, which brings its own SingleChildScrollView(primary: false), so the shipped default nests two scrollables on each of them. The default stays, because changing it would move layout for every existing host, and a host that puts every page through MSPageScaffold can set 'flex-1 min-h-0' and lose nothing.

    Found by a consumer app running against 0.0.32 rather than by reading it: at 1440x900 the shell itself was correct and all six of its screens were blank. (lib/src/configuration/magic_starter_theme.dart, lib/src/ui/layouts/magic_starter_app_layout.dart, doc/basics/views-and-layouts.md)

0.0.32 - 2026-09-22 #

Added #

  • The default app layout can now serve a rail, a television and a full-screen route, without being replaced. Four additions, every default unchanged, so an existing host sees nothing move.

    MagicStarterLayoutTheme.navigationBreakpoint (default 'lg') is the Wind breakpoint from which the persistent sidebar replaces the drawer plus bottom bar. It was hardcoded, so a host that wanted chrome on a 640 px window or on a television had to replace the layout to get it.

    MagicStarterLayoutTheme.sidebarExpandedBreakpoint (default 'lg') and .sidebarCompactWidth (default 80) are the compact rail: between the two breakpoints the sidebar renders at the compact width with icons only and every text label dropped, because a label at that width is clipped rather than shortened. Equal breakpoints, which is the shipped pair, leave the band empty and the compact form off. The compact width's floor is the compact team selector rather than the nav icon: MSTeamSelector's trigger is mx-3 p-2 around a w-8 avatar, 72 logical pixels, and sidebarClassName's border-r takes one more out of the box, so 72 was measured overflowing by exactly 1 pixel.

    Both breakpoint fields throw a StateError naming the field and listing the theme's own keys when the name is not one of them. wScreenIs resolves an unknown key to null and answers false, so a typo would pin the shell to its narrow form at every width, dropping every sidebar label or leaving a 4K window on the drawer for ever, with nothing to read. The values were hardcoded before they became fields, so the configuration is what introduced the failure class.

    MagicStarterNavigationTheme.focusItemClassName (default '') is applied to every navigation item, in the sidebar, in the drawer and on the bottom bar, so a host driven by arrow keys or a remote can light the destination that holds focus whatever the window is doing. The active and hover classNames had no focus sibling, and a keyboard user could not tell where they were.

    MagicStarterHideChrome is MagicStarterHideBottomNav's shape for the whole shell: a route group wrapped in it keeps the layout mounted, with its notification polling, its auth listeners and its route key, and hands the window to its child with no sidebar, no drawer, no header, no bottom bar, no safe-area inset and no scroll container. A media player or a map sizes itself, and a scroll view would hand it unbounded height.

    Asked for by a consumer app that was maintaining a parallel shell of its own for exactly these four reasons.

0.0.31 - 2026-09-21 #

Changed #

  • Every sibling floor names this batch's release. magic moves ^0.0.14 to ^0.0.15, magic_notifications ^0.3.2 to ^0.3.3 and magic_payments ^0.0.2 to ^0.0.3; fluttersdk_wind ^1.6.2 and fluttersdk_artisan ^0.0.16 were already the newest. starter:install writes magic_notifications: ^0.3.3 into the host's pubspec to match, since magicNotificationsConstraint and this package's own floor describe one dependency and a test fails when they disagree. The old ranges already admitted the new versions, so a fresh pub get resolves nothing differently; what changes is that the floors name the releases this package is verified against. magic 0.0.15 is breaking in its database layer (a migration may no longer manage its own transaction, and DB.transaction refuses a callback that closes the transaction itself); nothing in this package calls either, so no code here changes, but an app below magic 0.0.15 no longer resolves this release. (pubspec.yaml, lib/src/cli/commands/magic_starter_install_command.dart)

0.0.30 - 2026-09-19 #

Fixed #

  • A layout registered through registerLayout now keeps the per-route keying the default layouts carry. makeLayout wraps child in a KeyedSubtree keyed on the live route path before the builder sees it, and both default layouts drop their own copy.

    The keying was correctness rather than appearance and each default layout documented its own reason: MagicStarterAppLayout because a persistent shell reuses one child slot across routes, so swapping one scrollable view for another tears down render objects in a confused order under accumulated navigation; MagicStarterGuestLayout because under RouteTransition.none the outgoing and incoming routes are briefly mounted together, so a guest to guest move reparents the previous page's element tree instead of unmounting it. Neither lives in the router, so registerLayout was the exact moment a host app dropped both, silently and with nothing to read.

    Found by a consumer app, watchools, which replaced both layouts to get off Material and shipped the app half without the key. It came back by reading the layout it had replaced, which is not a way to find a defect.

  • Every sibling floor names this batch's release. magic goes ^0.0.12 to ^0.0.14, fluttersdk_artisan ^0.0.9 to ^0.0.16, fluttersdk_wind ^1.6.0 to ^1.6.2, and magic_payments ^0.0.1 to ^0.0.2. Every old range already admitted its new version, so nothing resolves differently for a consumer on a fresh pub get; what changes is that the floors say which releases this package is verified against. magic 0.0.14 is BREAKING (an unresolvable route middleware alias stops the app at Magic.init), so a consumer below it no longer resolves this package. magic_notifications stays at ^0.3.2, which is still its newest. (pubspec.yaml)

    The wrap goes around the child rather than around the builder's result on purpose: keying the shell itself would tear the navigation chrome down on every route change, which is the opposite of what a persistent shell is for.

    Both default layouts now say in their own doc comment that they must be constructed through makeLayout. They are public barrel exports, and after this change neither keys its own child, so a host reaching past the registry would get exactly the unkeyed shell this fixes, with the silent loss simply moved to a different door.

    _RouteScope asserts in debug when GoRouterState is unreachable. The key is computed wherever the HOST chose to render the child rather than at the layout's own build context, so a host placing it in an Overlay entry or a nested Navigator would get one constant key for every route and no remount at all, with nothing to read. Release behaviour is unchanged.

    test/ui/magic_starter_view_registry_test.dart's wraps child with registered layout builder asserted contains(child) and changes with the behaviour. (lib/src/ui/magic_starter_view_registry.dart, lib/src/ui/layouts/magic_starter_app_layout.dart, lib/src/ui/layouts/magic_starter_guest_layout.dart, doc/architecture/view-registry.md)

0.0.29 - 2026-09-17 #

Improvements #

  • CI takes the shape the sibling repos use, and the gate that was advertising a wait stops advertising it. The first job is Lint & Test, named as it is in magic and wind rather than describing its own mechanics. The second was called Published graph (red until magic_payments 0.0.1 is on pub.dev) and carried continue-on-error, which made it decoration: that wait ended when magic_payments 0.0.2 shipped, so the job is Published graph now and it blocks. It earns that, having gone red three times in one day for the right reason, once each for fluttersdk_wind ^1.6.0, magic_notifications ^0.3.2 and magic ^0.0.12 while those releases were still in flight, which is exactly the window an adopter would have met version solving failed in.

  • publish.yml creates the GitHub release it never created. magic and wind both cut one from the CHANGELOG section after publishing; this package did not, which is why 0.0.27 and 0.0.28 are on pub.dev while the newest release page here is v0.0.1-alpha.26 from 2 September. The tag always carried the code; what was missing is the page a changelog link or a dependency bump lands on. (.github/workflows/ci.yml, .github/workflows/publish.yml, CLAUDE.md)

Fixed #

  • Every settings screen can be left again: swipe back on iOS, system back on Android. MagicRoute.to() calls go(), which replaces the Navigator's whole page list, and none of this package's routes opted out of it, so a settings app was never more than one page deep. That reads as a missing iOS edge swipe and is worse than that on Android: Flutter reports canHandlePop: false to the platform at a stack depth of one, the embedder unregisters its own back callback, and the system back button LEAVES THE APP from a settings sub-page. Twelve routes now push instead: the eight settings spokes, team create and team settings, and both notification screens.

    The hub keeps replacing, and so does the invitation-acceptance route. A hub is where a host commonly points a nav destination, and a destination that pushes grows the stack every time its tab is tapped; an emailed invitation link is an arrival, with nothing behind it to go back to. The auth routes are untouched for a third reason: a login bounce is a go_router redirect rather than a to(), and stacking them risks leaving a login screen underneath a signed-in app.

    A stacked route now names no transition, which is the half a host controls. These routes each pinned RouteTransition.none, and magic reads an explicit value as opting OUT of MagicRouter.defaultTransition, so an app that had asked for the platform animation app-wide still got none of it here, and no back gesture with it: only RouteTransition.platform builds a page whose route mixes in MaterialRouteTransitionMixin, and Flutter installs the edge-swipe detector inside that transition rather than beside it. Dropping the pin sends them to the host's default, which is itself none, so an app that sets nothing sees exactly what it saw before.

    Requires magic ^0.0.12, and the floor is the point rather than a formality. RouteDefinition.stacked() exists in no release below it: git show 0.0.11:lib/src/routing/route_definition.dart has no stacked at all, and eleven routes here call it. A caret on a zero major spans the whole 0.0.x line, so a lower floor lets pub resolve an adopter onto 0.0.11 and hand them The method 'stacked' isn't defined inside a package they do not own instead of a version-solve message naming magic. magic 0.0.12 also carries the fix for a routed page that was transparent, which nothing could see until a route stacked: without it, these pushes show the outgoing screen through the incoming one for the length of the animation.

0.0.28 - 2026-09-16 #

Added #

  • MSAvatar, and the account's photo finally reaches the one avatar that is always on screen. A starter app drew a person's photo on the profile screen and nowhere else: MSUserProfileDropdown rendered an initial whatever the account carried, so uploading a photo looked like it had not worked. The component owns exactly two things, clipping the photo to the box and choosing between the photo and a fallback. It owns no size, no shape and no colour, because those genuinely differ per surface (32 logical pixels in a header against 80 on a profile screen, a circle for a person against a rounded square for a team) and a variant axis over them is a list nobody could finish; they arrive as className. The fallback is a WIDGET rather than a string, because the initials rule is not shared: this package takes one letter and a host app commonly takes the first letter of each of the first two words, and handing the rendered fallback in keeps both correct instead of making one of them wrong. It also lets the profile screens keep their person glyph, which is not initials at all. A photo that fails to load falls back too, so an expired signed link or a photo deleted on another device shows the initial rather than a grey box.

    Three call sites move onto it: the dropdown trigger gains the photo branch it never had, and the two profile screens each drop a verbatim-duplicated ClipOval/SizedBox/WImage block. The theme's dropdownAvatarClassName deliberately stays on the WDiv that carries the open and hover states rather than moving inside the avatar, which takes none: the shipped default has no state variants, so a host that themed one would have lost its hover branch with nothing on screen to show it. (lib/src/ui/components/avatar/, lib/src/ui/components/user_profile_dropdown/user_profile_dropdown.dart, lib/src/ui/views/profile/)

Fixed #

  • An MSAvatar with a photo is round again, instead of one wide band clipped out of it. The recipe base carried flex items-center justify-center to centre the fallback, and that flex starved the photo: inside a wind flex a child asking for w-full gets the SCREEN width rather than its parent's, and h-full collapses on the cross axis. Measured on a 36px header avatar, the image laid out 905.8 x 28.0 where the box is 36 x 36, so overflow-hidden showed one horizontal slice of the photo with the background visible above and below it. Reported off a TestFlight build as an avatar that was "not round, cut on the left and right", which is exactly what a 36-wide window onto a 906-wide image looks like. The two utilities that should have made the photo fill its box were doing the opposite, and no test caught it because the existing ones asserted which widget rendered rather than how big it was. The centring moved into the fallback branch, the only branch that ever needed it, where a full-size child of a non-flex box does resolve to that box. The errorBuilder branch beside it had no test either, and it is the one that matters most in production, since a signed link past its window and a photo deleted on another device both arrive as a failed load rather than as a null url; the test binding refuses every network image, so the case reproduces with no fixture. (lib/src/ui/components/avatar/avatar.dart, lib/src/ui/components/avatar/avatar.recipe.dart, test/ui/components/avatar/avatar_test.dart)

  • A bottom sheet lifts clear of the software keyboard. showModalBottomSheet compensates for the keyboard on nobody's behalf: viewInsets appears nowhere in Flutter's own material/bottom_sheet.dart, so a sheet asked for 85% of the screen keeps that height while the keys cover the bottom third of it. Every sheet carrying a field low in its body was therefore unusable on a phone. Measured on an iPhone 17: the keyboard took 335 of 874 logical pixels and the focused field sat 259 pixels under it, with nothing left to scroll because the body's own scroll view had not been told its viewport shrank. After the fix the same field ends 14 pixels above the keys. The height cap charges for the LARGER of the keyboard and the home indicator rather than their sum, because they describe the same strip of screen, and the panel's own safe-area strip goes to zero while the keyboard is up for the same reason. MediaQuery.removeViewInsets states that the inset is spent, so a body widget reading it does not reserve the same height a second time. (lib/src/ui/components/bottom_sheet/bottom_sheet.dart)

  • A native session is named instead of echoing the page title. A phone's own session rendered as "Browser Sessions" beside a laptop icon: the fallback title was the SECTION HEADING, so a Turkish app read "Tarayıcı Oturumları" as the name of a device directly beneath a heading saying the same words. The row now takes the application's name for a native client, from the agent.app that magic-starter-laravel sends, and the empty-everything fallback moves to profile.unknown_device. "iOS - Uptizm" rather than the page's own title. agent['app'] is read defensively, so an older backend that sends no such key reads exactly as it does today. sessionDeviceTitle is top-level and public so the rule can be tested for what it is, three inputs and two empty cases, without standing up the view and its network.

    Adopters add one key, profile.unknown_device, and it is a live regression on UPGRADE as well as a new key at install. An app that published its catalogue before this release renders the raw key on any session whose agent is unreadable, until the key is added. The install stub carries it for a fresh install. (lib/src/ui/views/settings/security/magic_starter_sessions_view.dart, assets/stubs/install/en.stub)

  • This package's own page padding no longer doubles magic_notifications' own. Both notification screens are mounted here inside MSPageContainer, which carries the host app's width cap and edge margins, and the package padded its content column on top of that. The two applied to the same edge, so these two pages sat twice as far from the display as every other page in the shell: measured on a phone at 32 logical pixels against the host's 16. contentClassName: '' hands the geometry to the container alone.

    Requires magic_notifications 0.3.2, and the floor is declared rather than deferred. A caret spans the whole 0.3.x line, so leaving it at ^0.3.0 would not merely keep CI red until that release: after it shipped, pub could still resolve an adopter onto 0.3.0 or 0.3.1 and hand them a COMPILE error at notification_routes.dart rather than a version-solve message naming the package. ^0.3.2 turns that into a readable refusal.

    magicNotificationsConstraint moves with it, so starter:install writes ^0.3.2 into a host's pubspec. A scaffolding consumer sees that change. The two are held together by a test, because when they disagree pub finds no intersection and answers by walking magic_starter BACKWARDS to an older release rather than failing: an adopter enabling notifications at install time would silently scaffold onto an older starter.

    magic_notifications 0.3.2 and fluttersdk_wind 1.6.0 are both on pub.dev as of this release, so the hosted graph resolves; the floors were declared before either shipped and the CI job that resolves against pub.dev was red until they did.

    The second screen that draws sessions got the same fix, one release late. MagicStarterProfileSettingsView keeps its own sessions section and was still building the row title from platform and browser alone, so the same phone read as the page heading there while the sessions screen named it correctly. Both screens are exported and publishable, so an adopter who published the profile one saw the old behaviour after the fix landed. It now calls sessionDeviceTitle and falls back to profile.unknown_device like its sibling, and two widget tests drive the section rather than trusting the call sites to stay in step: a native agent, and the unreadable one that was rendering an empty title.

    sessionDeviceTitle moves to lib/src/support/ and is exported from the package barrel, because both screens that draw sessions are PUBLISHABLE and starter:publish copies a view's imports verbatim. A helper reachable only through a private src/ path would leave a host choosing between an implementation_imports violation and reimplementing the rule, which is the drift this entry exists to end. (lib/src/support/session_device_title.dart, lib/src/ui/views/profile/magic_starter_profile_settings_view.dart, lib/src/ui/views/settings/security/magic_starter_sessions_view.dart, lib/magic_starter.dart)

  • The timezone select loads the pages after the first one, and keeps its place across a reopen. The endpoint pages over 400 IANA identifiers and this widget asked for page one and nothing else, so scrolling the dropdown to the bottom did nothing and the rest were reachable only through the search box, which nothing on screen says is mandatory. WSelect already had the whole mechanism, watching its own scroll position and calling onLoadMore within 50 pixels of the end, gated on hasMore; both were simply never passed, so this is a caller fix rather than a widget one. A response carrying no meta counts as the last page rather than as unknown, because guessing upward makes the select ask for a page that does not exist every time the reader reaches the bottom, and a search resets the cursor so the next scroll asks for page two OF THAT QUERY. The loader RETURNS its rows instead of pushing them into options: WSelect owns the visible list once a search has filtered it and resets that list whenever options changes identity, so appending from the caller would throw the filtered list away mid-scroll.

    The reopen is the half a first pass missed. WSelect clears its search and restores options every time the menu opens, and the cursor survived that reset: open, scroll once to pull page two, close, reopen, and page two's twenty identifiers were unreachable without searching for them, which is the same symptom pagination exists to remove. The cursor now resets through WSelect.onOpen, and the unfiltered has-more is held separately from the running query's, so a search that ended on its last page does not leave the restored full list reporting that it has no more.

    Every async write now checks that what it is about to say is still true, which took four rounds because each guard answered a narrower question than the one before it missed. A search has no cursor to compare against, since writing one is the thing it is about to do. A page compares the page it asked for against the page after the current one, which agrees with itself in the case that matters: a page two in flight while the cursor sits at page one is indistinguishable from a page two about to be asked for, and the reopen early-returns there because the cursor never left base. So both capture an epoch the reset bumps, and a response whose epoch has moved writes nothing.

    Cancelling the debounce is still done and is still not enough, because a cancel reaches only a timer that has not fired: once it has, the request is on the wire and the window is network latency rather than 300ms. The cancel answers the pending completer the way a superseding keystroke does, since WSelect awaits that future behind its own in-flight flag.

    The epoch cannot see the last one, because both requests belong to it: type past the debounce twice and two searches are on the wire at once, and the cursor was written by whichever LANDED last rather than whichever was asked for last. WSelect gets its own half right and drops the older list on its query mismatch, which is what made this quiet, since the visible rows stay the newer query's while the cursor names the older one and the two only disagree on the next scroll. The completer identity is the term that closes it.

    Two ways the list could end early, both silent. _fetchTimezones answers hasMore: false on any failure and the load-more path wrote that straight into the cursor, so ONE dropped request ended pagination for the life of the open menu: the reader scrolls to the bottom and nothing loads again until they close and reopen, which they have no reason to try. A failed page no longer advances the cursor or clears the flag, so the next scroll retries. And _hasNextPage read only meta.last_page, which is a LengthAwarePaginator field: a SimplePaginator sends a meta without it and the widget would have answered "no more" on page one and reverted to the single-page behaviour this entry exists to remove. links.next is the fallback, which it does send. The endpoint is length-aware today, so the second is about the shape changing rather than about the shape now.

    A CURSOR paginator is not covered and the fallback does not make it so: the request is built as page=$page, which a cursor paginator ignores, so it would serve page one again with links.next still set and every scroll would append the same rows. Covering one means changing how the url is built, not how the response is read.

    The failure is carried rather than inferred, because an empty page is not a failure. A dropped request and a page that genuinely is empty and genuinely is last both arrive as no rows and no more pages, so reading the empty page as the failure signal fixed one and created the other: the cursor stayed put and hasMore stayed true, and every later scroll to the bottom re-requested the same page for as long as the menu was open. _TimezonePage now carries failed, which says the request taught us nothing about the list, and only that ends the retry. A LengthAwarePaginator cannot produce an empty non-final page, but the links.next path above can (it says another page exists without saying it has rows) and so can the row filter, when every entry on a final page is malformed. A 200 whose data is missing or not a list counts as failed too: the server answered and the answer is unreadable, which is not the same as there being no more rows.

    Requires fluttersdk_wind ^1.6.0, a floor rather than a reservation: the code passes an argument 1.5.3 does not accept. It is declared here directly rather than left to reach this package through magic, since a transitive constraint gives nothing to raise when this package starts calling a new Wind API, and the adopter's symptom would then be undefined_named_parameter inside a package they do not own. Minor rather than patch because onOpen is a new public parameter. (lib/src/ui/widgets/magic_starter_timezone_select.dart, pubspec.yaml)

0.0.27 - 2026-09-12 #

Changed #

  • The version rail leaves the alpha suffix: 0.0.1-alpha.N becomes 0.0.N, and this release is the alpha.27 that was never tagged. Twenty-six releases arrived as prereleases of a 0.0.1 that never shipped, which is not what the suffix means: every one of them was the current release, and pub.dev showed the package as having no stable version at all while an adopter's flutter pub add magic_starter refused to take it without an explicit prerelease pin. The counter is carried rather than reset, so 0.0.27 follows 0.0.1-alpha.26 and the release history stays monotonic.

    No adopter action. A caret raises the MINOR whenever the major is zero, so an existing ^0.0.1-alpha.26 reads >=0.0.1-alpha.26 <0.1.0 and takes 0.0.27 on the next pub upgrade without a re-pin. The rail change is visible in magicStarterVersion, which the starter:* banners print, and in the pin the README and the installation doc show.

  • starter:install writes magic_notifications: ^0.3.0 into the host pubspec, not ^0.0.1-alpha.1. The installer's literal had not moved since the notification screens lived in this package, so a scaffolded app asked for a range that ends at 0.1.0 while this package's own floor has required 0.1.0 or later since alpha.25. The two describe one dependency and left pub no intersection to solve, which pub answers by walking magic_starter back to a release that fits rather than by failing: enabling notifications at install time silently scaffolded an app onto an older starter. The constraint is now magicNotificationsConstraint, and a test reads it against the floor in pubspec.yaml so the two cannot drift apart again.

  • Requires magic_notifications ^0.3.0, and this is a reachability move rather than a compilation one. Nothing in this package needs an API that release added; what it needs is to stop excluding it. A caret raises the MINOR whenever the major is zero, so the previous ^0.2.0 reads >=0.2.0 <0.3.0, and an app that asks for magic_notifications: ^0.3.0 alongside this package leaves pub with no intersection to satisfy. Pub does not report that as a conflict: it walks magic_starter back to an older release that fits and exits zero, so an adopter who upgrades notifications on their own gets a silently downgraded starter instead of an error. The same shape cost magic_example two starter releases before anybody noticed. Nothing else in this package moves with the floor.

  • The install stub carries the five notifications.* keys the package's own screens ask the host for. magic_notifications ships no catalogue and Translator.get answers a missing key with the key itself, so each of these renders as visible machine text on a freshly installed app. Two arrive with 0.3.0's bulk card, notifications.bulk_title and notifications.bulk_description, which sit above the preference matrix on a screen an adopter has already translated. Three have been missing since the 0.2.0 floor landed in alpha.26 and are caught here rather than left for another release: notifications.delete is the accessible name of the list row's delete control, which is a bare glyph a screen reader otherwise announces as "button"; notifications.delete_failed is what Magic.error shows when a delete the server refused rolls back; and notifications.channel_sms labels the SMS row, which magic-starter-laravel offers in the preference matrix out of the box. An app installed before this release has its own copy of the catalogue and has to add all five by hand.

Added #

  • starter:doctor prints the push external id prefix the config resolves to. Four sites compose one external id and a mismatch between them is accepted by OneSignal and delivered to nobody, which is the failure the key above exists to prevent and the one nothing could see: three of the four are in magic-starter-laravel and no check inside this repository can reach them. So the prefix is reported rather than checked, and never fails, since any value is valid as long as the backend uses the same one. --verbose adds the key name and config/magic-starter.php's onesignal.external_id_prefix to read it against. The line is omitted entirely when the notifications feature is off, because an app that sends no push has no side to agree with. The value is resolved the way the runtime resolves it, trimmed and with a blank one falling to user_ rather than to no prefix, and the report distinguishes the three ways it got there: a declared value prints bare, a blank key prints (blank, using default) and an absent one prints (default, key not set). Those last two resolve identically and are not the same situation, since being told the key is not set sends an adopter to write a key they already wrote instead of telling them the value they wrote is being discarded. It is read from lib/config/magic_starter.dart textually, with whole-line // comments skipped so a commented-out old key above the live one does not win: the command boots nothing, so an app that writes the key at runtime through Config.set is not covered, and neither is a block comment around the key.

  • A session now declares the push identity it was tearing down. Notify.logoutPush() had one caller (MagicStarterAuthController.logout) and initializePush had none, anywhere in this package or in magic_example, so an adopter who wired nothing got a device subscribed under no external id while the Laravel counterpart addressed user_<id>. Nothing on the client failed: the only trace was a zero-recipient report on the server. MagicStarterServiceProvider now declares <prefix><user id> whenever the auth state goes to signed-in, hung off Auth.stateNotifier because five ways into a session live in the auth controller and a sixth, Auth.restore() on a cold boot, lives nowhere a call site can see; the notifier is the one funnel all of them pass through. The prefix is a new config key, magic_starter.notifications.external_id_prefix, defaulting to user_ because that is what HasNotifications composes on the backend, and the two have to agree exactly. A blank value resolves to user_ rather than to no prefix, and surrounding whitespace is trimmed, matching what the PHP side does: OneSignal rejects a bare numeric external id outright, so emptying the key restores the default rather than switching the prefix off. Repeat declarations cost nothing: want returns early on an unchanged intent and the permission prompt it raises first is claimed once per process, which matters because the notifier also bumps for a team switch.

    The same listener releases the identity on the sign-outs the auth controller never sees, and stops the poller with it. Account deletion (MagicStarterProfileController.doDeleteAccount) and magic's AuthInterceptor on a failed token refresh both call Auth.logout() bare, so the controller's logoutPush() never runs for either. The intent is persisted, so declaring one without this would leave a device whose account was just deleted subscribed as that account across restarts and still receiving its pushes. Not a race with the controller, which tears down before it calls Auth.logout(): by then want(null) returns early.

    Notify.stopPolling() goes with it, and that is the behaviour change an app would notice if it relied on polling continuing past a bare Auth.logout(). It did not stop before: the auth controller pairs the two and those paths reach neither, so after an account deletion the poller kept issuing GET /notifications with a dead token and nothing watched the 401. Polling is re-armed by MagicStarterAppLayout.initState when the shell remounts after a re-login, so a normal sign-out and sign-in is unaffected.

    A session restored before this provider boots is declared too. Providers boot in order and this one goes last, so on the ordinary cold start AuthServiceProvider has already awaited Auth.restore() and bumped the notifier, and the notifications provider has already attached its driver on a broadcast stream that replays nothing to a later subscriber. Subscribing alone therefore declared nothing at all for somebody already signed in, which is the launch this whole change exists for; the provider now reads the current state once at boot as well as listening for the next change. What hid it is that a cold boot usually bumps a second time from the unawaited user sync inside restore(), a rescue that is absent on a token with no cached user, on a device with no network, and with no userEndpoint configured.

    That boot read runs whether or not anybody is signed in, so a launch with no session releases the identity once, and it asks the vault before it does. It is the same single call taking the signed-out branch rather than the declaring one, and the release is real rather than incidental: a device whose last session ended through a path the auth controller never saw has its persisted intent dropped at the next boot instead of carrying it forever. An app with magic_starter.features.notifications off reaches none of it, since the whole listener returns before this.

    What decides it is the token and not Auth.check(), which is _user != null and answers a different question. Auth.restore() on a stored token with no cached user awaits the user sync, and a transport failure keeps the session and returns without setting a user, because magic reads a statusCode 0 as "nobody answered" rather than as a rejected token. So somebody signed in on a dead mobile link reaches this boot with check() false and a good token, and releasing there is not free: logoutPush() clears the cached notifications unconditionally, and its want(null) reads the VAULT before the equality check, so a device carrying user_42 persists null and reaches the driver's logout. This release is therefore gated on Auth.hasToken(), which is safe because BaseGuard.logout() awaits its token clear BEFORE it bumps the notifier, so a real sign-out always arrives with an empty vault. A token clear that THREW is the case left over: the guard rethrows to its caller and the device stays subscribed while the app shows it signed out.

    It also subscribes to Notify.manager.onPushDriverAttached, which closes the ordering magic_notifications documents: auth providers register ahead of the notifications one, so a cold boot declares an identity while no driver exists to carry it, and without a second pass the OS permission ask is skipped for the whole launch. No host action is needed; this provider boots in every starter app. An app that already declares its own identity keeps working, and one using a different prefix sets the new key.

  • A deep link that lands on a signed-out device now survives the login bounce. EnsureAuthenticated.redirectTarget records the requested location via MagicRouter.setIntendedUrl before bouncing an unauthenticated visitor to login, and a new NavigatesRoutes.navigateHome reads it back with pullIntendedUrl once they authenticate, falling back to MagicStarterConfig.homeRoute() when no intent was stored or the stored value is not an in-app path. Nothing is recorded for the login route itself or for any other guest-only auth route (register, forgot-password, reset-password, two-factor-challenge, otp), since a bounced visitor cannot use one of those as a destination either. All five post-auth navigations (login, register auto-login, two-factor challenge, OTP verification, guest login) now call navigateHome() instead of navigating straight to the home route. Known limit: redirectTarget only ever sees state.matchedLocation, which carries no query string, so a recorded intent loses any ?token=... the original link carried.

    An intent belongs to the session that asked for it, and ending that session discards it. Signing out flips the auth state, which re-runs go_router's redirects while the app is still on the protected route, so EnsureAuthenticated writes that route down as somewhere to return to. Nobody asked for it: a sign-out on /teams/settings would otherwise send the NEXT person who signs in on that device straight there, and the account-deletion path would send them to a deleted account's settings. MagicStarterServiceProvider now listens to Auth.stateNotifier and discards the intent whenever the state goes to signed-out, which is the one funnel all three logouts pass through: the two a user asks for, and the one the app performs on its own when magic's AuthInterceptor fails a token refresh, which no call-site clear can reach. In this provider rather than in SessionScopeSync, which listens to the same notifier: that one is opt-in and nothing in this package calls attach(), so an app that never adopted SessionScopedController would have had no clear at all. This provider boots in every starter app, so no host action is needed. The clear is deferred by a microtask because the whole record path is synchronous and clearing inline would run before the redirect that writes the value. A host route with an ASYNC redirect records after that microtask and is not covered.

0.0.1-alpha.26 - 2026-09-03 #

Changed #

  • Requires magic_notifications ^0.2.0, and the delete confirmation now answers whether it went ahead. NotificationsListView.onDelete changed to Future<bool> in that release, so _confirmThenDelete returns false when it refuses (no navigator to ask in, or somebody said no) and true after a delete the server accepted. That answer is the whole reason the signature changed: the list reloads its page after a real delete, because a row leaving page one pulls one up from page two and only the server knows which, and with nothing to read it had to reload after EVERY tap. So this dialog, the one this package added in the same Unreleased block, was costing a full GET /notifications every time somebody declined it. Nothing about the dialog itself changes.

Added #

  • A delete asks first. The notification list's delete is destructive, irreversible and one tap away in a scrollable list, so the mount now shows this package's own MSConfirmDialog and only calls Notify.deleteNotification once somebody says yes. Asked here rather than in magic_notifications, which removed its own dialog widget in 0.1.0 precisely so a published package stops imposing one adopter's tone and layout; this keeps the confirmation in the same package as every other destructive confirmation a starter app shows, and looking like them is the point: MSConfirmDialog reads MagicStarter.manager.modalTheme, while Magic.confirm styles from view.confirm.* with light-mode fallbacks and would have shipped the one destructive dialog in the app that ignores the host's dark mode. The dialog is shown against MagicRouter.instance.navigatorKey.currentContext, since neither the view registry nor onDelete provides a BuildContext; a null context refuses rather than deleting, because nobody could have been asked. Copy comes from notifications.delete_confirm_title, notifications.delete_confirm_message, common.delete and common.cancel, and all four now ship in assets/stubs/install/en.stub.

  • common.delete, notifications.delete_confirm_title and notifications.delete_confirm_message in the install stub. The stub is the catalogue starter:install scaffolds into every consumer project, and Translator.get answers a missing key with the key itself, so without these a freshly installed app would open a dialog titled notifications.delete_confirm_title with a confirm button reading common.delete. An app with a hand-written catalogue still needs them added.

Fixed #

  • The notification list can delete a notification again, which it never could. _mountNotificationViews() built const NotificationsListView(), and that view renders its per-row delete control only when onDelete is non-null, so the affordance never appeared. Because this registration REPLACES the package's own default in order to apply the host page geometry, its null was the whole ecosystem's answer: Notify.deleteNotification and the DELETE /notifications/{id} route behind it were working code with no surface anywhere. The mount now passes onDelete: Notify.deleteNotification, and a test asserts the mounted view carries it, which turns red if the parameter is dropped again. The nullable parameter itself is unchanged and still lets a host opt out by registering its own screen.

0.0.1-alpha.25 - 2026-09-02 #

Fixed #

  • The host page geometry now actually reaches the two notification screens. _mountNotificationViews() gated on Notify.view.has(key), and reading Notify.view is what seeds magic_notifications' own two screens into the registry, so the key was always present and this mount ALWAYS skipped: the MSPageContainer and the 1280 width cap it exists to apply never reached either screen in a real app. Three tests covered that wrap and all three passed, because their setUp called Notify.view.clear() and left the registry empty, which is not the state an app boots with. The gate is now hasOverride(key), which is true only when somebody CHOSE a screen rather than when the package seeded its default, and the tests reset with Notify.forgetView() so they run against a registry carrying those defaults. Reverting the gate to has now turns three tests red.

  • A host that registers a notification view BEFORE the routes are mapped no longer loses it. _mountNotificationViews() called Notify.view.register unconditionally, while every other default in this package is installed register-if-absent (MagicStarterManager._registerDefault checks has(key) first) precisely so provider order does not matter. The two calls land in different files by design: the installer injects the route mount into route_service_provider.dart, and the scaffold tells adopters to do their Notify.view work in AppServiceProvider, so which boot runs first is a property of the host's provider order that neither file can see. An adopter following that guidance had their screen silently discarded. Both orders now win, and each has its own test.

  • The two starter:* command banners printed v0.0.1. Twenty-four alpha releases in, publish and uninstall were still announcing the version they were written against, because each carried a hand-written literal that nothing compared with anything. They read magicStarterVersion now, which starter_artisan_provider_test.dart pins to pubspec.yaml, the same guard magic_notifications already uses for its seven.

Breaking #

  • The whole notification UI moved to magic_notifications, and this package re-exports none of it. Four barrel exports are gone with no shim and no deprecated alias: MagicStarterNotificationController, MagicStarterNotificationsListView, MagicStarterNotificationPreferencesView and MSNotificationDropdown (the src/ui/components/notification_dropdown/index.dart export). The seven source files behind them are deleted. The replacements ship in magic_notifications >= 0.1.0 under the names NotificationPreferencesController, NotificationsListView, NotificationPreferencesView and NotificationDropdown, all reachable from package:magic_notifications/magic_notifications.dart, and the dropdown's five constructor parameters (notificationStream, onMarkAsRead, onMarkAllAsRead, onNotificationTap, onViewAll) are unchanged, so remounting a bell is an import plus a rename. Two packages shipping the same screen is what made this a move rather than a copy: the notification package advertised a widget it did not ship, this package shipped one, and consumers had written a third.

  • MagicStarter.useNotificationTypeMapper(...), MagicStarter.notificationTypeMapper, MagicStarterManager.notificationTypeMapper and the MagicStarterNotificationTypeMapper typedef are removed. Saying what a notification type looks like is now the notification package's own slot, and it answers the same question for the list screen and the bell at once: Notify.view.slot(NotificationViewRegistry.typeIconSlotView, 'monitor_down', (context) => WIcon(Icons.error_outline, className: 'text-lg text-red-500')). Register 'default' as the slot name to answer for every remaining type. The manager's reset() no longer clears the field, because there is no field.

  • MagicStarter.view no longer registers notifications.list or notifications.preferences. The two keys live on Notify.view, whose API is identical (register / has / make / slot / buildSlot / clear), so an app that overrode a notification screen moves the same call from one registry to the other. A registration made after registerMagicStarterNotificationRoutes() still wins, which is where a host swap belongs.

  • starter:publish --tag=views:notifications is gone. This command copies files this package ships, and it no longer ships those two. Customize them through Notify.view instead of by publishing a copy.

  • magic_notifications is now required at ^0.1.0. This is a floor for an API, not an upper-bound fix. The old ^0.0.2 was not merely too low, it could not express the requirement at all: a caret on a 0.0.x resolves >=0.0.2 <0.1.0, because pub_semver raises the MINOR whenever the major is zero, so it admitted 0.0.3, which has no Notify.view for this package's own routes to resolve against, and excluded 0.1.0, which is the release that has it. ^0.1.0 is >=0.1.0 <0.2.0 by the same rule and admits nothing below the release carrying the API.

    Three symbols make the floor exact rather than approximate: Notify.view for the routes, and hasOverride plus forgetView, which arrived in 0.1.0 and are what keep this package's page geometry from being skipped by its own register-if-absent mount.

Changed #

  • Route registration stays here, and it is the mount point for the host's page geometry. registerMagicStarterNotificationRoutes() is unchanged in name, in path (/notifications and /settings/notifications) and in shell (layout.app); only what the routes build changed, to Notify.view.make('notifications.list') and Notify.view.make('notifications.preferences'). It also re-registers both screens wrapped in MSPageContainer under the shared page surface, because the width cap and the edge margins come from MagicStarterManager.pageContainerClassName and a published notifications package cannot resolve that; without the wrap the two pages would spread the full shell width while every neighbouring page stayed capped. The preferences screen's back control is supplied the same way: it takes backRoute as a parameter now, and this package passes MagicStarterConfig.settingsHubRoute() so the affordance behaves exactly as before.
  • starter:install no longer scaffolds a type-mapper call. With the notifications feature enabled the generated AppServiceProvider now carries a commented example of the notification package's icon slot, including the import it needs, rather than a call to an API this release removes.

0.0.1-alpha.24 - 2026-08-30 #

Fixed #

  • Every route this package registers now carries a page title. All 20 had none, so TitleManager fell back to the application title and a browser tab read the bare app name on the login screen, the register screen, the whole Settings hub, profile, teams and notification preferences alike. Measured on a consumer on 2026-08-29: its own 21 routes resolved their titles correctly in both languages while all 20 of this package's read Uptizm. Nothing failed anywhere; a missing title is a silent fallback by design, and no test asked.

Added #

  • magic_starter.titles.* in the install stub, 20 keys. A title is a translation key and the catalogue is the CONSUMER's, exactly like every other key this package references. A fresh starter:install picks them up. test/routes/route_titles_test.dart sweeps every registered route and fails on a route with no title, a title whose key the stub does not ship, and a stub key no route uses.

Upgrading an existing app: merge the magic_starter.titles block from assets/stubs/install/en.stub into your own catalogue, and add your other locales. Until you do, trans() returns the key itself and a tab reads magic_starter.titles.login, which is worse than the bare app name it replaced. This is the only manual step.

0.0.1-alpha.23 - 2026-08-29 #

Fixed #

  • The billing toggle existed in code and in no template, so an adopter could not find it. MagicStarterConfig.hasBillingFeatures() reads magic_starter.features.billing and gates the whole teams.billing view, but neither lib/config/magic_starter.dart nor the install stub mentioned the key, and MagicStarterInstallCommand.dynamicFeatureKeys did not carry it either, so starter:install --features=billing did nothing. Both templates now ship it, along with routes.billing and the billing.web_origin key the billing view needs.
  • starter:configure could not see two toggles. MagicStarterConfigHelper.featureKeys was a second list of the same thing as dynamicFeatureKeys, and the two had drifted in opposite directions: this one was missing timezones, that one was missing billing. There is one list now, in the helper, and the install command points at it.

Added #

  • starter:doctor reports a billing install with no web_origin. The billing view builds Stripe's successUrl, cancelUrl and the portal returnUrl by concatenating that origin with a path, and Stripe rejects a relative url. The resulting BillingException is logged rather than shown, so an adopter who enabled billing and skipped the key saw a checkout button that did nothing and no reason why. The check is silent when billing is off and when the config file is absent, since a missing config is already reported once.
  • test/configuration/config_template_parity_test.dart, which pins every feature key MagicStarterConfig reads against both config templates and both CLI lists, in both directions. This is the test that was missing: five places had to agree about the feature set and nothing checked that they did.

Documentation #

  • The README feature table and the configuration guide carry billing, its route and its origin key. The guide gains a Billing section that says why web_origin has no default.

0.0.1-alpha.22 - 2026-08-29 #

Added #

  • MSDataTable, a table whose body can be lazy. The account surface has three screens that render a list of rows (a billing history, a session list, a team roster) and no shared shape for one, so each built its own column tracks and dividers. This carries the layout and takes the columns as data, which is why it has no variant axis: what varies between callers is the vocabulary, not the styling. The default constructor renders every row, which is right for a short and complete list. MSDataTable.paginated hands the body to magic's MagicPaginatedListView inside a bounded box, so a long collection costs the viewport rather than the result and reaching the tail asks the paginator for the next page; measured in a widget test at fewer than 30 rows of build for a 200-row collection in a 300px body, against 200 for the eager path. The header stays outside the scrolling body on purpose, and the component LISTENS to its paginator, which two things turned out to need. Reading isEmpty once in a stateless build only produced an empty state when the caller had already awaited the first page, so the ordinary order (build the view, let the controller load) left a bare header forever; and forwarding that state into the lazy list instead fixed the ordering but put it inside the h-[bodyHeight]px box, so an empty history reserved 420px around one sentence. Listening answers both, and the empty state replaces the ROWS with the header kept as context. The column track is a wrapper rather than a flex-1 on the cell, so alignEnd applies to a flexing column too; on the cell it was silently ignored for every column without an explicit width. Column labels and the optional loadingLabel are already-translated strings rather than keys, because a key would make this component decide the caller's i18n namespace and several callers render a label that is not a key at all. (lib/src/ui/components/data_table/)

Fixed #

  • The billing history dropped a cursor it had always been given, so a customer with more than one page of invoices could never see past the first. BillingService.getInvoices({String? cursor}) has always accepted one and BillingInvoicesPage has always carried nextCursor; loadInvoices() read page.invoices and threw the rest away. The whole chain was modelled end to end and unused at the last step. The controller now holds a MagicPaginator<Invoice> (invoicePages) and the card asks for older invoices as the reader reaches the end. A fetcher paginator rather than a url one, deliberately: the invoices arrive through BillingService, whose store build throws rather than answering and whose tests install a fake, so pointing a url paginator at /billing/invoices would walk around both. isFirst is what the fetch branches on, because the producer addresses its first page by sending no cursor at all, and a reset that reused the stored token would fetch page two and render it as the whole history. Verified by mutation: dropping the cursor again does not merely fail to advance, it refetches page one and appends it twice, so ten invoices become twenty. Below eight rows the card renders every invoice at its own height, since a fixed 420px body around three invoices is worse than the cost it avoids, but a cursor outranks that row count: a producer that pages at three rows would otherwise render page one eagerly, and the eager column mounts nothing that can ask for page two, which is this same defect in the shape the threshold gave it. The read also guards the paginator's own escape hatch, which catches on Exception and lets an Error through: BillingService is the consumer's class and onInit calls load() unawaited, so a bad cast in a consumer's getInvoices landed as an unhandled zone error instead of this screen's documented degradation. (lib/src/http/controllers/magic_starter_billing_controller.dart, lib/src/ui/views/teams/magic_starter_billing_view.dart)

Improvements #

  • A press on the billing cycle toggle repaints the prices instead of the screen. The toggle wrote its override through setState on the view's State, so one press rebuilt everything that State builds: the page scaffold, the scrollable, the header, the usage meters, all four plan cards in full, the payment method and the billing history. The only thing on the screen a press changes is four price labels, four billing notes and the toggle's own selected segment. The override is a ValueNotifier now, and the two regions that read it are the only ones subscribed to it. Measured on Chrome against a running app, one press: WDiv rebuilt 79 to 11, WText rebuilt 58 to 13, wind class-cache lookups 156 to 22 with zero misses on both sides. Three post-change runs returned those counts identically. Frame build time moved with them, but a debug build with timeline instrumentation is not a source for a millisecond figure, so the counts are the result here. _cycle stays derived and the press still writes the OVERRIDE, so the entitlement default keeps its meaning; the notifier is disposed in onClose beside the controller listener. (lib/src/ui/views/teams/magic_starter_billing_view.dart)

0.0.1-alpha.21 - 2026-08-25 #

Added #

  • magic_payments is a dependency now, because the starter's billing surface reads its entitlement state from there rather than defining a second one beside it. Billing is the last page of the account surface (profile, teams, sessions, notifications) that every host app still had to build for itself, and the entitlement contract it renders belongs to magic_payments. The dependency is that contract, not a convenience. It is a hosted dependency on a package that is not on pub.dev yet: magic_payments 0.0.1 publishes as its own decision, and until it does, a resolution with no local overrides fails with could not find package magic_payments. So this release cannot go to pub.dev before that one does, and CI now says so out loud instead of hiding it. The workflow was a bare flutter pub get, which meant CI resolved the published siblings while every developer resolved working trees through a gitignored pubspec_overrides.yaml, and an unreleased sibling API was invisible to both. It is two jobs now: siblings clones the six fluttersdk repositories, writes the override file CI never inherits, and runs the full gate (analyze, format, test); published resolves pub.dev with no overrides and analyzes against whatever is actually released, which is the graph an adopter gets. The second job is red on the missing publish and is named for that reason, non-blocking only until magic_payments is up.

Breaking #

  • MagicStarterBillingCycle is gone; use BillingCycle from magic_payments. Two enums for one concept is how the display copy and the charge drifted apart in the first place, so the wire type is now the only one. Same two members, same names, so the change at a call site is the import, and this barrel re-exports BillingCycle so that import is package:magic_starter/magic_starter.dart rather than a new dependency on an unpublished package. It had no callers outside this package's own billing view.

  • The declared SDK floor moves from sdk: ">=3.6.0" / flutter: ">=3.27.0" to sdk: ">=3.11.0" / flutter: ">=3.41.0", and it corrects a claim that was already false rather than introducing a new restriction. magic 0.0.6 declares sdk: ">=3.11.0 <4.0.0" and flutter: ">=3.41.0"; this package depends on magic: ^0.0.6, which resolves to exactly that release, and a package cannot resolve below its own dependency's floor. An adopter on Dart 3.6 to 3.10 could therefore never install this package, whatever the pubspec said: they met a solver error naming magic's requirement instead of a constraint naming it here. Nothing that worked stops working. magic_payments needs the same floor for the same reason and adds nothing to it, so the number is magic's, not billing's.

Changed #

  • Analyzer infos are fatal in CI now, and 44 of them were cleared first. flutter analyze --no-fatal-infos is why 20 files could carry use_null_aware_elements and unnecessary_underscores findings with nothing going red. The findings are gone (applied by dart fix, so if (x != null) x! inside a collection literal is ?x and (_, __, ___) is (_, _, _), both well inside the sdk: >=3.11.0 floor and neither changing behaviour, with the suite at the same 1394 on both sides), and the flag is gone with them from all three CI call sites plus the local post-edit hook and the release checklist, so a contributor cannot pass one gate and fail another. The cost is worth knowing before you hit it: a Dart SDK upgrade that ships a new lint now turns CI red with no code change. That is the trade, and the alternative is the debt this entry is clearing. (.github/workflows/{ci,publish}.yml, .claude/settings.json, .claude/commands/release.md, CLAUDE.md, 20 files under lib/ and test/)

Fixed #

  • The "Recommended" badge was drawn on top of the plan name. It sat absolute -top-2.5 left-5 inside a relative card, which is the CSS idiom for a badge straddling a border and did not survive the port: it landed over the heading, so "Recommended" and "Pro" were rendered on top of each other. It is IN FLOW now, on the name's row, flex-1 on the name and shrink-0 on the badge. It cannot collide at any width and needs no negative offset to sit where it belongs.

  • Every full-width button rendered its label against the left edge. MSButton's fullWidth wraps the button in SizedBox(width: double.infinity), which widens the box and leaves the label where it started. The recipe's base deliberately carries no justify-center (in Wind that token maps to the Container's alignment and forces the button to fill its constraints, so a base carrying it would make EVERY button full-width), and a shrink-wrapped button needs none because its padding box already centres a single child. Stretched, it does. The token is applied by the fullWidth branch, which is the one case the base cannot cover. This reached every full-width button in the package, not just the billing grid.

  • Every call to action on the plan grid looked the same, so nothing said which was pressable. The filled button was chosen by recommended && !isCurrent, which means a customer already ON the recommended tier saw no filled button anywhere: four grey rectangles, and the disabled one indistinguishable from the three live ones. Three changes, and they are one decision: the held tier renders no button at all (a marker with a check, bordered in the card's own accent, because a marker is not a control and should not look pressable), the filled button is the cheapest tier ABOVE what the customer holds (the vendor's recommended flag now decides it only while no tier is held, which is the state that flag was written for), and a downgrade is a plain secondary button. It was ghost for one revision, on the reasoning that a grid should not invite a downgrade, and ghost here is bg-transparent with no border: in a card footer it rendered as bare text with no affordance at all, indistinguishable from the feature list above it. "Quieter" had turned into "not a button". Two treatments carry the hierarchy, not three, and the single filled button carries it on its own. Nothing asserted button emphasis before, which is how the grid could flatten unnoticed; there is a test counting exactly one filled button now.

  • No plan card is filled while the entitlement is unresolved. The grid's one filled button fell back to the vendor's recommended flag when currentPlanId was null, documented as "a visitor with nothing to compare against". No such state exists: that field is null before loadEntitlement resolves and permanently after a failed read, and a customer on the free tier resolves to free like any other. So the fill landed on the one screen where _ctaLabel deliberately answers the neutral plan_button_unresolved, and after a failed read it never went away: the label refused to claim a direction and the colour claimed one anyway. Nothing is filled there now, and the flag keeps its badge.

  • A customer whose payment failed saw a perfectly healthy billing page. Both dunning statuses (past_due, grace) still GRANT while the rail retries, deliberately, so subscribed stays true and the renewal line reads normally: the page a customer opened after their card bounced was indistinguishable from a paying one, and the first they heard of it was losing access when Stripe's retries ran out. Measured on a live Stripe test clock, not reasoned about: a failed renewal left plan_status: past_due on the wire and the screen read "$34/mo billed monthly · renews Nov 24, 2026". The controller now publishes planStatus (it published plan, manage_via, manage_url, renews and cycle and dropped the rest) and the current-plan card carries a payment_failed_notice line when PlanStatus.isDunning. Adopters publishing their own translations need the new magic_starter.billing.payment_failed_notice key. It is a notice and not a gate: whether a status entitles stays the producer's answer, arriving as subscribed. Review caught the notice sitting inside ONE arm of the card's three-way branch, so a customer grandfathered on a tier the catalogue no longer serves took the other arm and got the same silent healthy page; it is a sibling of that branch now, since a failed payment is a fact about the subscription whatever the catalogue can say about the tier.

  • A customer taking the annual discount was charged the monthly price, and told otherwise. The cycle toggle was local display state whose own docblock said it "is never encoded into a checkout payload: a catalogue row carries one price per cycle for DISPLAY, and which price a rail charges belongs to the rail's own product". That reasoning is the defect. A tier is not a price: sold monthly and annually it is two, so the cycle now travels with the purchase (WebBillingService.checkout and swap both take it) and the producer resolves the price behind the exact (tier, cycle) pair. Measured against a live Stripe test account before the fix: the screen offered "Annual · save ~15%" at $29/mo billed annually, Stripe charged $34.00 monthly, and the confirmation toast said "Billed annually" on top of it. Three places claiming a cycle, one of them a segmented control, and none of them attached to the charge.

  • The renewal line reported the same cycle to everybody, because it was a literal. _renewalLine passed MagicStarterBillingCycle.annual as a constant, so every paying customer read "billed annually" whatever they were on. It reads MagicStarterBillingController.cycle now, which is what the customer BOUGHT, resolved by the producer from the price their subscription sits on. Reading the toggle instead would only have moved the claim onto a control the customer can press, so the test presses it: a monthly customer's sentence has to stay monthly while the catalogue toggle is moved to annual. A null cycle (a store subscription, or a price whose cycle the vendor's config never declared) takes a new sentence WITHOUT one rather than a guessed word: renewal_text_cycleless and renewal_ends_cycleless, needed in each locale you publish.

  • A tier sold monthly only was purchasable at a cycle it has no price for. The cycle is one screen-wide value and a catalogue row is not obliged to sell both, so a row with a monthly price and no annual one stayed behind a live Upgrade button while the toggle sat on Annual, and the checkout named a (tier, annual) pair the producer cannot resolve. That row is a state this screen already expects everywhere else: the price label renders the custom copy for it and the billing note guards on it. It is per CARD now (_cycleFor), so such a tier is offered, priced and charged monthly whatever the toggle says, rather than refused (which would hide a tier the vendor is selling because of a toggle position) or sold at a price nobody rendered. Found by review, and the fixture had no such row, which is why nothing caught it.

  • The toggle opens on the cycle the customer is already billed on, falling back to annual only when none is known. Not cosmetic: with a fixed default, a customer on monthly whose screen opened on annual and who then tapped a plan card was moved to that tier ANNUALLY without ever choosing annual. A press still wins for the rest of the visit, which is why the choice and the default are separate fields.

  • The billing screen told a customer who had just cancelled that their plan renews. The producer reports renews on the entitlement and magic_payments decodes it into BillingEntitlement.renews, but nothing in this package read the field: MagicStarterBillingController.loadEntitlement kept plan, manage_via and manage_url and dropped the rest, so _renewalLine had no way to know and rendered its one live sentence, "· renews :date", over a subscription that will not renew. On this rail a cancellation is normally end-of-period, so the tier is still granting and the date is still shown; it is an EXPIRY, and the customer most likely to read that line is the one checking that their cancellation took. The controller now publishes renews (tri-state, null while unresolved) and the line takes a new renewal_ends key when it is false. null keeps the renewing sentence, which leaves the pre-resolution window reading exactly as it did, and that window shows no date anyway. Adopters publishing their own translations need the new magic_starter.billing.renewal_ends key in each locale, and there is no fallback: Translator.get answers _sentences[key] ?? key, so a catalogue published before this release renders the literal string magic_starter.billing.renewal_ends to the customer who has just cancelled. Deliberately not special-cased here. Detecting a missing key means comparing trans(key) against the key itself and silently substituting a DIFFERENT sentence, which would hide the gap on the one screen where it is most visible, and every key this package has ever added carries the same requirement; re-publishing the stubs on upgrade is the contract, not a per-key rescue. (lib/src/http/controllers/magic_starter_billing_controller.dart, lib/src/ui/views/teams/magic_starter_billing_view.dart, assets/stubs/install/en.stub)

  • A selected tab underlined itself in the colour of the rule it sits on, and the first fix put it one brand shade off. The indicator used border-color-border, the same token as the tab list's own bottom rule, so a selection marked itself with a thicker length of the very line under it and read as a grey smudge. Replacing it with a bare selected:border-primary fixed the smudge and introduced a subtler wrong: there is no border-color-primary alias, so the alias layer passes the token through untouched and wind's border parser defaults the missing shade to 500, while every other brand surface resolves bg-primary to primary-600 in light mode. An active tab therefore underlined in primary-500 beside a primary-600 button, and the bare token carried no dark: half at all, contrary to this project's own widget rules. Now selected:border-primary-600 dark:selected:border-primary-500, asserted against the colour wind itself resolves for those shades in both modes rather than against a hardcoded hex. (lib/src/ui/components/tabs/tabs.recipe.dart, test/ui/components/tabs/tabs_test.dart)

  • A focused multiline field on iOS had no way to close the keyboard, and the first shape of the fix silently ate what the user had typed. MSTextarea is InputType.multiline, so its Return key inserts a newline rather than dismissing the keyboard, and on iOS there is no hardware way out: a form whose textarea sits above the fold left the keyboard covering the submit button with nothing to tap. The field now carries WKeyboardActions with a Done toolbar, platform: 'ios' (Android has a system back gesture and needs none) and nextFocus: false (one node has nowhere to navigate, and dead arrows beside Done are worse than no arrows). A read-only or disabled field takes no keyboard, so it takes no toolbar, and that is expressed by handing the toolbar an EMPTY node list rather than by returning early: gating the tree shape on enabled changed the element type at that slot when a form flipped it mid-submit, which unmounted the WInput below and let _WInputState.initState re-seed its controller from widget.value ?? '', losing the typed text in the uncontrolled case. Reproduced both ways and pinned by two cases over the enabled and readOnly transitions. Two dead recipe classes went with it: resize-none and focus:outline-none are unparsed by Wind, so neither ever reached the layout. (lib/src/ui/components/textarea/textarea.dart, test/ui/components/textarea/textarea_test.dart, doc/basics/components.md)

  • MSPageHeader's back control was an unnamed button on every page that has one. backLabel was used as a presence flag and nothing else: leading ?? (backLabel != null ? _buildBackControl(context) : null). Its string was never rendered and never announced, so the control reached assistive technology as a button with no name at all, and a screen reader user navigating a detail page heard "button" with nothing to say where it goes. Found by walking a consumer app's component previews and asserting that no platform button node is nameless; the back control was the finding with the widest reach, because it is on every page that sets backLabel. It now passes semanticLabel: backLabel to the anchor, which is the name it should always have had, since the label already names the parent the control returns to. Nothing changes on screen: the chevron stays icon-only, and semanticLabel reaches assistive technology rather than the layout. Four docblocks are corrected with it, all describing behaviour the control has never had. MSPageHeader's class comment and backLabel field both claimed the leading slot renders "a Icons.chevron_left icon followed by the [backLabel] text"; the class comment also claimed the tap "calls MagicRoute.back(fallback: backFallback), which tries a native pop first, then the internal history stack", where the code has always called MagicRoute.to(fallback) and nothing else; and MSPageScaffold carried the same two claims, which is the surface every in-package caller actually reads.

  • MSPageHeader no longer renders a back control it cannot navigate. The render was gated on backLabel alone, but backFallback is the control's ONLY destination: onTap was if (fallback != null) MagicRoute.to(fallback), so backLabel without backFallback produced a chevron that did nothing when pressed, and the naming fix above made that dead control more discoverable rather than less. It is now gated on both, so a caller who supplies no destination gets no control instead of a broken one, and _buildBackControl takes the fallback as a non-null argument. All ten in-package callers pass both, so nothing regresses; the check is a grep in the suite as well as a test. (lib/src/ui/components/page_header/page_header.dart, lib/src/ui/components/page_scaffold/page_scaffold.dart)

0.0.1-alpha.20 - 2026-08-17 #

Fixed #

  • MSPageHeader's inline mode is settable from the theme, so its two halves cannot drift apart. inlineActions does two things at once: it swaps containerClassName for containerInlineClassName, and it gives the title row flex-1 min-w-0 instead of only sm:flex-1. Those are two halves of one decision, and until now a consumer could set the first and not the second, because the container class is a theme string while the flex behaviour was a widget argument. That combination silently overflows: an app that themes the container into a row at every width, so a phone header keeps its action beside the title rather than dropping it under the subtitle, leaves the title row without flex-1 below sm. The title column is flex-initial, a loose fit, so the text takes its intrinsic width and runs past the actions. Measured in a host app at 40 logical pixels on a 390px viewport with a two-word title and three icon buttons, and reproduced in the suite at 79. line-clamp-2 on the title cannot save it, for the same reason truncate cannot without a constrained box. MSPageScaffold does not forward inlineActions at all, so a scaffold consumer had no way to reach the second half even knowing it existed; the flag now lives on MagicStarterPageHeaderTheme, beside the container class that requires it.

Changed #

  • MSPageHeader.inlineActions is now bool? and falls back to MagicStarterPageHeaderTheme.inlineActions. Not breaking: the theme field defaults to false, which is the previous behaviour, and an explicit argument still beats the theme, so every existing caller is unchanged.

0.0.1-alpha.19 - 2026-08-03 #

Added #

  • MSPageContainer: one page container for every page, in this package and in the host app. The width cap the previous release handed to the host fixed the settings pages and nothing else, because the cap was only ever half the geometry and only one of three surfaces read it. The team pages (/teams/create, /teams/settings) and the notification pages (/notifications, /notifications/preferences) opened with a bare WDiv(className: 'p-4 lg:p-6 flex flex-col gap-6'): no cap at all, so on a desktop window they spread the full width of the content region while the settings and host pages centred in a column, and p-4 lg:p-6 against the settings scaffold's px-4 lg:px-8 put their headers on a different vertical and horizontal grid. Three surfaces, three answers, one shell. MSPageContainer now owns the geometry (width, edge margins, vertical rhythm, plus a horizontal safe-area guard so content never slides under a rounded display corner), MSPageScaffold composes it, and a host app uses the same component for its own pages. Nothing per page is left to disagree about.
  • MSPageScaffold.actions forwards page-level actions to the shared header. A page that needed an action next to its title (Notifications and its "mark all read") had to build its own MSPageHeader, and a page that builds its own header builds its own container right after. Now it passes actions and keeps the shared chrome.
  • The Notifications, Notification Preferences, Team Create, Team Settings, and Profile Settings views moved onto MSPageScaffold. They now share the page surface, the scroll ownership (primary: false), the geometry, and the header with every settings page. Their view slots (header, footer, afterSection:*) are unchanged and still render in the same order, as the first and last children of the sections column.

Breaking #

  • MagicStarterManager.settingsMaxWidthClassName is now pageContainerClassName, and carries the WHOLE geometry instead of just the cap. Migration is one line: MagicStarter.manager.pageContainerClassName = 'max-w-6xl px-4 sm:px-5 lg:px-8 pt-6 sm:pt-8 pb-24';, using the same values the host's own page container uses. Passing only a cap ('max-w-6xl') still works and keeps the starter's default padding, so the old one-value call is a valid subset. One string rather than a cap knob plus a padding knob is deliberate: a cap that agrees while the padding does not still reads as two different pages. The default is MagicStarterManager.defaultPageContainerClassName (max-w-7xl px-4 lg:px-8 pt-6 sm:pt-8 pb-16), which reproduces the previous scaffold geometry exactly, so an app that configures nothing sees no visual change.
  • MSSettingsScaffold is now MSPageScaffold, and lives at lib/src/ui/components/page_scaffold/. It stopped being a settings component the moment the team and notification pages needed it. Migration is the rename; the constructor is unchanged apart from the added optional actions.
  • settingsScaffoldContainerRecipe() is removed. Its job is MSPageContainer's now. A consumer that called it directly should render MSPageContainer instead, or read pageContainerRecipe(hostClassName: ...) if it only wants the className. settingsScaffoldScrollableRecipe() is now pageScaffoldSurfaceRecipe() and settingsScaffoldChildrenAreaRecipe() is now pageScaffoldChildrenAreaRecipe(); both are unchanged in output.
  • Every pre-MS-prefix alias widget is removed: MagicStarterCard, MagicStarterPageHeader, MagicStarterSocialDivider, MagicStarterNotificationDropdown, MagicStarterTeamSelector, MagicStarterUserProfileDropdown. All six were empty subclasses that added nothing to MSCard, MSPageHeader, MSSocialDivider, MSNotificationDropdown, MSTeamSelector and MSUserProfileDropdown, and keeping them split this package's own code across two names for one component: the team views used the canonical names while the notification and profile views used the aliases, and the app layout reached for the aliases while the components it composed documented themselves against them. Migration is the rename, nothing else: every constructor parameter, CardVariant, and the barrel export path are unchanged. Their test files are gone too, but no coverage went with them: the assertions that were unique to an alias test (five menu-content cases on the user-profile dropdown, four optional-slot cases on the page header) moved into the canonical component test, and the rest were duplicates of assertions the canonical test already made.
  • MagicStarterTimezoneSelect is NOT affected and keeps its name. It reads as one of the aliases but is a real 242-line widget with no MS counterpart, like MagicStarterConfirmDialog and MagicStarterDialogShell.

0.0.1-alpha.18 - 2026-08-02 #

Added #

  • MagicStarterManager.settingsMaxWidthClassName: the host now owns how wide the Settings pages are. MSSettingsScaffold centres its own content column, and it always capped that column at its own max-w-7xl regardless of the app it was rendering in. A host app caps its own pages wherever it likes, so in any app that does not happen to use the same value both columns centred inside the SAME content region at DIFFERENT widths: same sidebar, same chrome, and every settings header starting tens of pixels further out than the header on every other page (64px per side against a max-w-6xl host, measured on a 1800px viewport). Registering the host's own shell as layout.app does not fix this and in fact hides it, because the two columns then differ inside identical chrome. Set the field once, from the same constant the host's own page container uses, and the two cannot drift: MagicStarter.manager.settingsMaxWidthClassName = PageContainer.maxWidthClassName;. It defaults to MagicStarterManager.defaultSettingsMaxWidth (max-w-7xl), the value the scaffold has always used, so an app that configures nothing sees no change in width.

Fixed #

  • Every Settings page sat 32px higher than every other page in the app, because the scaffold emitted no vertical page padding at all. The app layout's content region is a bare scroll view with no padding of its own, so a page's own pt-* is the only thing standing between its header and the top edge of the viewport, and settingsScaffoldContainerRecipe emitted none. The header was therefore glued to the top edge on every settings screen, in every consumer, since the recipe was written. It now emits pt-6 sm:pt-8 plus a pb-16 so the last section does not end flush against the fold. Verified against a host app on a 1800px viewport: content top and the full content column (left AND right edge) now identical across the host's dashboard, its list pages and the starter's settings pages, checked in production rather than only locally.
  • The class docblock described a column the widget had stopped rendering. It claimed the inner column was always w-full max-w-2xl mx-auto px-4 lg:px-0; the recipe had been emitting px-4 lg:px-8 with a max-w-7xl cap for some time, so the documentation was already wrong before this release and would have sent a reader looking for padding that was not there. It now states the real className, says why the vertical padding is this column's own responsibility, and names the host as the owner of the cap.

Breaking #

  • settingsScaffoldContainerRecipe() now takes a required maxWidthClassName. The recipe is exported from the package barrel, so a consumer calling it directly must pass a cap. Migration is one argument: settingsScaffoldContainerRecipe(maxWidthClassName: MagicStarterManager.defaultSettingsMaxWidth) reproduces the previous output exactly. The parameter is required rather than defaulted on purpose: a cap nobody had to think about is precisely how the widths drifted apart in the first place. MSSettingsScaffold itself is unchanged for callers, and passes the host's configured value for you.

0.0.1-alpha.17 - 2026-07-29 #

Added #

  • MagicStarter.bootstrap() is now the single entry point for the starter's identity contract. The starter needs four things from the host app before it behaves correctly: how to build the app's user model, what logging out does, which locales to offer, and (only when the teams feature is on) how to read and switch teams. Those were four separate use* calls that nothing enforced, and forgetting one failed SILENTLY: MagicStarterManager.userFactory defaults to MagicStarterAuthUser.fromMap, so an app that skipped useUserModel kept running while every starter screen quietly read the starter's own user type instead of the app's. bootstrap() makes userFactory, onLogout and locales required named arguments. The three team callbacks stay optional because teams are opt-in (magic_starter.features.teams defaults to false) and a teamless app must not be forced to pass stubs, but they are cohesive: a partial set throws an ArgumentError before any setter runs (so a rejected call leaves the manager untouched rather than half-configured), and enabling the teams feature without them throws a StateError. That second check finally gives MagicStarterManager.isReady a reader; it encoded exactly this rule and nothing had ever called it. All four individual setters remain public and unchanged for partial or advanced setup, and the 16 optional theming setters are deliberately NOT part of bootstrap(). The installer now scaffolds bootstrap() instead of the loose calls, on both the inject-into-existing-provider path and the --force / first-install stub path. Covered by test/facades/magic_starter_bootstrap_test.dart and the install-command tests; documented in doc/getting-started/installation.md.

  • SessionScopedController plus SessionScopeSync: a fix for a cross-tenant data leak every magic app has. magic caches controllers as Type-keyed singletons and runs onInit once per instance lifetime, so a logout followed by a login as a DIFFERENT user, or a team switch, never re-runs the initial fetch and the previous session's rows stay on screen until a hard reload. On a team-scoped product that is not staleness, it shows one tenant's data to another. A controller that caches team-scoped data now implements SessionScopedController.resetForSession(), and the host calls SessionScopeSync.attach() once from its service provider to drive them off Auth.stateNotifier, keyed on <userId>:<teamId> so a team switch counts as an identity change. Three rules are load-bearing: resetForSession() must CLEAR before it refetches (ordinary reload() paths are deliberately non-destructive so a transport blip does not blank a dashboard, which is exactly wrong across an identity change, where a failed refetch must leave the screen empty rather than populated with the previous tenant's data); only a change to a NON-NULL identity resets, because resetting on logout could only fire requests that 401 from the login screen; and each controller's reset is isolated, so one failure logs and does not abort the others. Covered by test/http/session_scoped_controller_test.dart; documented in doc/basics/session-scope.md.

  • EnsureAuthenticated and RedirectIfAuthenticated middleware, ready to register as the auth and guest aliases. Both resolve their destinations through MagicStarterConfig.loginRoute() / homeRoute() rather than literals, and both override redirectTarget (a pre-build synchronous redirect) instead of handle (a post-build remount), so a guarded page never mounts for a visitor who is about to be redirected away. Each guards its own destination so the redirect cannot loop, which matters because go_router raises after more than five successive redirects. Documented in doc/basics/middleware.md.

  • PlanUpgradeRequirement, UpgradePrompt, MSUpgradeDialog and MSUpgradeNudge: a plan-gate wall with the purchase action attached. A plan-gated refusal used to end in a plain error toast that named the tier in prose and left the user to find billing, the plan and the checkout button themselves. PlanUpgradeRequirement.fromResponse reads a 403 carrying an upgrade.required_plan marker and returns null for anything else, so a caller can branch on "upgrade wall or real failure" without matching English prose. The marker is REQUIRED on purpose: a 403 without it is an authorization denial no purchase fixes (a team-scope denial, a revoked token), and offering to upgrade there would be a lie. The destination is MagicStarterConfig.billingRoute() (magic_starter.routes.billing, default /teams/billing), and each navigation mints a fresh single-use intent token because the billing screen mounts more than once per arrival (the router rebuilds it on the auth-state refresh) and both mounts read the same query, which previously opened two checkout sessions. The two widgets read their copy from common.upgrade, common.upgrade_available_on and common.upgrade_dialog_not_now, which are added to the published en lang stub so an installed app resolves them; a consumer that installed an earlier stub should add those three keys. Covered by test/support/plan_upgrade_test.dart and the two component test folders.

  • The layout.app override seam is documented. MagicStarterAppLayout was already the registered default, so a fresh install always rendered account routes in a working shell, but an app with its own navigation chrome had no documented way to host starter routes inside it and would render them in a second, different shell. MagicStarter.view.registerLayout('layout.app', (child) => MyShell(child: child)) is that seam; it is now documented in doc/basics/views-and-layouts.md with the ordering rule, and pinned by test/ui/layout_override_test.dart.

Changed #

  • Every symbol added in this release is exported from package:magic_starter/magic_starter.dart, and test/barrel_export_test.dart imports only that entry point to prove it: a symbol present under lib/src/ but missing from the barrel is invisible to consumers and would otherwise surface as a compile error in a downstream app.

  • Every component now styles through tokens the starter's own theme guarantees. The two upgrade widgets arrived from the downstream app still referencing bg-ai-soft and text-ai, which belong to that app's hand-authored status supplement rather than to the semantic role set. Wind resolves an unknown alias to nothing and drops it silently, so in any other app the lock tile rendered with NO background and the glyph fell back to the inherited colour: a visual no-op with no error, and invisible to design:lint (which validates DESIGN.md, not className tokens) and to the component tests (which assert text and taps, not decoration). They now use bg-primary-container and text-primary, both of which the shipped alias map resolves. MSErrorState's icon and title keep their raw text-red-* pair deliberately, and now say why in a docblock: the alias contract ships bg-destructive, text-on-destructive and bg-destructive-container but NO destructive TEXT role, so the semantic-looking text-destructive is claimed by the parser and then resolves to nothing. Both components are now pinned by tests that assert the RESOLVED colour rather than the className, because a dropped token renders identically to no token at all and every string-level assertion passes straight through it.

  • starter:doctor checks the contract it claims to check. Its verbose output named MagicStarter.bootstrap while the probe still grepped MagicStarter.useNavigation, an optional theming setter, so a provider missing the identity contract entirely could report OK. It now accepts bootstrap( or the legacy useUserModel( and says so.

  • starter:install no longer overwrites a pre-bootstrap() app's setup. Injection appends at the end of boot(), so re-running the installer on an app wired with the individual setters would have placed a generic bootstrap() AFTER that app's own useLocaleOptions() and useLogout(), silently winning by write order and replacing a customized locale list or logout behaviour. The idempotency guard now recognises the legacy shape and leaves such a provider alone.

  • The two new components follow the MS namespace (MSUpgradeDialog, MSUpgradeNudge) that the rest of the component layer adopted, so nothing new lands in the flat namespace that previously collided with Material. Their preview classes stay unprefixed (UpgradeDialogPreview, UpgradeNudgePreview), matching every other component.

0.0.1-alpha.16 - 2026-07-26 #

Added #

  • Push-not-provisioned hint on the notification preferences view, driven by the backend. When the app has no OneSignal app_id, a push preference is still offered but the channel is dropped from via() at send time, so the toggle silently could not deliver. MagicStarterNotificationController now reads meta.push_provisioned off both preference responses (GET and PUT /notification-preferences, added in magic-starter-laravel) into pushProvisionedNotifier, and the view renders a subtle hint under the push channel label while it is false. The flag starts true and only moves on a response that actually carries it, so a backend that predates the flag (or a degraded payload) never renders a false "not configured" claim. MagicStarterNotificationPreferencesView also takes an optional bool? pushProvisioned as a host OVERRIDE (null, the default, means "read the backend flag"); pass a bool only to force the hint on or off. The hint reads the new notifications.channel_push_unconfigured lang key (added to the published en lang stub), so a consumer that installed an earlier stub should add that key to keep it translated. The hint deliberately stays OUTSIDE the label's ExcludeSemantics (the exclusion exists so an E2E label lookup resolves the switch, not the text), because it carries information the switch label does not and a screen reader has to announce it. Requires magic-starter-laravel with the meta.push_provisioned responses; against an older backend the hint simply never shows.

Changed #

  • Dependencies tracked to the current release line: magic ^0.0.5 and magic_notifications ^0.0.2. Under pub's 0.0.z caret semantics the previous ^0.0.4 / ^0.0.1 bounds excluded those releases, so the graph could not solve against current magic. magic 0.0.5 also carries the Model.save() 422 validation-error surface this starter's forms can read.

  • Account views now style through the semantic alias tokens instead of raw Tailwind gray classes. The auth screens (login, register, forgot / reset password, OTP verify), the profile and notification views, the team settings / invitation views, the app layout, and the password-confirm / two-factor dialogs used literal gray-* classes for their surfaces, borders, and text. They now map to the semantic aliases (bg-surface*, text-fg*, border-color-border*), so a consumer's theme and dark-mode pairs drive them and the account surface matches the rest of the design system. Pure class-name refactor, no behavior change. Touches the auth / profile / teams views under lib/src/ui/views/, lib/src/ui/layouts/magic_starter_app_layout.dart, and the magic_starter_password_confirm_dialog / magic_starter_two_factor_modal widgets.

  • Dependency constraints realigned to the 0.0.x release line. magic is now ^0.0.4 (was a stale ^1.0.0-alpha.13 that no published magic satisfied) and magic_notifications is ^0.0.1 (was ^0.0.1-alpha.1). The old ^1.0.0-alpha.13 constraint also conflicted with magic_notifications's magic ^0.0.3, so the graph only solved via the local path overrides; it now resolves cleanly against published packages. magic 0.0.4 pulls fluttersdk_wind ^1.2.0, which carries the WindRecipe/WindSlotRecipe API the component layer uses.

  • BREAKING: every design-system component class is now MS-prefixed (MS-7b): the flat, unprefixed component classes were renamed to an MS-prefixed namespace (Button -> MSButton, Dialog -> MSDialog, ...) and the old names were removed outright (no re-export, no @Deprecated alias, no compat barrel). This ends the package:flutter/material.dart collision that previously forced consumers to sprinkle hide clauses (Switch, Dialog, Checkbox, Radio, Badge, Typography, BottomSheet, Tooltip, DropdownMenu, DropdownMenuItem, EmptyState, ErrorState all shadowed Material or common consumer names). The already-MagicStarter*-prefixed public widgets (MagicStarterCard, MagicStarterPageHeader, ...), the per-axis enums (ButtonIntent, InputState, BadgeTone, ...), and the recipe functions/consts are unchanged. Migration: replace each old class name with its MS counterpart and drop any now-unnecessary hide clause.

    Old name New name Old name New name
    Button MSButton Tooltip MSTooltip
    Input MSInput DropdownMenu MSDropdownMenu
    Textarea MSTextarea DropdownMenuItem MSDropdownMenuItem
    Checkbox MSCheckbox MagicFormField MSFormField
    Switch MSSwitch Navbar MSNavbar
    Radio MSRadio EmptyState MSEmptyState
    Badge MSBadge ErrorState MSErrorState
    Typography MSTypography SettingsSection MSSettingsSection
    Skeleton MSSkeleton SettingsRow MSSettingsRow
    Select MSSelect SettingsNavRow MSSettingsNavRow
    Combobox MSCombobox SettingsScaffold MSSettingsScaffold
    SegmentedControl MSSegmentedControl Card MSCard
    Tabs MSTabs PageHeader MSPageHeader
    Accordion MSAccordion SocialDivider MSSocialDivider
    AccordionItem MSAccordionItem NotificationDropdown MSNotificationDropdown
    Dialog MSDialog UserProfileDropdown MSUserProfileDropdown
    BottomSheet MSBottomSheet TeamSelector MSTeamSelector
    Toast MSToast ConfirmDialog MSConfirmDialog

    Note: MagicFormField becomes MSFormField (the Magic segment is dropped, not double-prefixed). The MagicStarter* alias widgets keep their names and now subclass the MS-prefixed components (MagicStarterCard extends MSCard).

Fixed #

  • doUpdateProfile now sends the language param under the locale wire key: MagicStarterProfileController.doUpdateProfile was posting the language change as body field language, but magic-starter-laravel's UpdateProfileRequest validates locale, so the field was silently dropped and language changes never persisted. The Dart-side language parameter name is unchanged (existing call sites keep passing language:); only the outgoing wire key is corrected to locale.
  • MagicStarter.manager no longer throws when magic_starter is unbound (MS-6): components that read theme through the facade (e.g. Card via MagicStarter.cardTheme) threw "Service [magic_starter] is not registered" when rendered without a running MagicStarterServiceProvider — e.g. a standalone widget test or a /preview catalog entry. MagicStarter.manager now checks Magic.bound('magic_starter') first and, when unbound, falls back to a shared default-constructed MagicStarterManager() (its 7 sub-themes already hold const defaults) instead of throwing. The fallback emits a one-time kDebugMode warning ("MagicStarterManager not bound; using defaults...") so a genuine forgot-to-bind bug in a real app still surfaces in development; a bound manager still wins. Note: in kReleaseMode the fallback is silent (no warning) and renders unbranded defaults, so wire MagicStarterServiceProvider in production even though a missing binding no longer crashes.
  • Caller className now APPENDS onto the component recipe instead of replacing it (WIND-1): 14 components (Button, Badge, Input, Textarea, Card, Switch, Checkbox, Radio, Skeleton, Toast, Typography, DropdownMenu, Tooltip, SettingsSection) previously bypassed their recipe entirely when a caller passed className (if (className != null) return className! / className ?? recipe()), so Button(intent: primary, className: 'w-full') dropped the primary fill and every base token. Each component now routes the caller className through the recipe's caller-slot (recipe(variants: {...}, className: className)), so it appends last and Wind's parse-time per-family last-wins resolves conflicts while every non-overridden base class survives. DropdownMenu also threads its per-item className (active + disabled) through per-item recipes, Radio appends indicatorClassName, Switch appends thumbClassName, and SettingsSection appends both containerClassName and captionClassName. Tooltip and DropdownMenu, which had hardcoded default strings and no recipe, now lift those defaults into small WindRecipes (tooltipPanelRecipe, dropdownMenuPanelRecipe, dropdownMenuItemRecipe, dropdownMenuItemDisabledRecipe); the previous kTooltipDefaultPanelClassName / kDropdownMenu*ClassName string constants are removed in favor of these recipes. Default styling (no caller className) is byte-identical to before.
  • SegmentedControl rendered its segments vertically: the recipe root slot used inline-flex, which Wind does not support (no inline layout) and which falls back to a vertical flex column, so the segments stacked top-to-bottom instead of sitting side by side. Changed root to flex flex-row items-center so the control lays its segments out horizontally as intended.

Added #

  • MagicStarter.useWindTheme(WindThemeData) one-call theme adoption (MS-7a): a single call now derives all 7 magic_starter sub-themes (navigation, modal, form, card, page header, layout, auth) from a WindThemeData's semantic alias palette and delegates to the existing useTheme(MagicStarterTheme) hook, so a consumer aligns every built-in surface to their brand without hand-building up to 7 sub-theme structs. Backed by a new MagicStarterTheme.fromWind(WindThemeData) factory that rebuilds each color-bearing className from the 17 semantic roles (bg-surface/bg-surface-container/bg-surface-container-high, text-fg/text-fg-muted/text-fg-disabled, bg-primary/text-on-primary/text-primary, border-color-border/border-color-border-subtle, bg-destructive/text-on-destructive/bg-destructive-container). Each alias carries its own dark: pair, so a single token replaces every bg-white dark:bg-gray-800-style pair. A role is emitted as a token only when the passed theme defines it (as an alias key or a backing color key); otherwise the property keeps the shipped default palette pair, so a partially-configured theme never renders a silent no-op surface. Purely additive: useTheme and every individual use*Theme() setter still work and override afterward. Pair it with MagicStarterTokens.defaultAliases (or a design:sync-generated alias map) to re-skin every surface. See doc/guides/wind-theme-adoption.md for the full alias-to-property mapping.
  • setUpMagicStarterForTests() test utility (MS-6): lib/src/testing/magic_starter_test_utils.dart (exported from the release barrel) wraps the Magic.singleton('magic_starter', () => MagicStarterManager()) idiom repeated across 16+ test files. Call setUpMagicStarterForTests() for a default manager, or setUpMagicStarterForTests(manager: myManager) to bind a pre-configured one. Combined with the MagicStarter.manager defensive fallback above, tests may now omit this call entirely for components that only need default theme values.
  • First-class fullWidth prop on Button, Input, Textarea (MS-2): each component gains a bool fullWidth = false constructor prop. Because Material widgets ignore cross-axis stretch inside a Column (flutter/flutter#19399), setting fullWidth: true wraps the rendered WButton/WInput in a SizedBox(width: double.infinity) at the widget layer instead of relying on a className token; the recipe stays width-agnostic (inputRecipe/textareaRecipe no longer bake an unconditional w-full into their base). fullWidth is orthogonal to size (a layout concern, not the padding/font scale) and defaults to false (content-width).
  • package:magic_starter/previews.dart (dev-only barrel): exposes all 30 component previews as (label, slug, builder) records via starterComponentPreviews(), so a consumer's dev-only preview catalog can surface the full component set (Button, Badge, ..., UserProfileDropdown, TeamSelector, NotificationDropdown) without duplication. Kept SEPARATE from the magic_starter.dart release barrel (the atomic-component contract keeps *.preview.dart out of release); the records are returned from a function (not a top-level const holding widget refs), so a consumer that only calls it behind a kReleaseMode/PREVIEW_ENABLED guard tree-shakes the whole set from release.
  • design.md.stub: a DESIGN.md template shipped at assets/stubs/design.md.stub covering all 17 semantic roles (surface, fg, primary, border, destructive, success, warning, and their variants), typography on the 4px logical scale, rounded/spacing scales, and key component entries with {{ placeholder }} tokens. Consumers copy it into their project root, fill in brand hex values and fonts, then run design:lint to validate and design:sync to generate the Wind theme. Pairs with MagicStarterTokens.defaultAliases as the stable key contract.
  • Wave 4 design-system component library: 23 new generic UI components are now part of the public barrel (package:magic_starter/magic_starter.dart). Each component lives in the canonical atomic-component folder shape (<name>.dart, <name>.recipe.dart, <name>.preview.dart, index.dart) under lib/src/ui/components/.
    • Form controls: Button (with ButtonIntent, ButtonSize, buttonRecipe), Input (with InputState, inputRecipe), Textarea (with TextareaState, textareaRecipe), Checkbox, Switch, Radio, Select (with selectRecipe), Combobox (with comboboxRecipe).
    • Feedback and display: Badge (with BadgeTone), Typography (with TypographyVariant), Skeleton (with SkeletonShape), Toast (with ToastVariant), Tooltip, EmptyState, ErrorState.
    • Layout and navigation: Accordion (with AccordionItem, accordionRecipe), SegmentedControl (with SegmentedControlSize, segmentedControlRecipe), Tabs (with tabsRecipe), Navbar, Dropdown menu (DropdownMenu, DropdownMenuItem).
    • Overlay: Dialog, BottomSheet.
    • Composition: MagicFormField (label, hint, error wrapper).
    • Previously migrated components (Card, PageHeader, SocialDivider, NotificationDropdown, UserProfileDropdown, TeamSelector, ConfirmDialog) were already barrel-reachable through their existing alias exports and are unchanged.
    • Collision resolved by the MS prefix (see the BREAKING entry below): these components were initially added under bare names (Switch, Dialog, Checkbox, Radio, Badge, Typography, BottomSheet, Tooltip, DropdownMenu, DropdownMenuItem) that collided with package:flutter/material.dart. They now carry an MS prefix (MSSwitch, MSDialog, ...), so importing both packages no longer needs a hide clause.

Changed #

  • Wave 5 view rewrite: the auth (login, register, forgot, reset, two-factor-challenge, otp-verify), profile, notifications (list + preferences matrix), and teams (create, settings, invitation-accept) views plus both layouts (MagicStarterAppLayout, MagicStarterGuestLayout) now compose the new design-system components (Button, Card, Switch, PageHeader, SocialDivider, etc.) instead of inline W-widgets. Views are now Wind-exclusive: bare package:flutter/material.dart imports were replaced with widgets.dart + material show Icons (and the few genuinely-needed Material shells via show), so the new component names no longer collide. Behavior, registry keys (auth.*, profile.*, notifications.*, teams.*, layout.app, layout.guest), controller contracts, gate abilities, refreshNotifier, and notification polling are all preserved; only the presentation layer changed.
  • Card migrated to the atomic-component folder + WindRecipe: the card now lives at lib/src/ui/components/card/ in the canonical 4-file shape (card.dart → class Card, card.recipe.dart, card.preview.dart, index.dart) and resolves its root className through a theme-driven WindRecipe instead of inline string interpolation. The recipe output is byte-identical to the previous _defaultClassName for every CardVariant x noPadding combination (gated by an explicit equivalence test). MagicStarterCard is retained as a thin re-export alias of Card, and CardVariant plus the barrel export path (package:magic_starter/magic_starter.dart) are unchanged, so existing callers and the widget-test suite are untouched. This establishes the verbatim template for the Wave 4 component migration.

Added #

  • MagicStarterTokens.defaultAliases: semantic token alias map with 17 roles (surface, surface-container, surface-container-high, fg, fg-muted, fg-disabled, primary, on-primary, primary-container, accent, border, border-subtle, destructive, on-destructive, destructive-container, success, warning). Each role maps to a light+dark wind className pair ('bg-... dark:bg-...' / 'text-... dark:text-...'). Pass as WindThemeData(aliases: MagicStarterTokens.defaultAliases) so components resolve against semantic roles rather than palette utilities directly. This map is the stable key contract that design:sync (Steps 20-21) will later regenerate from DESIGN.md.

Fixed #

  • User dropdown adds a Settings (hub) entry + items work on web: the user-profile dropdown now lists Settings (-> the iOS settings hub) above Profile. The dropdown items previously appeared dead on web (clicking did nothing and the popover closed; reopening closed immediately) because of a WPopover focus-loss auto-dismiss — fixed upstream in fluttersdk_wind (see its changelog).
  • Account deletion moved off the Profile form to Security: the destructive Delete Account row no longer sits on the Profile sub-page (it read as tacked-on); it now lives in a Danger section at the bottom of the Security > Browser Sessions sub-page, reusing the same password-confirm dialog and doDeleteAccount unchanged.
  • Guest auth screens no longer crash during in-app transitions: MagicStarterGuestLayout wrapped its content in SingleChildScrollView(primary: true), which attaches to the ambient PrimaryScrollController. Auth routes use RouteTransition.none, so navigating between guest screens (login -> register -> forgot, etc.) briefly mounts the outgoing and incoming routes together; two primary: true scroll views then contended for the single PrimaryScrollController and detached each other mid-layout, producing a dropChild / "RenderBox.size accessed beyond scope" / "wrong build scope" assertion cascade (and, on the worst cold-start case, a red error screen). The scroll view is now primary: false so each guest page owns its own implicit controller and never contends for the shared one.
  • PR #78 review (release boundary + semantic tokens + Wind-only previews):
    • Component index.dart barrels no longer re-export their *.preview.dart (9 components: error_state, empty_state, navbar, form_field, notification_dropdown, social_divider, page_header, user_profile_dropdown, team_selector). Previews are dev-only and must stay out of the release barrel (magic_starter.dart re-exports every index.dart); previews:refresh and the previews.dart dev barrel discover *.preview.dart directly, so the exports leaked previews into release for no benefit. Preview tests now import the preview file directly.
    • Tooltip default panel + kTooltipDefaultPanelClassName use semantic alias tokens (bg-surface-container-high text-fg border border-color-border) instead of hardcoded gray palette utilities, so tooltips re-skin via MagicStarterTokens / design:sync. The Tooltip doc comment was corrected to describe the actual enableTriggerOnTap: true behavior (it does not use a PopoverController).
    • BottomSheet drag handle uses bg-surface-container-high instead of bg-gray-300 dark:bg-gray-600.
    • PageHeader / EmptyState / ErrorState previews render their action with the design-system Button + WText instead of Material ElevatedButton / Text, keeping previews Wind-only and dropping the Material import churn.
  • Creating a team now switches to it. MagicStarterTeamController.doCreate only set the local currentTeamId notifier after POST /teams; the backend's current_team_id stayed on the previous team, so the resolver-driven sidebar name and active-team highlight showed the OLD team while local state and the member fetch pointed at the new one (REPORT #14, confirmed via e2e: create opened the old team's settings). doCreate now calls PUT /user/current-team with the new id before Auth.restore() (Jetstream create-then-switch), so the server, resolver, sidebar, and settings all agree on the new team.
  • Button no longer forces full-width: the buttonRecipe base dropped justify-center, which in Wind maps to WButton's Container alignment and made every default Button() expand to fill its constraints (stacking one-per-row in a variant row). A default button now shrinks to its content (its inline-flex intent), with the label centered by the shrink-wrapped padding box. Form/modal buttons are unaffected: they pass a className override (the form theme ships w-full), which bypasses the recipe base entirely.
  • Create team opens the new team's settings: MagicStarterTeamController.activeTeamName now matches the local currentTeamId (when set) against the resolver's allTeams() by id, falling back to the resolver's currentTeam() when no match exists. Previously activeTeamId preferred the local currentTeamId notifier (set on create/switch) while activeTeamName read only the resolver's currentTeam(), so after creating a team the settings view pre-filled the OLD team's name and effectively opened the old team (#14).

Removed #

  • Breaking: Standalone CLI entrypoint — removed bin/magic_starter.dart and dart run magic_starter:* commands. Commands now surface via the host app's artisan dispatcher. Migrate: dart run magic_starter:install becomes dart run <app>:artisan starter:install (register StarterArtisanProvider in your app's artisan.providers list).
  • Removed magic_cli dependency: CLI now builds on fluttersdk_artisan ^0.0.8.

Changed #

  • plugin:install auto-scaffolds starter: install.yaml now declares bootstrap_command: starter:install so plugin:install magic_starter automatically runs the full starter scaffold (config, routes, middleware, dashboard) without a separate manual step. Requires fluttersdk_artisan ^0.0.9 which introduced auto-execution of the bootstrap_command field. Pass --no-bootstrap to skip the auto-run.
  • post_install message: reworded so it no longer flatly claims the starter:install bootstrap succeeded (a chained-bootstrap failure previously left the message asserting a scaffold that never happened). It now tells the operator to run dart run <app>:artisan starter:install by hand if the bootstrap exited non-zero or lib/config/magic_starter.dart is missing, shows the --features= one-liner, and documents the --no-bootstrap opt-out. Pairs with fluttersdk_artisan's fix that surfaces a non-zero bootstrap exit code.
  • Install is manifest-driven — static scaffolding (config publish, provider injection) now driven by install.yaml manifest; dynamic logic (feature toggles, interactive mode) handled by fluent override in MagicStarterInstallCommand.

Added #

  • Read-only MCP tool — starter_doctor diagnostic command exposed as a read-only MCP tool via StarterArtisanProvider.mcpTools().

🐛 Bug Fixes #

  • Social login translation keys: the install-generated assets/lang/en.json (from assets/stubs/install/en.stub) now ships auth.sign_in_with and auth.sign_up_with. The social-login buttons (SocialAuthButtons from magic_social_auth) call trans('auth.sign_in_with', {'provider': ...}), but magic_social_auth ships no lang file and magic loads translations only from the consumer's assets/lang, so a fresh starter:install with social_login enabled previously rendered raw keys ("auth.sign_in_with") instead of "Sign in with Google". Surfaced by a full reference-app E2E bring-up.
  • Mobile Header Brand: MagicStarterAppLayout mobile topbar now honors navigationTheme.brandBuilder, so custom brand widgets render consistently across breakpoints when provided (#65)
  • Page Header Title Truncation: MagicStarterPageHeaderTheme defaults now use line-clamp-2 instead of truncate for titleClassName and subtitleClassName, so long titles wrap to a second line on narrow viewports (e.g. iPhone-width screens) instead of clipping to "AI sett..." (#67)

🧪 Tests #

  • wind 1.1.x widget-test compatibility: the two-factor modal and password confirm dialog widget tests drove input via find.byType(TextField), which broke once CI resolved fluttersdk_wind 1.1.x (the Material-free WInput/WFormInput rewrite renders an EditableText instead of a Material TextField). Both files now resolve the field via find.descendant(of: find.byType(WFormInput), matching: find.byType(EditableText)), scoping the search to the single form input so the two-factor setup step's selectable secret-key EditableText is not matched by accident.

Fixed #

  • Notification-preference toggles now carry an accessible name and expose a single Semantics node. Each channel toggle (MSSwitch) had no semanticLabel and sat beside a visible WText of the same channel name, so a screen reader announced a bare "switch" while the row exposed TWO nodes sharing the label. The switch now takes semanticLabel: <channel name> and the visible label is wrapped in ExcludeSemantics, so the row exposes one correctly named toggle. This also gives an accessibility / E2E lookup a single stable target instead of resolving the inert text first. Touches lib/src/ui/views/notifications/magic_starter_notification_preferences_view.dart.

0.0.1-alpha.14 - 2026-04-16 #

✨ New Features #

  • Unified Theme System: Added MagicStarterTheme with 7 sub-themes (form, card, navigation, modal, layout, pageHeader, auth) set all theme tokens in one call via MagicStarter.useTheme()
  • Builder Slots: Added MagicStarter.view.slot() for partial view customization — override specific sections (header, footer, sidebar) without replacing the entire view
  • Granular Publish Command: dart run magic_starter:publish --tag=views:auth.login publishes a single view file to the host app for full ownership
  • Auto-wire Published Views: Published views are automatically wired into AppServiceProvider so they take effect immediately without manual registration
  • Doctor: Published View Detection: dart run magic_starter:doctor now detects published views and reports wiring status — flags views that are published but not registered
  • Layout Theme Drawer Shade: Added drawerBackgroundLightShade to MagicStarterLayoutTheme for consistent drawer background customization

🐛 Bug Fixes #

  • CLI: Cross-platform paths: Replaced POSIX string manipulation with package:path in publish and doctor commands for Windows compatibility
  • CLI: boot() injection: Publish auto-wire now locates the boot() method by signature and brace-depth tracking instead of fragile second-to-last } heuristic
  • Team Settings: Invite button now reads className from MagicStarter.modalTheme.primaryButtonClassName instead of hardcoded Wind UI tokens

🧪 Tests #

  • Added slot injection widget tests for 7 views: forgot_password, reset_password, two_factor_challenge, otp_verify, teams.create, teams.invitation_accept, notifications.preferences
  • Added drawerBackgroundLightShade default value test to theme test suite

📚 Documentation #

  • Manager: Added unified theme section, 5 new sub-theme sections (form, auth, card, page header, layout), updated facade methods table with all theme accessors
  • View Registry: Added Builder Slots section documenting slot/hasSlot/buildSlot API
  • CLAUDE.md: Added publish/uninstall commands, updated manager description, added customization gotchas, updated test count

0.0.1-alpha.13 - 2026-04-09 #

🐛 Bug Fixes #

  • Icon Tree-Shaking: Extracted all runtime-conditional Icons.* references into static const fields for Flutter web tree-shaking compatibility — fixes 11+ broken icon usages across 10 files (#37)

0.0.1-alpha.12 - 2026-04-09 #

✨ New Features #

  • Sidebar Footer: Added sidebarFooterBuilder slot via MagicStarter.useSidebarFooter() — renders custom widget between navigation and user menu in both desktop sidebar and mobile drawer (#27)
  • MagicStarterUserProfileDropdown: Moved theme toggle from sidebar bottom bar into user profile dropdown menu — sidebar now shows only avatar, name, and notification bell (#30)

🐛 Bug Fixes #

  • MagicStarterUserProfileDropdown: Fixed menu overflow when many profile menu items are registered — wrapped menu items in scrollable overflow-y-auto WDiv, keeping header and logout footer fixed (#28)
  • Sidebar Navigation: Fixed overflow when many nav items exceed viewport height — added overflow-y-auto to navigation WDiv so items scroll while brand, team selector, and user menu remain fixed (#29)

📚 Documentation #

  • Manager: Added useSidebarFooter() section and facade entry to manager doc
  • Views & Layouts: Updated theme toggle location from sidebar to user profile dropdown
  • README: Added layout customization section with useHeader() and useSidebarFooter() examples

0.0.1-alpha.11 - 2026-04-07 #

✨ New Features #

  • MagicStarterPageHeader: Added titleSuffix (Widget?) for inline widgets after title (e.g. status badges) and inlineActions (bool) to force single-row layout on all screen sizes (#24)

📚 Documentation #

  • Release Command: Added critical tag format warning — publish.yml requires tags without v prefix (#23)

0.0.1-alpha.10 - 2026-04-07 #

🐛 Bug Fixes #

  • MagicStarterDialogShell: Fixed bottom overflow when body content exceeds viewport — removed flex flex-col from outer WDiv that broke constraint propagation to inner Column; body now scrolls correctly with sticky header/footer (#21)

🔧 Improvements #

  • Dependencies: Bumped minimum magic to ^1.0.0-alpha.7 — updated all test setUp blocks to bind AuthManager in the IoC container, matching the new container-resolved Auth facade

0.0.1-alpha.9 - 2026-04-04 #

✨ New Features #

  • MagicStarterHideBottomNav: New InheritedWidget that signals MagicStarterAppLayout to hide the mobile bottom navigation bar for fullscreen routes — wired into layout and exported from barrel (#19)

📚 Documentation #

  • State/Controller Registration Guide: New architecture reference (doc/architecture/controllers.md) covering the lazy singleton pattern, MagicController + MagicStateMixin usage, controller lifecycle, view binding, and a decision tree for eager vs lazy vs per-view registration (#18)
  • State Management Getting-Started Guide: New practical guide (doc/guides/state-management.md) with end-to-end examples — state class, view integration, and testing patterns for consumer apps (#18)
  • Scaffolded Stub: app_service_provider.stub now includes state registration guidance comments showing the recommended Magic.findOrPut() pattern (#18)
  • Cross-References: doc/architecture/service-provider.md now links to the new controllers doc (#18)

🔧 Improvements #

  • CI: Bumped codecov/codecov-action from v5 to v6 (#16)

0.0.1-alpha.8 - 2026-03-31 #

🐛 Bug Fixes #

  • MagicStarterDialogShell: Fixed mobile overflow — maxHeight now computed from safe area (MediaQuery.viewPaddingOf) instead of raw screen height; added vertical insetPadding (24px) to prevent dialog from extending to screen edges (#13)
  • MagicStarterPasswordConfirmDialog: Same safe area fix — replaced hardcoded maxHeight: 600 with safeHeight * 0.85; added vertical insetPadding
  • MagicStarterTwoFactorModal: Same safe area fix — replaced hardcoded maxHeight: 800 with safeHeight * 0.85; added vertical insetPadding

0.0.1-alpha.7 - 2026-03-29 #

✨ New Features #

  • MagicStarterPasswordConfirmDialog: Added ConfirmDialogVariant support (primary, danger, warning) — confirm button now resolves color from variant via _resolveConfirmClassName(), matching MagicStarterConfirmDialog behavior. Both constructor and show() accept optional variant parameter, defaults to ConfirmDialogVariant.primary for backwards compatibility.

🔧 Improvements #

  • Profile Settings: Standardized dialog variants across all password-confirm call sites — danger for session revocation, warning for 2FA disable and recovery code regeneration, primary for neutral confirmations (enable 2FA, view codes)

0.0.1-alpha.6 - 2026-03-29 #

🐛 Bug Fixes #

  • MagicStarterPasswordConfirmDialog: Footer buttons now right-aligned — added w-full to footer WDiv so justify-end stretches to container width
  • MagicStarterTwoFactorModal: Footer buttons now right-aligned in both setup and recovery steps — same w-full fix applied to both footer locations

🔧 Improvements #

  • MagicStarterTwoFactorModal: Extracted duplicated footer className to shared _footerClassName const — reduces divergence risk

0.0.1-alpha.5 - 2026-03-29 #

Changed #

  • MagicStarterDialogShell: Now exported publicly from the barrel (package:magic_starter/magic_starter.dart) — consumer apps can compose custom dialogs on top of it
  • MagicStarterDialogShell: footer parameter replaced with footerBuilder (Widget Function(BuildContext dialogContext)?) — provides the dialog's own BuildContext so callers can call Navigator.pop(dialogContext) without needing an outer context

Fixed #

  • MagicStarterConfirmDialog and MagicStarterPasswordConfirmDialog: Buttons are now compact and right-aligned (justify-end gap-2 wrap) — previously rendered as full-width (flex-1) buttons that stretched across the footer
  • MagicStarterDialogShell: Body no longer creates a gap between scrollable content and the footer when content is shorter than the available height — switched from SingleChildScrollView to ListView(shrinkWrap: true)

0.0.1-alpha.4 - 2026-03-29 #

✨ New Features #

  • MagicStarterModalTheme: Added configurable modal theme system via MagicStarter.useModalTheme() with 13 Wind UI className token fields (containerClassName, headerClassName, bodyClassName, footerClassName, titleClassName, descriptionClassName, primaryButtonClassName, secondaryButtonClassName, dangerButtonClassName, warningButtonClassName, errorClassName, inputClassName, maxWidth). All fields optional — zero breaking changes.
  • MagicStarterConfirmDialog: Generic confirmation dialog with ConfirmDialogVariant enum (primary, danger, warning). Static show() factory supports async onConfirm callback, custom labels, and description. Exported from barrel.
  • Modal View Registry: Extended MagicStarterViewRegistry with registerModal(key, builder), hasModal(key), and makeModal(key). Three default modals auto-registered: modal.confirm, modal.password_confirm, modal.two_factor.
  • MagicStarterDialogShell: Internal composition widget with sticky header/footer and scrollable body. Uses Material Dialog shell + Wind UI content. Not exported — internal use only.

🔧 Improvements #

  • PasswordConfirmDialog: Now reads theme tokens from MagicStarter.manager.modalTheme instead of hardcoded classNames
  • TwoFactorModal: Now reads theme tokens from MagicStarter.manager.modalTheme instead of hardcoded classNames
  • Team Settings: Replaced Material AlertDialog with MagicStarterConfirmDialog.show() using ConfirmDialogVariant.danger

0.0.1-alpha.3 - 2026-03-26 #

✨ New Features #

  • MagicStarterCard: Added CardVariant enum (surface, inset, elevated) and a variant parameter so consumer apps can choose the card's visual style. Default is CardVariant.surface, which reproduces the original flat-border appearance and is fully backward-compatible.
  • MagicStarterPageHeader: Existing actions (List
  • Configurable navigation theme: Added MagicStarterNavigationTheme class and MagicStarter.useNavigationTheme() to allow consumer apps to override navigation colors and styles without breaking changes.
    • activeItemClassName — sidebar/drawer active item tokens (default: active:text-primary active:bg-primary/10 dark:active:bg-primary/10)
    • hoverItemClassName — sidebar/drawer hover tokens (default: hover:bg-gray-100 dark:hover:bg-gray-800)
    • brandClassName — brand/logo text className including gradient support (default: text-lg font-bold text-primary)
    • brandBuilder — custom brand widget builder (image/SVG/styled text); overrides brandClassName when set
    • bottomNavActiveClassName — bottom nav active icon/label tokens (default: active:text-primary)
    • avatarClassName — sidebar user menu avatar background (default: bg-primary/10 dark:bg-primary/10)
    • avatarTextClassName — sidebar user menu avatar initial color (default: text-sm font-bold text-primary)
    • dropdownAvatarClassName — profile dropdown trigger avatar background (default: bg-gradient-to-tr from-primary to-gray-200)
    • All fields optional — zero breaking changes, existing apps continue to work unchanged

0.0.1-alpha.2 - 2026-03-25 #

🐛 Bug Fixes #

  • Install Command: Use version dependency (^0.0.1-alpha.1) for magic_notifications instead of hardcoded relative path that only works in monorepo development environment

0.0.1-alpha.1 - 2026-03-25 #

✨ Core Features #

  • Authentication: Login, register, forgot/reset password with email and phone identity modes
  • Guest Auth: OTP-based phone login with send and verify flow
  • Two-Factor Authentication: Enable/disable 2FA with QR code setup, OTP confirmation, and recovery codes
  • Social Login: OAuth integration with configurable providers
  • Profile Management: Photo upload, email/password change, email verification, session management, timezone selection
  • Extended Profile: Additional profile fields with locale and timezone defaults
  • Teams: Create teams, switch active team, invite members, manage roles
  • Notifications: Real-time polling, mark read/unread, notification preference matrix
  • Newsletter: Simple subscribe/unsubscribe controller
  • 13 Feature Toggles: All opt-in — teams, profile_photos, registration, two_factor, sessions, guest_auth, phone_otp, newsletter, email_verification, extended_profile, social_login, notifications, timezones
  • 9 Gate Abilities: Authorization checks for profile sections (photo, email, phone, password, verify-email, two-factor, newsletter, sessions, delete-account)
  • View Registry: String-keyed view factory — host app can override any screen or layout
  • Wind UI: Tailwind-like className system — no Material widgets in layouts
  • CLI Tools: install, configure, doctor, publish, uninstall commands with stub templates
  • 2 Layouts: AppLayout (authenticated) and GuestLayout (auth pages)
  • 12 Views: 6 auth, 1 profile, 3 teams, 2 notifications
  • 10 Widgets: Reusable Wind UI components (auth form card, card, password confirm dialog, team selector, notification dropdown, two-factor modal, timezone select, user profile dropdown, social divider, page header)

🐛 Bug Fixes #

  • Timezone: Fix API field name and add comprehensive null safety checks
  • Auth: Correct register endpoint from /auth/login to /auth/register
  • UI: Remove flex Row from password confirm dialog buttons to prevent overflow

🔧 Improvements #

  • Auth Events: Add auth restored listener for app reload on team switch
  • Validation: Add input validation and network error handling to auth controllers
  • Config: Add HTTP timeout and retry configuration
  • i18n: Add notification and network error translation keys to en.stub

📚 Documentation #

  • README: Full pub.dev-ready README with badges, features table, quick start guide
  • doc/ folder: Comprehensive documentation (installation, configuration, authentication, profile, teams, notifications, views, CLI, architecture)
  • CLAUDE.md: Rewrite to match Magic ecosystem format
  • Publishing: Package metadata, CI/CD workflows, issue templates, LICENSE
1
likes
0
points
1.17k
downloads

Documentation

Documentation

Publisher

verified publisherfluttersdk.com

Weekly Downloads

Starter kit for Magic Framework. Auth, Profile, Teams, Notifications — 14 opt-in features with overridable views.

Homepage
Repository (GitHub)
View/report issues

Topics

#authentication #starter-kit #magic #flutter #admin-panel

License

unknown (license)

Dependencies

args, flutter, fluttersdk_artisan, fluttersdk_wind, magic, magic_notifications, magic_payments, path

More

Packages that depend on magic_starter