magic 0.0.23
magic: ^0.0.23 copied to clipboard
A Laravel-inspired Flutter framework with Eloquent ORM, routing, and MVC architecture.
Changelog #
All notable changes to this project will be documented in this file.
[Unreleased] #
0.0.23 - 2026-09-28 #
BREAKING #
make:controller's plain and--resourcestubs, andmake:view's stateful stub, changed shape. The resource controller stub dropped its own hand-rolled CRUD/state scaffolding in favour of theRepositoryQuery-backed read stateMakeControllerCommandnow assembles per flag (--actions,--broadcasts,--timers,--validates, plus aresetForSession()implementingSessionScoped); the plain controller stub gained the sameSessionScopedshape with an emptyresetForSession().make:view --statefulnow writes aMagicStatefulView<<Name>Controller>instead of a plainStatefulWidget, so the derived<Name>Controllerhas to exist (or be named with--controller=<Name>) for the view to compile; the new--listaddsRefetchesOnMountover a--resourcecontroller, and--form=<FormObject>adds a State-owned form object disposed inonClose. A controller or stateful view generated before this change keeps its old shape until regenerated; regenerating with--forceoverwrites any hand-edited body, so review the diff first. (lib/src/cli/commands/make_controller_command.dart,lib/src/cli/commands/make_view_command.dart,assets/stubs/controller.stub,assets/stubs/controller.resource.stub,assets/stubs/view.stateful.stub)make:componentno longer always scaffolds the preview file and chainspreviews:refresh. It now writes<name>.preview.dartonly when the target project already maintains a preview catalogue (any*.preview.dartfile or a_previews.g.dartindex underlib/); pass--previewor--no-previewto force the direction explicitly. A project with no preview catalogue yet used to get one scaffolded regardless. (lib/src/cli/commands/make_component_command.dart)- Exporting
ActionRequestFailedfromlib/magic.dartconflicts with an app class of the same name until the app imports magic's. Dart resolves a bareActionRequestFailedreference in a file that neither declares nor imports one of its own to the export frompackage:magic/magic.dart; an app that already declares its ownActionRequestFailedsees an ambiguous-import error the moment both land in scope, and must rename its own class or import magic's explicitly instead. (lib/magic.dart,lib/src/actions/action_request_failed.dart)
Added #
references/plugin-sentry.md, the skill's page formagic_sentry0.0.1. The ecosystem plugin table, the reference index and the skill's trigger line now name the package, and the page covers the boot order (MagicSentry.runbeforeMagic.init), whatSentryServiceProvider.boot()wires, howSentryNetworkInterceptorsorts an HTTP failure into an event or a breadcrumb, the scope user,ReportsBreadcrumbevents, and the published.envconfig. (skills/magic-framework/)make:resource, the artisan generator composing a full CRUD vertical for a model. Magic's analogue of Laravel'smake:model --all: model and factory, repository, the create/update/delete actions, the Store and Update requests, the resource form object, a--resource --actionscontroller, and the list and form views, plus the tests for the actions, the form and the controller. Every file comes from its own owning generator throughRunChild; the run preflights every target path first, so a clash anywhere (other than a kept model or factory) fails the whole run with nothing written unless--forceis passed.--no-modelleaves the model and factory out (an existing model and factory are kept either way);--no-viewsstops after the data and write layers. The index and create route lines print forRouteServiceProvider.boot(), never write into it. A new integration test (taggedintegration, run in CI as its own job) generates everymake:*output into a scratch Flutter project and requiresflutter analyzeto report zero issues. (lib/src/cli/commands/make_resource_command.dart,test/cli/integration/generated_code_analyzes_test.dart,dart_test.yaml,.github/workflows/ci.yml,skills/magic-framework/)make:test --kind=<kind>, the artisan generator for a test skeleton mirroring anothermake:*generator's own output. Coverscontroller,action,form,repository,request,viewandunit, resolving the target project's package name from its ownpubspec.yamlso the generated test imports app code aspackage:<name>/...rather than a relative../lib/path (which tripsavoid_relative_lib_imports).make:controller,make:view,make:action,make:form,make:repositoryandmake:requestnow accept--test, chainingmake:testonto a successful write so the matching test lands in the same run;--forceon the host forwards to the chained test too, and a refused test write fails the host with exit 1.make:componentnow also writes a widget test for the component, importing its barrel with a prefix so a component named like a Material widget (Badge,Card) stays unambiguous; a test already at that path fails the run before anything is written unless--forceis passed. (lib/src/cli/commands/make_test_command.dart,lib/src/cli/commands/make_component_command.dart,lib/src/cli/helpers/creates_matching_test.dart,lib/src/cli/helpers/run_child.dart,skills/magic-framework/)make:action --kind=create|update|delete --model=<Model>, the write variants of the plainmake:actiongenerator.createfills and saves a new model,updatesaves an edit and writes it into<Model>Repository,deletedeletes and evicts it; a refused save throwsActionRequestFailed.--kindrequires--model. (lib/src/cli/commands/make_action_command.dart,assets/stubs/action.create.stub,assets/stubs/action.update.stub,assets/stubs/action.delete.stub,skills/magic-framework/)make:form --resource=<Model>, the full create/edit variant of the plainmake:formgenerator. Scaffolds aneditingfield,initialseeded fromediting?.toArray(), arequestchoosing between the model's Store/Update requests, and apersistrunning the matching Create/Update action (a create then reloads the--resource<Model>Controller, so it expects the controllermake:resourcewrites), instead of the plain skeleton's TODO placeholders. (lib/src/cli/commands/make_form_command.dart,assets/stubs/form.resource.stub,skills/magic-framework/)ActionRequestFailed, the exception a refusedMagicActionwrite throws.ActionRequestFailed.refusalOf(action, errors, [response])answers aValidationException(first message per field) whenerrorscarries any, otherwise anActionRequestFailedcarrying the refusing response;retryAfterSecondsreads a 429 body'sretry_after_seconds, falling back to 1. (lib/src/actions/action_request_failed.dart,doc/basics/actions.md,skills/magic-framework/)make:model's generated stub now includes a staticfromMap(Map<String, dynamic> map)factory. Hydrates the model directly viasetRawAttributes, bypassing thefillablemass-assignment guard (unlikefill), and setsexistsfrom whether the map carries anidkey, matching how a factory or a repository already builds a model from raw API data. (assets/stubs/model.stub,skills/magic-framework/)make:enum --wire, a wire-backed variant of the plainmake:enumgenerator. Adds anunknownfallback case, afromWire()factory that never throws on an unrecognised value, and atrans()-backedlabelgetter, for an enum mirroring a backend string value. (lib/src/cli/commands/make_enum_command.dart,assets/stubs/enum.wire.stub,skills/magic-framework/)make:lang --from=<locale>, copying an existing language file's key tree into a new one. Defaults toen; whenassets/lang/<from>.jsonexists, the new locale's file carries the same keys with each leaf copied verbatim (a complete catalogue ready for a human translator) instead of an empty{}. A source that is not valid JSON fails with exit 1 and writes nothing. (lib/src/cli/commands/make_lang_command.dart,skills/magic-framework/)
Changed #
make:requestnow writes aFormRequestsubclass with aconstconstructor and arules()override, and accepts--test. The old stub was a plain class wrappingValidator.make, which neitherMagicFormObject.requestnorValidatesRequests.validateRequestaccepts. (lib/src/cli/commands/make_request_command.dart,assets/stubs/request.stub,skills/magic-framework/)make:model --allalso writes the model's repository, and passes--model=<Model>to the resource controller it chains, since that controller now reads through<Model>Repository. (lib/src/cli/commands/make_model_command.dart)
Fixed #
make:modelnow exits 1 when the model file already exists without--force, instead of logging the clash and generating the requested companions (migration, factory, seeder, policy, controller) against it anyway. (lib/src/cli/commands/make_model_command.dart)
0.0.22 - 2026-09-27 #
BREAKING #
MinandMaxread a numeric string as a number when the field also carriesNumericorInteger. Form input is always a string, and both rules used to measure it by length whatever the field was:[Numeric(), Max(100)]accepted"500"(three characters) and[Integer(), Min(3)]refused"12". Both now compare the value, as Laravel'sgetSizedoes beside a numeric rule. A field withoutNumericorIntegeris unchanged and still measured by length, so[Min(8)]on a password is unaffected. A directpasses()call, with no rule list to read, also sizes a string by length. (lib/src/validation/contracts/size_rule.dart,lib/src/validation/rules/min.dart,lib/src/validation/rules/max.dart,lib/src/validation/validator.dart,lib/src/validation/form_validator.dart,doc/digging-deeper/validation.md)Auth.fake()'s login/logout now dispatchAuthLogin/AuthLogoutthrough the realEventfacade, same as the real guard. A test that registered anEvent.listenlistener and drove it throughAuth.fake()used to see nothing; it now observes the same events a real session fires. A test asserting a listener's absence of a call across a faked login/logout now needs to account for it. (lib/src/testing/fake_auth_manager.dart,doc/testing/facades.md,doc/security/authentication.md)state()andcount()return a copy, and every factory implementsFactory<T> newFactory(). They used to mutate the factory and return it, sofinal f = UserFactory(); f.state({...}); f.make()carried the state, and two branches off one base leaked into each other. Dart cannot construct "the same subclass" on its own, so the new abstract hook answers with the subclass constructor (Factory<User> newFactory() => UserFactory();). Named states move to an extension onFactory<T>, since a method on the subclass is out of reach after the firststate()orcount(); the oldstate({...}) as UserFactorycast would now throw. No factory subclass exists in this package'slib/,test/orexample/; themake:factorystub anddoc/database/seeding.mdshow the new shape. (lib/src/database/seeding/factory.dart,assets/stubs/factory.stub,doc/database/seeding.md)MagicTest.init()resets the Gate, the Translator and the DateManager between tests. It now callsGate.flush()insetUpandtearDown, andDateManager.reset()plusTranslator.reset()intearDown, so an ability, a loaded catalogue or a locale no longer leaks into the next test. A suite underMagicTest.init()that defines abilities or loads translations insetUpAllloses them after the first test and must move that work tosetUp. (lib/src/testing/magic_test.dart,doc/testing/getting-started.md)Magic.delete<T>()now disposes theChangeNotifierit removes;Magic.flush()does not. EveryMagicControlleris one, and so is a plainChangeNotifierorValueNotifierregistered withMagic.put:deletedisposes each of them, so code that disposes such a notifier itself must stop, or its seconddispose()throws in a debug build. Such an instance used to stay live in memory afterdelete, listened to by nothing, disposed by nothing. A caller that reused a handle to a deleted controller acrossdeletenow hits a disposedChangeNotifier, where it used to keep working on a live, orphaned one; hold a freshMagic.find/findOrPutafter adeleteinstead.Magic.flush()(a full container reset, mainly for tests) is unchanged: it clears the registry without disposing, since a flushed instance may still be referenced by a test assertion made just before the flush. (lib/src/foundation/magic.dart,lib/src/http/magic_controller.dart,doc/basics/controllers.md,skills/magic-framework/)
Added #
BaseGuarddispatchesAuthLogin/AuthLogoutthrough theEventfacade.startSessiondispatchesAuthLogin(user)last and awaits its listeners, skipped when the session ended or was replaced while the user was being cached;logout()dispatchesAuthLogout(previous)after thestateNotifierbump and before a rethrown Vault failure, for a guest (nulluser) too, meaning "the in-memory session ended" rather than "the credentials are gone" (gate server-side release onAuth.hasToken()).AuthRestoredis unchanged: only an API-confirmed sync fires it. (lib/src/auth/guards/base_guard.dart,doc/security/authentication.md,doc/digging-deeper/events.md,skills/magic-framework/)Event.listen<T extends MagicEvent>(MagicListener Function() factory), a registration shortcut on theEventfacade. Equivalent toEventDispatcher.instance.register(T, [factory]), so a listener can be registered without adding a mapping toAppEventServiceProvider.listen.Tmust be named explicitly; call it from a provider'sregister(), notboot(), since a guard can dispatchAuthLoginduringAuthServiceProvider.boot. (lib/src/facades/event.dart,doc/digging-deeper/events.md,skills/magic-framework/)AuthChannelSubscription, a reconciler for a private broadcast channel whose name depends on auth state.sync()re-reads a caller-suppliedchannelName()on every call, serialised against overlapping calls, and is a no-op when the name has not changed, whatever the connection is doing (the Reverb driver recovers a drop on its own; resubscribing here would open a second socket). A name change leaves the old channel by its prefixed name, connects only when not already connected, then subscribes and wireslisteners.onReconnectfires on both anEcho.onReconnectsignal and aconnectionStatetransition toconnected.dispose()cancels only the reconnect-listening subscriptions.disconnectOnTeardown: falsemakes anullname leave the channel without disconnecting, for an owner of several subscriptions on one connection. (lib/src/broadcasting/auth_channel_subscription.dart,doc/digging-deeper/broadcasting.md,skills/magic-framework/)Str.unwrap(value, before, [after]), Laravel'sStr::unwrapported. Stripsbeforefrom the start andafter(defaultbefore) from the end, each checked and stripped independently, so a prefix-only match ('"x') loses the leading quote and stays unbalanced rather than being left alone. (lib/src/support/str.dart,doc/digging-deeper/helpers.md,skills/magic-framework/)CollapsesIndexedErrorKeys.collapse(wireKey), a static entry point for the mixin's own collapse. For a controller that cannot mix inCollapsesIndexedErrorKeys(it already extends a different base) but still needs to collapse an indexed wire validation key (items.0.name) onto its field name (name). Same collapseerrorFieldForruns when the mixin is in place. (lib/src/concerns/validates_requests.dart,doc/digging-deeper/validation.md,skills/magic-framework/)Model.unguarded(),Model.unguard(),Model.reguard()andModel.isUnguarded, Laravel's mass-assignment switch. InsideModel.unguarded(() => ...)everyfill()keeps every key, whateverfillableandguardedsay; the guard comes back when the callback returns or throws, and a nested call leaves the outer scope unguarded. The callback must be synchronous, since an async one would run its laterfill()calls guarded, and an assertion says so, now checked whether or not the call nests inside an outerunguard()/unguarded()scope. Outside it,fill()behaves exactly as before,strict: trueincluded;fromMap()andsetRawAttributes()are untouched. (lib/src/database/eloquent/model.dart)Factory.raw(), the merged definition and states without a model: always aList<Map<String, dynamic>>, one map per model, a single map when nocount()was set. (lib/src/database/seeding/factory.dart)Carbon.setTestNow([testNow])andCarbon.hasTestNow(), Laravel's frozen-clock testing helper.Carbon.now([timezone])returns the frozen instant while one is set (timezone conversion still applies on top of it),isToday(),isYesterday(),isTomorrow(),isFuture(),isPast()and argument-lessdiffForHumans()measure against it, andCarbon.setTestNow()with no argument (ornull) clears the freeze. A test that seeds an app's clock now has a Laravel-parity seam instead of threading a fakeDateTimethrough every call site. (lib/src/support/carbon.dart,doc/digging-deeper/carbon.md,skills/magic-framework/)Number,Str,Arr, andCast, Laravel's Support helpers ported to the subset magic needs.Numberformats values, currency, percentages, file sizes, and abbreviations under a resolved locale (Number.percentage(99.95, precision: 2, locale: 'tr')->'%99,95');Str.upper/Str.lower/Str.initialsapply the Turkish/Azerbaijani dotted-i ruleString.toUpperCase()/toLowerCase()get wrong;Arr.get/has/set/dotwalk a dotted path through a nestedMap<String, dynamic>;Castreads a loosely-typed wire value (stringOr,intOr,numOrNull,boolOr,idOrNull, ...) as a specific type, degrading to a fallback instead of throwing. All four areabstract final classstatic namespaces with no shared base. (lib/src/support/number.dart,lib/src/support/str.dart,lib/src/support/arr.dart,lib/src/support/cast.dart,doc/digging-deeper/helpers.md,skills/magic-framework/)Carbon.shortDiffForHumans([other]), a compact ladder for dense tables. Steps seconds through years ('14m ago','1mo ago','5m from now','Just now'under one second), resolving each unit and wrapper through theLangcatalogue (time.units_short.*,time.ago,time.from_now,time.just_now) with an English literal fallback when no catalogue is loaded. (lib/src/support/carbon.dart,doc/digging-deeper/carbon.md,skills/magic-framework/)ValidatesRequests.validateRequest/validateRequestAsync, running aFormRequestthrough a controller's own error bag.FormRequest.validate()returns only the rule-filtered payload and never touchesvalidationErrors, so a controller calling it directly skipped clearing stale errors, populating per-field errors, and repainting the form. The new methods run the same authorize/prepare sequence but validate through the mixin'svalidate()and return the FULL prepared map.validateRequestruns sync rules only (anAsyncRuleis skipped);validateRequestAsyncawaitsValidator.validateAsync()so anAsyncRuleactually runs. (lib/src/concerns/validates_requests.dart,doc/digging-deeper/validation.md,skills/magic-framework/)CollapsesIndexedErrorKeys, an opt-inValidatesRequests.errorFieldForoverride for a list field's indexed error keys. A backend validating a list returns one wire key per element (items.0.name); a form with a single error slot per field has nowhere to put a per-index message. Mixed in on top ofValidatesRequests, it collapses such a key to its field name (items.0.name->name), keeping the FIRST message when two indexed keys collapse onto the same field. Opt-in because the default keeping raw keys is a behaviour existing controllers already depend on. (lib/src/concerns/validates_requests.dart,doc/digging-deeper/validation.md,skills/magic-framework/)RefetchesOnMount<C, W>andSubmitsOnce<W>, two view-layer mixins closing gaps in magic's singleton-controller and async-submit model.RefetchesOnMount, mixed onto aMagicStatefulViewState, fire-and-forget refetches a controller's data on every mount (not only the first, since controllers fireonInitonce per instance for the app's lifetime);SubmitsOnce, mixed onto a form'sState, guards a submit handler against a second tap while the first write is in flight, resetting in afinallyso a throwing submit re-arms the button. (lib/src/ui/refetches_on_mount.dart,lib/src/ui/submits_once.dart,doc/basics/views.md,skills/magic-framework/)Env.filled(key, fallback)andEnv.getOrFail(key), guards for a value a blank silently corrupts.Env.get/env()only fall back when a key is entirely absent, so a present-but-blank or quote-only value silently resolved to''and has shipped as a blank browser tab title and a link pointing at a path with no origin.Env.filledtreats absent, blank, and quote-only the same way, stripping one wrapping quote pair and surrounding whitespace from a present value.Env.getOrFailmirrors Laravel'sEnv::getOrFail, throwing aStateErroronly when the key is entirely missing. (lib/src/foundation/env.dart,doc/getting-started/configuration.md,skills/magic-framework/)MagicTest.loadTranslations(locale, {directory}), a real translation catalogue for a widget test. Reads<directory>/<locale>.jsonoff disk, flattens it with the app's own flatten rule (JsonAssetLoader.flatten, now public), and installs it before awaiting the translator's load, sotrans()resolves real catalogue strings in a test instead of rendering the raw dotted key. (lib/src/testing/magic_test.dart,doc/testing/getting-started.md,skills/magic-framework/)SyncFeed,SyncLedgerandCreateSyncCursorsTable, a push-then-pull sync skeleton over one REST resource.SyncFeed.run({scope, account})sends everything a subclass'spending()reports written since this device's own push mark (POST '$resource/sync', batched atbatchSize), then walks pull pages (GET resource, up tomaxPages) handing each row to the subclass'sadoptRow(), and never throws: an exception surfaces as aSyncReport.failurestring, logged viaLog.error. Only the push mark advances locally, and only past a shared mark once every row carrying it has been sent (marks need not be unique, and a batch boundary may land in the middle of a run of rows that share one); a run halted by a throw after a batch landed reports that batch's rows aspushedinstead of zero. The pull cursor is the server's opaque text handed straight back, because a row adopted from the server carries the originating device's own clock.SyncLedgeris the bookmark store behind it (sync_cursors, upserted by delete-then-insert inside aSAVEPOINTso it nests inside a caller's own transaction), andCreateSyncCursorsTablecreates that table; magic has no migration discovery, so an app lists it in its ownMigrator().run([...])call. Scope derivation, any additional salt, and when a feed runs are left to the app. (lib/src/sync/sync_feed.dart,lib/src/sync/sync_ledger.dart,lib/src/sync/create_sync_cursors_table.dart,doc/digging-deeper/sync.md,skills/magic-framework/)Str.ascii(value)andStr.squish(value), two more of Laravel'sStrhelpers ported.asciifolds Latin letters (accents, the Romanian comma-below letters,ẞ) to their plain ASCII base for a search key, Latin only, diverging from Laravel'sStr::ascii, which transliterates every script it has a table for and would otherwise collapse a non-Latin word to?or a phonetic guess.squishtrims and collapses every run of whitespace to one space, matching Laravel'sStr::squish, over Dart's\sclass plus two Hangul filler code points a rendered blank can carry without registering as whitespace. (lib/src/support/str.dart,doc/digging-deeper/helpers.md,skills/magic-framework/)- The default
User-Agent's app-name folding (NetworkServiceProvider, viaStr.ascii) now also foldsș,ț,ẞ, and the Angstrom sign U+212B to their plain ASCII base. They used to fall throughStr.ascii's table untouched and were then dropped by the printable-ASCII filter that follows it. AppLifecycle.states(), the app lifecycle as aStream<AppLifecycleState>for a reader built before aWidgetsBindingnecessarily exists. A dependency constructed inside a service provider'sregister()runs before the app has bound anything, so reaching forWidgetsBinding.instancethere throws;states()defers that lookup to the moment a listener subscribes, adding its own observer to the binding onlistenand removing it oncancel, so nothing outlives its reader. Prefer Flutter's ownAppLifecycleListeneronce a binding is guaranteed to exist. (lib/src/support/app_lifecycle.dart,doc/digging-deeper/helpers.md,skills/magic-framework/)SessionScopeandSessionScoped, a tenant-reset boundary over every cached controller and repository.SessionScope.attach()subscribes toAuth.stateNotifier(explicit, and meant to be the LAST such listener), andsync()callsresetForSession()in place on everyMagic.controllers.whereType<SessionScoped>()plus every holder passed toregister(), on a change to a non-null identity; a change tonull(logout) is recorded without resetting, since a refetch from the login screen can only 401.SessionScope.identitydefaults to the authenticated user id and is overridable to fold a team id in. (lib/src/session/session_scoped.dart,lib/src/session/session_scope.dart,doc/digging-deeper/session-scope.md,skills/magic-framework/)Repository<T>andRepositoryQuery<T>, an id-keyed cache of one remote resource's rows plus an ordered, filtered, paginated view over it.Repositoryregisters itself withSessionScopein its own constructor;upsertFromListpreserves ashowOnlyKeys-named field the list endpoint never measured,upsertFromShowis authoritative for everything, andrefresh(id)evicts on a 404 but keeps the cached row on any other failure.RepositoryQueryreusesMagicPaginator.fetcher, writes every page into the repository, and resolvesitemslive against it so apatch/evictshows up with no refetch. (lib/src/data/repository.dart,lib/src/data/repository_query.dart,doc/eloquent/repositories.md,assets/stubs/repository.stub,skills/magic-framework/)MagicAction<I, O>,RunsActionsand the sealedActionOutcome<O>it answers, a stateless write unit and the controller mixin that runs one.MagicAction.resolve/bind/flushmirror Fortify's action-swap pattern;RunsActions.runActionguards a key against a second call while the first is in flight, paints aValidationException's errors throughValidatesRequestswhen the host mixes it in (falling back to the generic-failure toast when it does not), and otherwise logs the exception and shows a toast titledtrans('common.error_occurred'), its body the same translated fallback (orfailureMessage), never the exception's own text.runActionanswersActionOutcome<O>(ActionSucceeded<O>with the action'svalue,ActionFailed<O>with the thrownerror, orActionRefused<O>on a same-key re-entry) rather than a bareO?, so a refused double-tap and a real success are no longer bothnull. (lib/src/actions/action_outcome.dart,lib/src/actions/magic_action.dart,lib/src/actions/runs_actions.dart,doc/basics/actions.md,assets/stubs/action.stub,assets/stubs/install/lang_en.stub,skills/magic-framework/)MagicFormObject, a Livewire-style Form object composingMagicFormData,ValidatesRequests,CollapsesIndexedErrorKeysandRunsActions.submit()clears stale errors, validates againstrequest(client-side rules stop it beforepersistever runs), then routespersistthroughdata.process. Created one perState, disposed from thatState'sonClose;onCloseasserts (in debug) it was never registered viaMagic.put/findOrPut. (lib/src/forms/magic_form_object.dart,doc/basics/forms.md,assets/stubs/form.stub,skills/magic-framework/)LatestRead,Poll,Countdown,DebouncerandOwnsTimers, generalising the per-field timer bookkeeping a controller used to hand-roll.LatestRead.begin/isCurrentdrops a stale answer that lands after a newer read for the same key;Poll.untilre-reads untildoneaccepts a value or an attempt budget runs out, settling a sealedPollOutcome(PollSettled/PollExhausted/PollCancelled);Countdownruns a per-key one-second clock to zero;Debouncercoalesces repeated calls under a key into the last one.OwnsTimers.own(cancellable)accepts any of the four (plus a bareTimer/StreamSubscription) and cancels every owned one fromonClose. (lib/src/support/latest_read.dart,lib/src/support/poll.dart,lib/src/support/countdown.dart,lib/src/support/debouncer.dart,lib/src/http/owns_timers.dart,doc/digging-deeper/helpers.md,doc/basics/controllers.md,skills/magic-framework/)BroadcastListenersandListensToBroadcasts, oneAuthChannelSubscriptionper declared alias shared by every controller listening on it.BroadcastListeners.channel(alias, name, {onReconnect})declares what an alias resolves to;add/removefan a stable per-event callback out to every registered handler, isolating one handler's throw from the next. The aliases share the defaultEchoconnection: anullname leaves only that alias's channel, andsync()disconnects once no alias resolves one, so a user leaving their last team does not deafen the alias still naming them.ListensToBroadcasts.listenersis keyed'<alias>:<event>';startListening()runs fromonInit()for a mounted controller and must be called explicitly by one reached only through.instance. (lib/src/broadcasting/broadcast_listeners.dart,lib/src/broadcasting/listens_to_broadcasts.dart,doc/digging-deeper/broadcasting.md,skills/magic-framework/)UrlGeneratorand the top-levelurl()helper, an absolute-URL builder against the app's configured origin. NamedUrlGeneratorrather thanUrlbecause that name is already the validation rule atlib/src/validation/rules/url.dart.localized(path)prefixes the active language unless it is the configured default, degrading to the bare path for an unlisted language. (lib/src/support/url_generator.dart,doc/digging-deeper/helpers.md,skills/magic-framework/)Event.listenAny(callback)andReportsBreadcrumb, a wildcard event listener and a whitelist contract for a crash reporter's breadcrumb trail.listenAnyrunscallbackon every dispatched event, after the typed listeners for that dispatch, and answers a remover; a throwing wildcard callback is logged and isolated the same way a typed listener's is.ReportsBreadcrumb(breadcrumbCategory,breadcrumbMessage,breadcrumbData) lets an event opt into a reporter's breadcrumb trail without the reporter needing to know the event type;breadcrumbDatais a whitelist, never a raw payload dump. (lib/src/events/event_dispatcher.dart,lib/src/facades/event.dart,lib/src/events/reports_breadcrumb.dart,doc/digging-deeper/events.md,skills/magic-framework/)Uuid,Boolean,Numeric,Integer,Gt/Gte/Lt/Lte,Between,Regex,Date,Nullable,RequiredIfandArrayRule, eleven more validation rules ported from Laravel. Every rule butRequiredIftreatsnull/empty as "letRequiredhandle it"; the four comparison rules andBetweensize a numeric value, a string's length, or a list's item count, and read a numeric string as a number only when the field also carriesNumericorInteger, as Laravel'sgetSizedoes. So[Numeric(), Between(1, 100)]refuses"500"while[Between(8, 64)]on a password accepts"12345678".SizeRuleis the contract behind it:ValidatorandFormValidatorhandpassesSizedthe field's numeric answer, and a custom size rule extends it to get the same reading. (lib/src/validation/contracts/size_rule.dart,lib/src/validation/rules/uuid.dart,lib/src/validation/rules/boolean.dart,lib/src/validation/rules/numeric.dart,lib/src/validation/rules/integer.dart,lib/src/validation/rules/comparison.dart,lib/src/validation/rules/between.dart,lib/src/validation/rules/regex.dart,lib/src/validation/rules/date.dart,lib/src/validation/rules/nullable.dart,lib/src/validation/rules/required_if.dart,lib/src/validation/rules/array_rule.dart,doc/digging-deeper/validation.md,assets/stubs/install/lang_en.stub,skills/magic-framework/)make:action,make:formandmake:repository, three artisan generators for the new primitives.make:formandmake:repositoryguarantee theFormObject/Repositoryclass-name suffix regardless of what is typed;make:form --request=<Class>wires a real request import instead of the default TODO placeholder. (lib/src/cli/commands/make_action_command.dart,lib/src/cli/commands/make_form_command.dart,lib/src/cli/commands/make_repository_command.dart,assets/stubs/action.stub,assets/stubs/form.stub,assets/stubs/repository.stub,doc/basics/actions.md,doc/basics/forms.md,doc/eloquent/repositories.md,skills/magic-framework/)
Changed #
- The
fluttersdk_windfloor moves^1.6.4to^1.7.0. The old range already admitted 1.7.0, so a freshpub getresolves nothing differently; what changes is that the floor names the release this package is verified against. 1.7.0 only addscontrastRatioandcontrastForeground, which magic re-exports and does not call. (pubspec.yaml) magic:install --with-devtoolswritesmagic_devtools: ^0.0.7, up from^0.0.6. 0.0.7 re-pins devtools to this batch (magic 0.0.22, dusk 0.0.16, wind 1.7.0). The installer's constant,install.yaml's printed message anddoc/packages/magic-devtools.mdmove together, held by the parity test. (lib/src/cli/commands/magic_install_command.dart,install.yaml,doc/packages/magic-devtools.md)- A factory fills its models unguarded, as Laravel's does.
make()andcreate()used to runfill()under the model'sfillablelist, soid,last_statusand every other key the model does not mass-assign silently vanished, and a factory could not build a model the way the API returns it. Only thefill()is unguarded;create()saves with the guard back on.make()leavesexistsfalse and every attribute dirty.create()now sends every definition key, includingidand timestamps, to the persistence layer (Http.store/QueryBuilder.insert), and throws aStateErrornaming the model type whensave()refuses the model (any result other thantrue), including the model'svalidationErrorswhen it exposes them. (lib/src/database/seeding/factory.dart)
Fixed #
ReverbBroadcastDriver.connect()is idempotent: a second call, or a call while a reconnect is armed or in flight, no longer opens a second socket. A secondconnect()used to assign a fresh socket over the live one and leak it, and a retry timer armed by a drop, a failed retry or a connection timeout stayed armed beside it.connect()now returns when connected, joins the attempt already in flight (single-flight shared with the reconnect timer), and supersedes an armed retry: it cancels the timer and runs the same reconnect work now, resubscribing every channel and firingonReconnect; a failed superseding attempt re-arms the retry before it throws.AuthChannelSubscriptiongoes back to a plainisConnectedgate beforeEcho.connect()instead of inferring a pending reconnect fromconnectionState. (lib/src/broadcasting/drivers/reverb_broadcast_driver.dart,lib/src/broadcasting/auth_channel_subscription.dart,doc/digging-deeper/broadcasting.md,skills/magic-framework/)ReverbBroadcastDriverreportedreconnectingafter a socket drop even withreconnect: false, so a consumer ofconnectionStatewas told a reconnect was coming when_scheduleReconnecthad already returned without arming one._onDoneand_onErrornow reportdisconnectedinstead when thereconnectconfig key is off, and still reportreconnectingwhen it is on. (lib/src/broadcasting/drivers/reverb_broadcast_driver.dart)- The
AbilityCallbackdoc listedbool callback(Model user)as valid. The gate always callscallback(user, arguments), so that shape throws and the ability is denied; the doc now shows(Model user, [dynamic arg]). (lib/src/auth/gate_manager.dart) env('KEY', fallback)returned the literal two-character string'""'/"''"forKEY=""/KEY='', not the default and not an empty string.flutter_dotenv's own parser needs at least one character inside the quotes to strip them, so a present-but-quote-only value passed through unquoted.Env.getnow trims a value that is exactly'""'or"''"down to'', matching Laravel'sEnv::get(laravel-framework/src/Illuminate/Support/Env.php:271): an explicit empty value is'', never treated as absent. (lib/src/foundation/env.dart,doc/getting-started/configuration.md,skills/magic-framework/)
0.0.21 - 2026-09-24 #
Added #
MagicApplication.builder, a layer above the router that survives every navigation. It is passed straight toMaterialApp.router(builder:), so it wraps theRouterinside the app'sTheme, localizations,DirectionalityandMediaQuery, and above the rootNavigator. A layout cannot do this job, since it belongs to its routes and ato()to an unstacked route outside the group disposes it; there was no seam short of abandoningMagicApplication. The case that forced it is a floating video player whose platform view must never be remounted while the viewer moves between pages: its State now survives everyto(), push andback(). From the layer's own contextNavigator.ofandOverlay.offind nothing, so it navigates throughMagicRoute, opens dialogs withMagic.dialog(), and needs anOverlayof its own around anyTooltip,WPopoverorWSelect. A soft restart is not a navigation:Magic.reload(), whichLang.setLocale()calls unless passedreload: false, remounts the layer with everything else. The loading and failure screens shown before initialization are left unwrapped, and a nullbuilderchanges nothing. (lib/src/foundation/magic_app_widget.dart,doc/basics/routing.md,skills/magic-framework/)
0.0.20 - 2026-09-23 #
Changed #
-
The
fluttersdk_windfloor moves^1.6.3to^1.6.4. The old range already admitted 1.6.4, so a freshpub getresolves nothing differently; what changes is that the floor names the release this package is verified against. 1.6.4 clips a rounded, borderedoverflow-hiddenbox inside its border, so a child that fills the box no longer paints over the border's corners, and the box's shadow shows again. (pubspec.yaml,CLAUDE.md) -
magic:install --with-devtoolswritesfluttersdk_dusk: ^0.0.16, up from^0.0.15. dusk 0.0.16 stopsdusk:fill,dusk:typeanddusk:clearfrom writing into a field on a route the visible one covers. The installer's constraint map,install.yaml's post-install message (held together by a parity test),doc/packages/magic-devtools.mdand the skill's devtools page move together;magic_devtoolsstays^0.0.6, whose own^0.0.15admits 0.0.16. (lib/src/cli/commands/magic_install_command.dart,install.yaml,doc/packages/magic-devtools.md,skills/magic-framework/references/plugin-devtools.md) -
On the web, every push now reports the pushed page's address, not only a stacked
to(). The flag below is go_router's, and it covers every imperative push:MagicRoute.push(),replace()over a pushed page, and a go_routercontext.pushin app code all move the address bar now, where they used to leave the page beneath's. A reload of such an address arrives with only the path, so a page pushed with go_router'sextrareceives none, and the page that was underneath is not there forback()without afallback. (lib/src/routing/magic_router.dart,doc/basics/routing.md) (#185)
Fixed #
- A
stacked()route owns the browser's address on the web.to()pushes a stacked route, and go_router reports a push under the address of the page beneath it unlessGoRouter.optionURLReflectsImperativeAPIsis on, so opening a detail screen left the address bar on the list and the page could not be copied, shared or reloaded. The router now sets the flag when it is built. go_router discourages it because a pushed URL is not always deep-linkable; every Magic route is a full path the router matches on its own, so the reported address always matches a route. Web only.MagicRouter.reset()puts the flag back to go_router's default, since it is a static that outlives the router. (lib/src/routing/magic_router.dart,doc/basics/routing.md,skills/magic-framework/) (#185)
0.0.19 - 2026-09-23 #
Fixed #
- A boot sync answering 401 during a sign-in no longer deletes the token the sign-in just wrote. 0.0.18's
startSessionandstoreTokenwrote both tokens to the Vault before the in-memory token moved. A 401 about the restored token landing between those writes saw an unchanged token and an unchanged session, so the sync logged out:clearTokens()deleted the new token, the sign-in then set its user, and the app looked signed in with nothing in the Vault, a guest again on the next cold start. The same window existed on the refresh path. A session opening or ending is now visible to an in-flight sync from its first line:startSessionandlogout()move a private session epoch before any await and the sync ignores its answer once it has moved, andstoreTokenmoves the in-memory token before its writes, so a refusal arriving mid-refresh is re-checked under the new token rather than read as a verdict on it. When the access-token write throws (a locked keychain, a missing entitlement), the in-memory token goes back to what it was before the failure propagates, so a token that was never stored does not ride on later requests; a failed refresh-token write keeps the new access token, which is already on disk. The epoch also closes #191: a sync 200 landing insidelogout()'s Vault deletes is no longer applied to the ending session, and one whose own cache write a sign-out overtook neither dispatchesAuthRestorednor leaves the user cached on disk. (lib/src/auth/guards/base_guard.dart,doc/security/authentication.md,skills/magic-framework/)
0.0.18 - 2026-09-23 #
Added #
BaseGuard.startSession(user, token:, refreshToken:)persists the tokens, then sets the in-memory token and the user in one synchronous step, then caches the user. The three built-in guards'login()use it, and a custom guard should too, in place ofstoreTokenfollowed bysetUser: between those two calls the guard held the new token under the previous account, and a boot sync answering in that window applied the previous account and dispatchedAuthRestoredfor it.storeTokennow also persists the refresh token before the in-memory token moves. (lib/src/auth/guards/,doc/security/authentication.md,skills/magic-framework/)
Changed #
- Since 0.0.17, a guard that does not extend
BaseGuardis logged out on a 401 only when the request carried the headerauth.token.headernames. Before 0.0.17 every 401 ran the refresh-or-logout ladder whatever the request carried. A guard outsideBaseGuardkeeps no token the interceptor can compare against, so it is now judged on presence alone, and a cookie-based guard, or one that sends its credential under a different header, stays signed in while its calls return 401. Such a guard has to end its own session. This entry is late: 0.0.17 described the fix but did not list it as a behaviour change. (lib/src/auth/auth_interceptor.dart,doc/security/authentication.md,skills/magic-framework/)
Fixed #
-
A sign-in made while the boot-time user sync is in flight is no longer undone by it.
restore()sets the cached user and fires the/usersync unawaited with the token restored at boot. When the viewer signed in before that sync answered, a 401 about the OLD token made_syncUserFromApicalllogout()itself, andclearTokens()deleted the token the sign-in had just stored: the new session was silently gone, even though 0.0.17's interceptor had correctly ignored the same 401. The mirror case was just as wrong: a late 200 ransetUserandcacheUserwith the previous account while the guard held the new account's token, and a late 200 after a sign-out put the user back. The sync now ignores its answer when a sign-in or a sign-out happened while it was in the air (both bumpstateNotifier), and a 401 or 403 under a token that has since been replaced is re-checked once under the current token, whose answer decides. A refresh is neither: it keeps the account, so a 200 under a rotated token is still applied, which is what signs in a cold start whose expired token the interceptor refreshed while retrying the sync itself; if the server refuses the refreshed token too, the re-check ends the session, as 0.0.17 did. (lib/src/auth/guards/base_guard.dart,doc/security/authentication.md,skills/magic-framework/) -
An upload retried after a token refresh sends its body again.
Http.uploadposts a DioFormData, which is single use: the retry re-sent the same instance, Dio threw on the secondfinalize(), the retry swallowed it, and the caller got the 401 back for an upload the refreshed token would have carried. The retry now sends a clone. (lib/src/auth/auth_interceptor.dart) -
A retry that yields nothing hands back the refused request as it was sent. The retry rewrote the refused request's own header map, so the error returned when it failed advertised a token that request never carried. It now builds a copy. (
lib/src/auth/auth_interceptor.dart)
Improvements #
- The interceptor's 401 handling is now exercised on a real socket. Every earlier interceptor test ran against
Http.fake(), whoseaddInterceptoris a no-op, so the reasoning about Dio's header map and the error it hands back was never tested. A loopback suite puts a realDioNetworkDriverandAuthInterceptorin front of a localHttpServerand covers a 401 on the current token, one on a token replaced mid-flight (the session is kept, and the request is handed back refused rather than replayed, since a rotation and a different account signing in look the same from the interceptor), an anonymous request, and an upload retried after a refresh. (test/auth/auth_interceptor_loopback_test.dart)
0.0.17 - 2026-09-23 #
Fixed #
-
A 401 no longer ends the session when the refused request carried no credential.
AuthInterceptor.onErrortreated every 401 as a verdict on the stored token, so a request that went out with no auth header at all still ran the refresh-or-logout ladder. On an app with no refresh endpoint that ladder is a straight logout.That is not a hypothetical ordering. Measured in a browser: an app opens a guest session on launch without awaiting it, and a second call made during the same bootstrap is dispatched BEFORE the login returns, so it carries no header. Its 401 comes back AFTER
Auth.loginhas already stored the token and set the user, and the interceptor then logged out the session that had just been opened. Every account-backed feature was dead for the rest of the process, the app looked signed out with a valid token on the server, and nothing said so: the interceptor's ownLog.warningis the only trace and it is not an error.The interceptor now asks whether the request presented the header
auth.token.headernames before it concludes anything, matching the name without regard to case: header names are case-insensitive, and Dio keeps the casing of a key's first insertion, so a caller that passedauthorizationitself still owns that key after the interceptor writes into it. A 401 on a request the server was never shown a credential for says nothing about the credential the guard now holds, and neither does one on a request that carried an OLDER token: against aBaseGuardthe presented value has to be the one the interceptor would attach now, so a refusal of the token restored at boot that lands after a sign-in stored a new one no longer ends the new session. A guard that keeps no token of its own is judged on presence alone. This is the sibling of the ruleBaseGuard._syncUserFromApialready applies to a transport failure: only the server may end a session, and only about a credential it actually saw. A token the server really did reject still ends the session, exactly as before, and the retry after a successful refresh now drops a differently cased copy of the header before writing the new token, so it no longer carries the refused token beside the fresh one. (lib/src/auth/auth_interceptor.dart,doc/security/authentication.md,skills/magic-framework/)
0.0.16 - 2026-09-22 #
Changed #
-
file_pickerwidens from^12.2.0to>=12.2.0 <14.0.0, so a freshpub getresolves 13. v13 removes the parameters v12 deprecated in its federated rewrite (allowMultiple,withData,withReadStream,readSequential,lockParentWindow,cancelUploadOnWindowBlur,androidSafOptions), andPickpasses none of them. The one change a consumer can see isPlatformFile.length(), nowFuture<int?>: a file whose size cannot be read answers null on 13 where 12 answered 0.MagicFile.sizewas already nullable, so nothing stops compiling; the size warnings onMagicFile.sizeandPick's converter now name both answers, and an upload-limit guard should treat null and 0 alike as unknown. Keeping 12 in the range leaves an app that pins it directly resolvable. (pubspec.yaml,lib/src/storage/magic_file.dart,lib/src/facades/pick.dart) (#181) -
The
fluttersdk_windfloor moves^1.6.2to^1.6.3. The old range already admitted 1.6.3, so a freshpub getresolves nothing differently; what changes is that the floor names the release this package is verified against. 1.6.3 makesbg-transparentand the other*-transparenttokens resolve for the first time, so a className that carried one as dead weight now paints it, and a checkedWCheckboxsheds its outline. (pubspec.yaml,CLAUDE.md) -
magic:install --with-devtoolswrites this batch's releases:magic_devtools ^0.0.6andfluttersdk_telescope ^0.0.7, withfluttersdk_dusk ^0.0.15unchanged. The installer map, the post-install message and the package doc move together, and the install test fails when the first two disagree. (lib/src/cli/commands/magic_install_command.dart,install.yaml,doc/packages/magic-devtools.md) -
The skill's plugin reference pages name this batch's releases, and the starter page covers what magic_starter 0.0.32 to 0.0.34 changed for an adopter: the compact rail, the content area's
contentClassNameandcontentScrollPrimary, the collapsible sidebar with its two translation keys, the centred compact brand, and the guest entriesRedirectIfAuthenticatednow lets through. Stamps: magic_notifications v0.3.4, magic_deeplink v0.1.3, magic_social_auth v0.0.5, magic_payments v0.0.4, magic_devtools v0.0.6, magic_starter v0.0.35; the floor prose in the notifications, devtools, payments and starter pages moves with them. (skills/magic-framework/) (#179, #180, #182, #183)
0.0.15 - 2026-09-21 #
Breaking #
-
A migration may no longer manage its own transaction.
DB.beginTransaction,commitandrollbackare a documented pattern elsewhere, so a migration written that way was legitimate before this. Acommit()insideup()closes the migrator's savepoint, which meant every later migration ran unprotected and the closingRELEASEthrew AFTER every migration had succeeded and committed its ledger row: the caller saw a failure from a run that fully worked, and the retry found nothing pending.rundetects it and throws naming the migration that broke the contract, rather than failing late and confusingly. That case is the one exception to "all of them, or none": the offending migration's statements and every earlier ledger row are already committed by the time the guard sees anything. (See theMigrator.runentry under Fixed for the atomic run this protects.) -
DB.transactionanswers a callback that closes the transaction itself, instead of failing confusingly or quietly. The two branches differ on purpose.On failure the rollback is skipped. There is a real error in flight and the only thing that matters is that it reaches the caller:
rollback()would find nothing to unwind and throwcannot rollback - no transaction is activeover the top, so the caller read a message about transactions in place of the cause.On success it now throws, naming what happened. Skipping the commit the same way would be the worse bug: there is no error to protect on that branch, so silence buys nothing and costs the signal. A callback that commits half way and keeps writing ran everything after that point outside any transaction, and returning normally tells the caller the block was atomic when it was not.
Added #
-
A
Urlvalidation rule. Laravel hasurl; this package did not, so every consumer validating a typed-in endpoint wrote the samestartsWith('http://')pair by hand and each drew its own conclusion about a scheme it had not thought of.'website': [Required(), Url()], // http or https 'webhook': [Required(), Url(schemes: ['https'])], // https onlyIt checks a scheme from its allowlist and a non-empty host, and nothing else: it reaches no network and resolves no host, because a rule answering a form field synchronously cannot know any of that. The allowlist is the security-shaped half:
Uri.parseacceptsjavascript:alert(1)andfile:///etc/passwdwithout complaint, both have a scheme and both parse cleanly. Whitespace is rejected before parsing, becauseUri.tryParse('http://exa mple.com')succeeds and percent-encodes the space into the host; Laravel'surlrejects that too. -
A
messagesoverride onFormValidator.rules, Laravel's thirdValidator::makeargument. A rule's message came from its own key and nothing else, so a screen wantingŞifre gerekli.rather than the catalogue's generic:attribute alanı zorunludur.had to abandon the rules and hand-roll a closure, which is what the rules exist to prevent.FormValidator.rules( [Required(), Url()], field: 'address', messages: {'required': 'provider.error.address_required'}, )The value is a KEY rather than a finished sentence: an override taking a sentence would make every consumer using it monolingual. Rule parameters still reach it, so
:attributeand:schemeswork in an override.Keyed by the new
Rule.name, which is derived from the rule's message key (validation.requiredgivesrequired) rather than fromruntimeType. That choice is load-bearing:runtimeType.toString()is not a dependable identifier in a release build, so a messages map keyed on it would match in development and silently stop matching in production. dart2js minifies class names (js_helper.dart:107has aMINIFIEDbranch for reporting them), and Flutter's own framework declines to use it outside asserts for the same reason (foundation/object.dart,objectRuntimeType). -
Rulehas a const constructor, andRequired,Email,AcceptedandUrldeclare one. Additive; a rule with its own non-const constructor is unaffected. A stateless rule is written inline in a widget'sbuild, where a const instance is one allocation that never happens again. -
transChoice()/Lang.choice(), the pluralization Laravel callstrans_choice. A sentence carrying a number had one wording at every count, becauseLang.getis a map lookup plus areplaceAllper parameter and nothing parsed a pipe.What makes that worth a release rather than a nice-to-have is which apps it bites. Turkish, Japanese, Korean, Chinese, Indonesian, Vietnamese and nine more have no plural agreement after a number, so a catalogue written in one of them renders correctly at every count and reveals nothing. The defect appears the first time a SECOND locale is drawn, by which point every counted sentence in the app has been written without a plural form. Found exactly that way in a consumer app whose first locale is Turkish: its English build said "No schedule for 1 channels".
{ "apples": "There is one apple|There are :count apples", "inbox": "{0} Nothing here|[1,19] :count messages|[20,*] Lots of messages" }transChoice('apples', 1); // "There is one apple" transChoice('apples', 4); // "There are 4 apples" transChoice('inbox', 0); // "Nothing here":countis substituted from the number without the caller passing it, and an explicitcountinreplacestill wins. A key with no sentence answers the key, which isget's own contract. -
MessageSelector, exported. A direct port of Laravel'sIlluminate\Translation\MessageSelector, so a catalogue written for a Laravel backend renders the same sentences on the client. Both shapes are supported and compose in one line: positional segments, and inline{0}/[1,19]/[20,*]conditions.The 15 plural-rule groups covering 280 locale strings are ported from Laravel's
getPluralIndexmechanically, by reading the PHP rather than transcribing it: one mistyped language code is a language that silently renders the wrong sentence, and nothing downstream would catch it. The rules themselves carry Laravel's own Zend Framework attribution.The index is the CURRENT locale's, never English's. A two-segment line in a language with one form never reaches its second segment, and one in Russian or Arabic falls back to segment 0 rather than throwing a
RangeErrorinto a consumer's UI.
Changed #
-
flutter_secure_storagewidens from^10.0.0to>=10.0.0 <12.0.0, so a freshpub getresolves 11. v11 removes what v10 deprecated (on Android theencryptedSharedPreferencesandsharedPreferencesNameoptions, the PKCS1 key cipher and the CBC storage cipher), andMagicVaultServicepasses none of them. Its warning that data written with a deprecated cipher becomes unreadable is for an app that skipped v10: every magic release on pub.dev has required^10, so a vault written through magic is already on the v10 ciphers. v11 also raises the AndroidminSdkto 24, which every Flutter release since 3.35 already requires; magic's floor is 3.41. Keeping 10 in the range leaves an app that pins it directly resolvable. (pubspec.yaml) -
magic:install --with-devtoolswrites the constraints its own post-install message documents. The installer pinnedmagic_devtools ^0.0.1,fluttersdk_dusk ^0.0.8andfluttersdk_telescope ^0.0.4into the consumer's pubspec while the message printed beside it said^0.0.2,^0.0.9and^0.0.4. Both now name this batch's releases,^0.0.5,^0.0.15and^0.0.6, and the install test reads the message and fails when the two disagree, so a release cannot move one copy without the other. The old carets already admitted the new releases; what changes is that a fresh install names the ones it is verified against. (lib/src/cli/commands/magic_install_command.dart,install.yaml,doc/packages/magic-devtools.md)
Fixed #
-
Migrator.runwas not atomic, and the state that produced is a host that never boots again. It applied and recorded each migration in turn with no transaction anywhere, so a failure part way left that migration's earlier statements applied and its ledger row absent. The next launch re-ran it from its first statement, met the table it had already created, and failed identically. A host that migrates insideMagic.initbeforerunApphas no UI to report that from, and the only repair is deleting the database.The whole run is one transaction now, not each migration: a ledger recording one migration and not the next describes a schema nobody designed, and the host cannot learn which half it has. SQLite rolls DDL back like anything else, so this is one
BEGIN.A
SAVEPOINTrather than aBEGIN, which is what lets it nest inside a transaction the host opened itself. Verified in all three shapes: with no transaction open, inside one, and unwinding throughROLLBACK TO.The tracking table is created before the savepoint, so a run that owns its transaction and fails still leaves somewhere to record the retry. A host that wrapped the call itself and rolls back takes the table with it, which is harmless because every entry point creates it again. The schema cache is cleared on the rollback path too, so nothing cached during an undone migration survives to describe schema that no longer exists.
-
Every migration could be applied and silently never recorded.
_ensureMigrationsTablecreates the ledger with a rawexecute, whichDatabaseManagernever hears about, andgetColumnscaches the EMPTY answer a missing table gives._recordMigrationgoes throughQueryBuilder, which filters every key against that cache, and an empty filter makesinsertreturn 0 without inserting and without throwing. So anything that read the ledger's columns before it existed left every migration re-running on every launch for ever. OneclearSchemaCacheafter the create. Latent rather than observed: no caller in the wild was found reaching it. -
Migration.up()anddown()are documented as synchronous, becausevoid up() asynccompiles and is a silent defect:runcannot await avoid, so an async body is recorded complete the moment it reaches its first suspension. The doc block names the synchronous alternatives and says whyDatabaseManager().hasColumnmust not be called from a migration. -
A translation catalogue loaded nothing, silently, whenever no
logservice was bound.JsonAssetLoader._loadJsonopened withLog.info('Loading translation file [...]')before it read anything, andLogresolveslogthrough the container, which throws for an unbound key (foundation/application.dart:269-274).load's own catch then turned that throw into an empty map, so every key rendered as itself with nothing anywhere to read.Reported from a consumer app whose test suite could not assert a single translated sentence. Measured there:
rootBundlereads the asset fine (19,345 bytes),Translator.loadreportsloaded: truefor the right locale, and the loader still answers zero keys.Translator._loadFallbackForhad already conceded the point one caller up, where the sameLogcall is wrapped inif (Magic.bound('log'))with a comment naming this exact case: "a widget test that buildsMaterialAppthroughLangDelegatewithout a fullMagic.init()". The loader had no such guard.The line is removed rather than guarded. A loader that reads a file should not need a service to do it, nothing consumed the line, and it fired twice per
Translator.load(once for the locale, once for the fallback).loadnow says when it gives up. It answered an empty map for every failure with no trace, which is how this defect survived: a missing asset and a missing key look identical on screen. Bothreturn {}paths log a warning naming the path and the error, guarded onMagic.bound('log')for the same reason the line above was removed. (lib/src/localization/loaders/json_asset_loader.dart,test/localization/json_asset_loader_without_log_test.dart,doc/digging-deeper/localization.md)
0.0.14 - 2026-09-19 #
Breaking #
-
An unresolvable route middleware stops the app at
Magic.initinstead of silently ungating the route. Two changes, and the second is the one that does the work.Kernel.resolveAllended in.whereType<MagicMiddleware>(), so a route declaringmiddleware: ['auth']against a Kernel that never received anauthalias rendered with NO gate on it and reported nothing anywhere. A missing gate lets everybody through, which is the one failure mode that must not be quiet. It throws aStateErrornaming the entry now.That alone was not enough, and measuring is what showed it.
resolveAllruns at navigation, fromMagicRouter._handleRedirect, which is GoRouter'sredirectcallback: GoRouter routes a throw from there toonException. Measured against a realMaterialApp.router, the page rendered nothing, the log saidRoute not found: /, andtakeException()returned null. One silent failure traded for another, wearing a misleading message.So
MagicRouter._buildRoutervalidates every registered route's middleware before it constructs theGoRouter, and throws oneStateErrorlisting every offending route with its path. That lands insideMagic.init, where nothing catches it.Kernel.unresolvablebacks the check and constructs nothing, so validating a whole route table calls no factory and fires no side effect.Every registered route means the ones inside a layout too, which took a second pass to get right. A route declared in
MagicRoute.group(layout: ...)is diverted into the layout's child list and never reaches the top-level table, so a check that walked only that table left every route under a shell or tab layout exactly as ungated as before:_resolveRoutestill finds it at navigation, so the throw still landed inonExceptionand still said nothing useful. A shell layout is where a gated screen usually lives, which made it the wrong half to miss. The check walks both lists now, deduplicated by identity becauseMagicRoute.layout(routes: [...])registers its pages in both.onExceptiongoes with it: it reportedRoute not foundfor every exception GoRouter handed it, including one thrown by the redirect callback. It names the underlying error when there is one.Found by a consumer app,
watchools, whose everymagic_starterroute was ungated between installing the package and registering the three aliases its installer does not write. Nothing failed, nothing logged, and the screens rendered.Breaking for an app that currently relies on an unregistered alias being ignored, which is the behaviour this removes on purpose; that app now fails to boot until it registers or removes the alias.
Kernel.resolveis untouched and still answers null for a single entry. Permitted pre-1.0 and recorded here rather than left to be discovered at runtime. (lib/src/http/kernel.dart,lib/src/routing/magic_router.dart,test/http/kernel_resolve_test.dart,test/routing/middleware_resolvable_at_build_test.dart,doc/basics/middleware.md,skills/magic-framework/SKILL.md)
Added #
-
A
FakeNetworkDriverstub may answer asynchronously.FakeRequestHandlerwidens fromMagicResponse Function(MagicRequest)toFutureOr<MagicResponse> Function(MagicRequest), and_handleawaits it. Source-compatible: every synchronous stub keeps compiling and behaving.One behaviour change to know about in a consumer test suite.
recorded.addis now deferred by at least one microtask, where it used to happen synchronously inside theget/postcall: the old_handlewas sync and anasyncbody runs straight through to itsreturnbefore yielding, whileawaiton a non-Future still schedules a microtask. A test that fires an unawaiteddriver.get(...)and assertsassertSentCount(1)in the same synchronous block passed before and fails now;awaitthe call. Nothing in this repo's suites does it. The no-stub default path is unaffected, since it never awaits.What it admits is a test that needs a request to stay OUTSTANDING while the caller does something else, which is the only way to script a concurrency case. A consumer app could not test "a sign-out arrives while the panel handshake is still in the air" at all, and had to reach the same guard through a code path that happens to await nothing before its first write. A stub that must answer before it returns cannot open that window. (
lib/src/network/drivers/fake_network_driver.dart,test/network/fake_driver_async_stub_test.dart,doc/testing/http-tests.md)
Changed #
- The sibling floors name this batch's releases.
fluttersdk_windgoes^1.6.1to^1.6.2andfluttersdk_artisan^0.0.9to^0.0.16. Both old ranges admitted the new versions, so nothing resolves differently on a freshpub get; what changes is that the floors say which releases magic is verified against.CLAUDE.md's stack line was quoting^1.5.3and^0.0.9, the first of those already two minors stale before this bump, and it is corrected here. (pubspec.yaml,CLAUDE.md)
Fixed #
-
A navigation issued before the
Routerwidget has parsed a location no longer pushes onto an empty base. go_router pushes ontorouterDelegate.currentConfiguration, which isRouteMatchList.emptyuntil the widget mounts, and itsuriis a bareUri()with an empty path that every later match then reads. On go_router 17.3.0 the matcher throws aRangeErroron it and a release build replaces the wholeRoutersubtree with anErrorWidget; on 18.0.1 there is no crash, butcurrentLocation,pathParameterandqueryParameterall answer from an empty uri. BothMagicRoute.to()andMagicRoute.push()nowgoin that window.A
.stacked()route navigated to before the router mounts therefore replaces rather than pushes. Nothing is lost: there is no page underneath to pop back to yet, and a link arriving from outside the app is where the reader arrives.MagicRoute.push()also gains theStateErrorits four sibling verbs already throw when the router has never been built. It died on a null-check with a message naming nothing, which is the same "the error points at the wrong thing" failure the rest of this entry is about, and the two statespushcan be in at cold start should not report differently. -
A translation key missing from the current locale is served from the fallback, instead of rendering as its own dotted path.
Translator.loadnow reads the fallback catalogue alongside the locale's own and layers the locale over it, so the merge happens once at load rather than on every lookup.This is a defect against this package's own documentation rather than a missing feature:
doc/digging-deeper/localization.md:155has always saidtrans()"falls back tofallback_localeif not found". It did not.getanswered_sentences[key] ?? key, and the only fallback anywhere wasJsonAssetLoader's, which is whole-FILE (lib/src/localization/loaders/json_asset_loader.dart:58-75): it reads the fallback catalogue when the requested one fails to load at all, and never when the requested one loads and is simply incomplete.Measured in a consumer app whose
tr.jsoncarried 12 of the 357 keys itsen.jsonhad: all 345 others rendered on screen asmagic_starter.auth.login.titleand the like. The app closed it by hand-translating every key, which is the work this makes unnecessary.The recovery path guards on
Magic.bound('log')before it warns.Log.warningresolveslogthrough the container, which THROWS for an unbound key (lib/src/foundation/application.dart:269-274), so an unguarded warning defeated the whole point of the catch: a widget test buildingMaterialAppthroughLangDelegatewithout a fullMagic.init(), or a locale switch beforeLogServiceProviderboots, took the exception straight out ofload().Both catalogue reads start before either is awaited, so a locale with a fallback pays one round trip rather than two. The spread order decides precedence and is unaffected by completion order.
The merge lives in
Translatorrather than in the loader deliberately: this class ownsfallbackLocale, so everyTranslationLoadera host app writes gets the behaviour without knowing about it. Loading the fallback locale itself still reads one file. A fallback that will not load logs a warning and answers empty rather than throwing, because it is a courtesy and never a requirement: the locale's own strings must not go down with it. (lib/src/localization/translator.dart,test/localization/translator_key_fallback_test.dart,doc/digging-deeper/localization.md)
0.0.13 - 2026-09-17 #
Improvements #
plugin-starter.mdcovers the routesmagic_starter0.0.29 starts pushing. Eleven of them are.stacked()now (the eight settings spokes, both team screens, both notification screens) and three groups deliberately are not (the settings hub, the invitation arrival, the six auth routes), which is a distinction an agent registering a route beside them has to be able to make. The entry also records that a stacked route names NO transition, so it takesMagicRouter.defaultTransitionrather than pinningnone, and that themagicfloor is^0.0.12there becauseRouteDefinition.stacked()exists in no release below it AND 0.0.12 is where a routed page stopped being transparent, which is the defect stacking exposes. The stamp moves tov0.0.29, which that package's ownskill_reference_stamp_test.dartcompares its pubspec against, in CI as well as locally: its release PR was red on exactly this. (skills/magic-framework/references/plugin-starter.md,skills/magic-framework/SKILL.md)
0.0.12 - 2026-09-16 #
Breaking #
RouteTransitiongains a value,platform. Source-breaking for a consumer with an exhaustiveswitchover the enum: that switch stops compiling until the new case is handled. The value is inserted afternonerather than appended, so every later value'sindexshifts by one; nothing in the ecosystem persists an enum index and nothing should, since a persistedindexbreaks on any insertion. The default is untouched, and every existing value means what it meant. Permitted pre-1.0 and recorded here rather than left to be discovered at the compiler.test/routing/router_test.dartnow asserts the whole inventory in order instead of containment, which is why this entry exists at all: the old assertion passed with the new value missing from it. (lib/src/routing/route_definition.dart,test/routing/router_test.dart)
Fixed #
-
A routed page paints an opaque background, so the page under a transition stops showing through it. The wrapper was
Material(type: MaterialType.canvas), which paintsTheme.canvasColor(material.dart:460), andfluttersdk_windsets that toColors.transparenton purpose (wind_theme_data.dart:514) so a Material surface never paints over a Windbg-*className. Every magic app themes through wind, so every page has been transparent for as long as_buildPagehas existed, while the comment on that line claimed the opposite.Nothing could show it until routes started stacking.
to()callsgo(), which replaces the whole page list, so there was never a second page underneath to show through. A.stacked()route puts one there, and the outgoing page is then visible THROUGH the incoming one for the length of the push: on iOS it sits at the Cupertino parallax offset with the new page drawn over it, and disappears only when the animation ends and the Navigator offstages the route below an opaque one. Reported off a TestFlight build as the old screen stopping half-way across the display with the new one on top of it.Measured rather than inferred:
Theme.of(context).canvasColorreadsalpha 0.0in a running wind app whilescaffoldBackgroundColorreads opaque, which is why the fix takes the latter. An explicit color also stopsMaterialType.canvasconsulting the theme at all. A host that makesscaffoldBackgroundColortransparent is saying its pages are transparent, which is a choice rather than an accident.The floor on
fluttersdk_windmoves to^1.6.1with this, and it is a floor for a VALUE rather than for an API. Painting that field is what makes its value matter, and wind filled it from its own white and gray-900 rather than from thebg-surfacealias an app paints its canvas with until 1.6.1. On 1.6.0 this release paints#FFFFFFover#F9FAFBin light and#111827over#07090Cin dark, on every screen, and the dark one is not subtle. Nothing fails to compile below the floor, which is exactly why it is declared rather than left to resolve.Two things for an adopter to check, because this is the first release in which the page background is painted at all.
Set wind's
backgroundcolor if your pages have a canvas colour.scaffoldBackgroundColoris what wind fills from it (wind_theme_data.dart:511) and it is Flutter's own name for the colour behind a page, but an app that never set the key gets wind's fallback: pure white in light mode,gray900in dark. An app whose canvas comes from abg-*className alias instead has been painting that colour on a widget ABOVE the Navigator, so the two can differ and the page now wins. Measured on one consumer: the alias resolved to#F9FAFBwhilescaffoldBackgroundColorwas#FFFFFF.A LAYOUT that paints its own background behind the child slot is covered over for the same reason, since the page sits inside the shell. Harmless when the layout paints the same colour, visible when it paints a gradient or an image. Paint it outside the child slot, or set
scaffoldBackgroundColortransparent to opt the pages back out and accept the transition overlap.
Added #
-
MagicApplication.pageTransitionsTheme, so an app can say whatRouteTransition.platformlooks like. That transition deliberately has no animation of its own: its page mixes inMaterialRouteTransitionMixin, which readsTheme.of(context).pageTransitionsTheme, so leaving this unset gives every platform the animation its own operating system uses. That default is the reason to reach forplatformat all and most apps should keep it. What had no seam was overriding it: theThemeDatacomes from wind'sWindThemeController.toThemeData()andMagicApplicationpasses it straight toMaterialApp, so an app that wanted a different animation on one surface had nowhere to say so. A per-routeRouteTransitioncannot express it either, because it is chosen once at registration and the common case is exactly the opposite shape: keep the mobile builds' native animation, give the desktop or web build none.Applied with
copyWith, so the theme stays wind's. The colors, the typography and the component defaults are untouched and a null value changes nothing at all.A partial map is a partial override, not a reset. A platform left out of
builderskeeps its own default: Flutter answers an unnamed platform withCupertinoPageTransitionsBuilderon iOS andZoomPageTransitionsBuildereverywhere else (material/page_transitions_theme.dart:881-889), so omittingTargetPlatform.iOSleaves the Cupertino slide and its back gesture in place. This paragraph said the opposite before it was checked against the Flutter source, and namedFadeUpwardsPageTransitionsBuilder, which is not in that switch at all.RouteTransition.fade,slideRightand the rest build their own animation explicitly and never consult the theme, so none of this reaches them. -
A
User-Agentthat names the app and its platform, sent by default. Dart's HTTP client sendsDart/<sdk> (dart:io), which says nothing about the app and is identical across every Flutter client a backend has, so anything a server derives from the agent answers WRONGLY rather than partially. Found on a session list: the backend's user-agent parser matched no browser and no platform, defaulted the device to desktop, and a phone's own session rendered as a browser session on an unknown machine beside a laptop icon.NetworkServiceProvidernow composes<App Name> (Flutter; <platform>)fromapp.nameanddefaultTargetPlatform, with the platform in the casing Apple and Google use so a server can match it without normalising. No version: nothing here knows the app's build number, and a config key an adopter has to fill in would leave the useful half empty in most apps. Skipped on WEB, whereUser-Agentis a forbidden header name forXMLHttpRequest, so the browser drops it and sends its own, which is the right agent there anyway. A host that already sets one innetwork.drivers.api.headerskeeps it, matched case-insensitively, since HTTP header names are case-insensitive and a host writinguser-agentwould otherwise have sent two agents on one request.The name is folded to header-safe ASCII before it goes on the wire, and that is load-bearing rather than tidy.
dart:iorefuses any header value carrying a byte above 127 and throws aFormatExceptionfromHttpHeaders.set, which Dio surfaces as aDioException, so an app whoseapp.nameholds an accent would have lost EVERY HTTP request because of its display name: measured,Şirket TakipandCafé Münsterboth throw. Accented Latin letters fold to their base letter rather than being dropped, since dropping leavesirket Takipwhere the adopter would have picked a plain name; the table covers every letter in Latin-1 Supplement and Latin Extended-A, so Turkish, German, French, Spanish, Nordic, Polish, Czech and Dutch names survive legibly, and a test walks both ranges so the claim checks itself rather than being maintained by hand. A script with no Latin base is dropped and the agent falls back to the config default, which isMagic App, matching whatlib/config/app.dartships. The same pass closes the injection shape, since a carriage return or newline is below 0x20 and cannot survive it. A test drives the realHttpClientwith each of these names, because a string comparison cannot prove the thing that was actually broken. (lib/src/network/network_service_provider.dart,doc/basics/http-client.md,skills/magic-framework/references/http-network.md,skills/magic-framework/SKILL.md,test/network/default_user_agent_test.dart) -
RouteTransition.platformandRouteDefinition.stacked(), because a magic app has no back gesture and, on Android, no working back button. Two gaps that turn out to be one. No route has ever carried the iOS left-edge swipe, since go_router'sCustomTransitionPagebuilds a barePageRouteand Flutter installs the swipe detector INSIDECupertinoPageTransitionrather than beside it; a customtransitionsBuildernever reaches it. Andto()callsgo(), which replaces the Navigator's whole page list, so ato()-only app has nothing to pop even where the detector exists.The second half is worse than a missing gesture. Flutter reports
canHandlePopto the platform on every navigation, with one page it reportsfalse, and the Android embedder responds by unregistering itsOnBackInvokedCallback: the system back button then LEAVES THE APP rather than going back. Measured through the platform channel rather than reasoned about, and pinned by a test that reads it: eightsetFrameworkHandlesBack(false)across twoto()calls,trueonly after a push.RouteTransition.platformroutes throughPageTransitionsTheme, so one page type answers every platform: the Cupertino slide and its swipe on iOS and macOS, predictive back on Android (already the framework's own default builder), the zoom on Windows and Linux..stacked()makesto()push instead of replace. Navigating to the path you are already on turns on the query rather than the path: naming none is a nav destination re-tapped and does nothing, since pushing would stack a screen on itself and replacing would throw away the stack the reader built getting there, while naming one is a real move and swaps the top page. That swap REBUILDS the screen rather than remounting it, which is what a query change already does everywhere in Magic: go_router keys a page on the matched path and the query is not part of it, measured on an ordinary unstackedgo().pushReplacementwould have remounted instead, and only where something sits underneath, since it falls back to the declarative list when the stack would empty; one verb behaving two ways by stack depth is worse than every verb behaving one way. What it costs is that a screen has to read its query where a rebuild can see it, sodoc/basics/routing.mdnow says to read it inbuild()and never ininitState(), and not to register the page as aconstwidget, which does not rebuild at all.back()is untouched and still prefers the native pop, so the history fallback keeps covering every route that is not stacked.Both are opt-in per route, with
MagicRouter.defaultTransitionanddefaultStackedfor an app that wants one answer everywhere.RouteTransition.nonestill builds aNoTransitionPage: it is the right answer on web, wherego()already produces a working browser Back, and changing it would put a slide animation on every screen change in every existing app. Its dartdoc claimed to be the platform transition and never was; that is corrected rather than implemented.A side menu needs no arbitration with the swipe, though both live on the left edge, and no code was written for it:
popGestureEnabledis false on a route with nothing under it, so the detector never enters the gesture arena on a drawer's own screen, and on a pushed route the deeper recognizer wins the arena. A test pins the first half.swipeBack(false)refuses the gesture alone;PopScoperemains the tool for "this route should not be left yet", and Flutter's gesture already honours it.toNamed()answers the same way asto(). It used to go straight to go_router'sgoNamed()and never ask whether the route stacks, so one route pushed by path and REPLACED by name: no back gesture, and Flutter reportingcanHandlePop: falseso Android's system back left the app. Nothing at the call site said the verb decided that, and neitherstacked()'s dartdoc nor the routing doc qualified the verb. It now resolves the name to a location and hands it toto(), so the stacking decision, the same-screen rules and the history record are all the onesto()already applies. Two doc notes moved with it: both said the history stack is populated byto()andtoNamed(), which was already only true of an UNSTACKED route (the push is its own record, and an entry there would make the nextback()look like a press that did nothing) and is now the answer for named navigation too. (lib/src/routing/magic_platform_page.dart,lib/src/routing/magic_router.dart,lib/src/routing/route_definition.dart,lib/magic.dart,doc/basics/routing.md,example/lib/routes/app.dart,skills/magic-framework/references/routing-navigation.md,skills/magic-framework/SKILL.md,test/routing/platform_back_test.dart)
Improvements #
-
plugin-starter.mdcovers what 0.0.28 changes for an adopter. Four of them are contract rather than cosmetics, and the reference said none. The floors move:magic_notifications ^0.3.2for thecontentClassNamethe notification routes now pass, andfluttersdk_wind ^1.6.0declared DIRECTLY rather than taken throughmagic, because a transitive constraint leaves nothing to raise when the starter calls a new Wind API and the adopter's symptom is thenundefined_named_parameterinside a package they do not own.MSAvatarjoins the component table (39, not 38) with the one thing a caller gets wrong: aflexin its className starves the photo, which is the defect the component shipped with. The timezone select pages through its endpoint and resets its cursor throughWSelect.onOpen. Andprofile.unknown_deviceis a key an upgrading app adds by hand, like the fivenotifications.*keys beside it, or a session with an unreadable agent renders the raw key. The stamp moves tov0.0.28, whichmagic_starter's ownskill_reference_stamp_test.dartcompares its pubspec against whenever a sibling checkout exists. (skills/magic-framework/references/plugin-starter.md,skills/magic-framework/SKILL.md) -
plugin-notifications.mdcovers the APNs entitlement the installer now writes.magic_notifications0.3.1 stops warning about the Release build'saps-environmentand writes it:notifications:installproducesRunner.entitlementswithdevelopmentandRunnerRelease.entitlementswithproduction, points Release at the second and leaves Debug and Profile on the first, because Apple makes that value a property of the build configuration and one file cannot serve a development and a distribution profile at once. The reference said none of this, and the failure it prevents is invisible before TestFlight: an app exported against the development value registers a sandbox APNs token the production app can never deliver to. Also recorded: configurations are matched by BASE name so a flavoured project is covered rather than declined, a configuration that is none of the three is left untouched and named in a warning, andnotifications:uninstallreverts neither file by design. The stamp moves tov0.3.1and the requirement line now namesfluttersdk_artisan ^0.0.15, which is the release carrying thesetEntitlementsPathsop the install depends on. (skills/magic-framework/references/plugin-notifications.md,skills/magic-framework/SKILL.md) -
plugin-notifications.mdcarriescontentClassName, the parameter 0.3.2 adds to both screens. Both constructors are listed in that file and neither named it, which is the failure mode this reference exists to prevent: an agent mountingNotificationsListViewinside a host that owns its page geometry had no way to learn that the two paddings apply to the same edge, and the symptom is a page sitting twice as far from the display as its neighbours rather than anything that errors. The entry says what to pass (''to hand the geometry to the container, or a className of your own), and why it could not have been a wrapper: the padding is on the content column inside the scroll view, so the page surface stays full bleed behind the scroll. The stamp moves tov0.3.2, which is whatmagic_notifications' ownskill_reference_stamp_test.dartcompares its pubspec against whenever a sibling checkout exists. (skills/magic-framework/references/plugin-notifications.md,skills/magic-framework/SKILL.md)
0.0.11 - 2026-09-12 #
Improvements #
plugin-starter.mdfollowsmagic_starteroff the alpha rail. That package's next release is0.0.27rather than0.0.1-alpha.27, so the reference stamp moves with it and the release markers inside the file now read0.0.27where they described that release. Themagic_notificationsrequirement it states moves to^0.3.0, which is the floor the starter release carries, and the notification section gains the fivenotifications.*keys the mounted screens read and no package supplies (bulk_title,bulk_description,delete,delete_failed,channel_sms): an adopter upgrading with a hand-written catalogue sees each of them rendered as its own key. The configuration section also gainsnotifications.external_id_prefix, which 0.0.27 introduced: the provider now declares<prefix><user id>as the push external id offAuth.stateNotifierand the value has to equal the backend's own, since OneSignal accepts a mismatch and delivers to nobody. This file is the only agent-facing document for that package, since.pubignorekeeps itsCLAUDE.mdout of the published archive, andmagic_starter's ownskill_reference_stamp_test.dartfails against a stale stamp whenever a sibling checkout exists. (skills/magic-framework/references/plugin-starter.md,skills/magic-framework/SKILL.md)
0.0.10 - 2026-09-11 #
BREAKING #
-
If you pinned
fluttersdk_windbelow 1.5.3, Return no longer moves the focus to the next field. The Wind floor moves to^1.5.3(see the### Changedentry for what that buys), and the span crosses Wind 1.4.0, which made a single-lineWInputdefault its Return key toTextInputAction.doneinstead of.next. WritetextInputAction: TextInputAction.nexton every field in a multi-field form that relied on the old default. Nothing in magic's own API changes, which is why the constraint itself is filed under Changed; this line exists so the fix is where you would look for it. (pubspec.yaml) -
file_pickermoves from>=11.0.2 <12.0.0-0to^12.2.0, and thePickfacade moves with it. v12 splits the plugin into federated platform packages and rewrites the surface magic wrapped:pickFilesreturns a plainList<PlatformFile>instead of a nullableFilePickerResult,saveFilereturns aUri?instead of aString?, andPlatformFileloses itssizeandbytesfields in favour oflengthSync()andreadAsBytes(). None of that is expressible in a version range spanning both majors, which is why the constraint moves to^12rather than widening. (pubspec.yaml,lib/src/facades/pick.dart) -
Pick.saveFilereturnsFuture<Uri?>and takesfileNameandbytesas required arguments. The old signature accepted both as nullable and threw anArgumentErrorat runtime when either was missing, which was a guard against a mistake the type system can catch on its own;requiredmoves that failure from the user's device to the compiler. The return type follows file_picker rather than flattening it: v12 documents the scheme as possiblyfile,content,http(s),dataorblob, so aStringwould hand backcontent://com.android.providers.downloads/42to code that was written to open a filesystem path. Calluri.toFilePath()after checkinguri.scheme == 'file', and treat the rest as opaque handles. (lib/src/facades/pick.dart) -
withDatais gone fromPick.fileandPick.files. file_picker 12 deprecated the parameter and stopped forwarding it, so it had already become a no-op. Bytes now come offMagicFile.readAsBytes(), which reads throughPlatformFile.readAsBytes()on first call and caches the result. Behaviour is unchanged for anything that reads bytes, and better for anything that does not: picking twenty files no longer pulls twenty files into memory to look at their names. Drop the argument from the call site; there is nothing to replace it with. (lib/src/facades/pick.dart) -
package:magic/magic.dartre-exports five names fromfile_pickerinstead of the whole library:FilePicker,FilePickerStatus,FileType,IllegalCharacterInFileNameExceptionandPlatformFile. v12 re-exports its platform interface, which brings four option classes (AndroidOptions,LinuxOptions,WebOptions,WindowsOptions) whose names are already taken byflutter_secure_storagein the same barrel; that is anambiguous_exporterror in magic itself, so every consumer app fails to compile rather than only the ones that pick files. Ashowlist settles it and keepsFilePickerPlatformand the method-channel plumbing out of magic's public API at the same time. Importpackage:file_picker/file_picker.dartdirectly to configure per-platform picker options. (lib/magic.dart)
Added #
-
MagicVaultServicetakesmacOsUsesDataProtectionKeychain, so a macOS build with no signing identity can store a secret at all. macOS has two keychains and the service previously passed nomOptions, which left it onflutter_secure_storage's default of the data protection one (macos_options.dart:24). That keychain requires thekeychain-access-groupsentitlement, the entitlement is restricted and therefore forces a signed build with an App ID, and on a build with no certificate installed every write fails withPlatformException(-34018, errSecMissingEntitlement); measured on a consumer app's own build. Passingfalsemoves the items to the legacy login keychain, which needs no entitlement. Note thatfalsedoes not setkSecUseDataProtectionKeychainto false, it OMITS the key from the query entirely (flutter_secure_storage_darwinFlutterSecureStorage.swift:227-231, guarded byparams.usesDataProtectionKeychain), which is the mechanism by which the item lands in the other keychain rather than a flag the keychain reads.The default is
true, which is what every existing build already does, and it stays that way because there is no migration between the two keychains in either direction. An item written to one is invisible from the other, and every caller reads that miss as "never stored" rather than as an error:Crypt.encryptWithDeviceKeygenerates a fresh device key on a null read (crypt.dart:141-147), making everything encrypted under the old one permanently unreadable while the old key sits unreachable in the other keychain, andBaseGuardloses the stored token the same way and logs the user out. Flip the key once, before the app has stored anything; do not reach for it to clear a-34018on a build that has been storing secrets, and readdoc/security/vault.mdfor how to move existing items across.VaultServiceProviderreads it fromsecurity.vault.macos_data_protection_keychain, inside the singleton factory rather than beside it, so a test or a hand-registered provider that sets the key afterwards is still read. macOS also now getsfirst_unlock_this_deviceaccessibility, rather than the package defaultunlocked(apple_options.dart:72), for the same reason iOS already passesfirst_unlock: a read while the screen is locked, such as a refresh on launch before anyone has touched the machine, succeeds under the former and fails under the latter, and theThisDeviceOnlyhalf keeps the item off a backup restored onto a different Mac.kSecAttrAccessibleis a data protection keychain attribute, so that class is inert once the key above isfalse. The iOS and Android options are unchanged. (lib/src/security/magic_vault_service.dart,lib/src/security/vault_service_provider.dart) -
FakeVaultService.throwOnGet,.throwOnPut,.throwOnRemoveand.throwOnFlush, so a consumer can test its own vault-failure branch. Every prior override was a no-throw body over an in-memory map, so nothing exercised the path a real keychain failure takes throughMagicVaultService, which wraps aPlatformExceptionasMagicVaultExceptionon all four operations.throwOnRemoveis the one a sign-out needs: a consumer that deletes the stored credential first and clears its in-memory session afterwards has two branches through one keychain call, and the failing one decides whether the user is told the secret is still on the device or is shown a sign-out that did nothing. All four default to aMagicVaultExceptionand accept a custom error, all four are cleared byreset(), and each only affects its own operation: a throw armed ongetdoes not touchput, and one armed onremovedoes not touchflush. The throw is armed for every key, so a test needing one key to fail while its neighbours succeed still needs its own subclass; a key filter would have to record the attempt before the throw, and that would makeassertWrittenpass for aputthat threw. (lib/src/testing/fake_vault_service.dart) -
MagicSelector<C, T>rebuilds one subtree when one part of a controller changes.refreshUI()notifies every listener andMagicStatefulViewStateanswers withsetStateon the whole view, which is the right default and stops being cheap on a screen where one field changes often and most of the screen does not care: a consumer measured one keystroke in a search field rebuilding 220 styled containers.MagicBuildercould not help, because it needs aValueListenableand a controller is aChangeNotifier. The selector caches the widget its builder returned and, while the selected value compares equal, returns that same instance, soElement.updateChildshort circuits onchild.widget == newWidgetand never descends. Returning an identical instance rather than skipping asetStateis what makes it work under a parent that rebuilds anyway. Two rules follow:buildermust be a pure function of the selected value (select a record to watch several fields), and equality is plain==, so a selector returning a freshly builtListnever matches its own cache. Deep comparison is deliberately not used, because walking a ten thousand element list per keystroke costs more than the rebuild it prevents. (lib/src/ui/magic_selector.dart) -
MagicPaginator.isRefreshingand.isLoadingMore, because a list has three loading states and one flag cannot carry them. A first load shows a skeleton, a refresh keeps the rows the reader is already looking at, and a next page puts a footer under the last row. Read offisLoadingalone the second and third are indistinguishable, so a screen either blanks itself on every filter change or grows a footer promising a page nothing asked for. Both are false on a first load (nothing on screen to preserve, nothing being appended) and all three are false once the request lands. The distinction only exists DURING a request, which is why_isResetis set beside_isLoadingand before the notification rather than derived afterwards: by the time a caller can await the future there is nothing left to tell apart. One window is documented rather than changed: arefresh()deferred behind an in-flightloadMore()keeps reportingisLoadingMoreuntil that page lands, which is what is happening on the wire and the only path where the flags follow the request rather than the caller's most recent ask. (lib/src/http/magic_paginator.dart) -
MagicPaginator.total, read frommeta.total. The size of the collection rather than of the pages in hand:items.lengthanswers "how much have I fetched", and a header reading "11 of 240" needs the other number, which a consumer previously had to fetch a second time or parse out of a response this class had already parsed. Null on a cursor collection, because Laravel'scursorPaginate()deliberately does not count and a total invented from the loaded page would be wrong rather than approximate. Read withcontainsKeybefore the mode branches, so a page that says nothing about the count leaves the last known value alone: an endpoint sending the total on page one only would otherwise have it erased by page two. Cleared on a reset, since a reset is usually a different question and the previous count describes a collection that no longer exists. (lib/src/http/magic_paginator.dart) -
MagicPaginator.loadedPages. Counts what is HELD, not requests made: arefresh()puts it back to one and a failed page counts nothing. A screen that writes its position into a URL wants this rather than the cursor, because a cursor names a position in ONE ordered result: shared, it drops the reader into the middle of a list with nothing above it, and points nowhere once that row is renamed or deleted. A page count re-fetches pages one to N, which is the same rows with the top intact. (lib/src/http/magic_paginator.dart) -
Pick.saveFiletakes amimeType, and derives one from the file name when you do not pass it. file_picker 12 added the parameter and defaults it toapplication/octet-stream, which is the value Android SAF and the browser both read to decide what the saved file is: a PDF written under it opens in a generic handler rather than a reader.Pickalready carried an extension-to-MIME map for the pick direction, so the save direction now reads the same table and only falls back toapplication/octet-streamfor an extension it does not know. (lib/src/facades/pick.dart)
Changed #
-
fluttersdk_windmoves from^1.2.0to^1.5.3, so every magic app gets Wind's keyboard and focus fixes rather than only the apps that happened to resolve fresh. The ceiling does not move; the floor does. Anyone runningflutter pub getwithout a pin was already on 1.5.x, because^1.2.0admitted it, so for them this changes nothing. It changes something for an app that pinned Wind lower: 1.5.3 is now the minimum, and the span it is being pulled across carries one behavioural break, 1.4.0 making a single-lineWInputdefault its Return key toTextInputAction.doneinstead of.next. A multi-field form that relied on Return advancing the focus needstextInputAction: TextInputAction.nextwritten out. What the floor buys is worth that: 1.5.3 makes a focusedWAnchoranswerActivateIntent, so a control reachable by keyboard, gamepad or a television remote actually activates; it makes one control cost one Tab stop rather than two, which everyfocus:ring-*control in magic's own views was paying; and it fixesdisabled:not reaching aWDivinside a disabledWAnchor. Magic renders its views through W-widgets, so these are magic's bugs as much as Wind's. (pubspec.yaml) -
MagicFile.sizestays filled for every picked file, and it costs astaton Windows and Linux to keep it that way. v12 replacedPlatformFile.sizewith two readings:lengthSync(), the size the native picker reported, andlength(), which measures the file when it reported none. The Windows dialog and the Linux XDG portal return a path and nothing else, so the synchronous reading is null for every desktop pick; reading it would have leftsizenull exactly where a desktop app is most likely to be checking an upload limit, and the null would arrive as a silently skipped check rather than as an error.Pickreadslength()instead. Web and Android always report a size, so the fallback only runs on desktop. (lib/src/facades/pick.dart)
Fixed #
-
Outgoing header names keep the casing you wrote, instead of reaching the wire lowercased.
DioNetworkDriverleftpreserveHeaderCaseat Dio's default offalse, so the IO adapter normalised every key on the way out:User-Agentwent asuser-agent,X-Request-Idasx-request-id. HTTP/1.1 says header names are case-insensitive and most servers honour that, but the consumers that read a header by exact key do not, and they fail quietly: ExoPlayer looks its request headers up case-sensitively and simply finds nothing, so a media request loses its user agent and gets served the wrong stream rather than an error anyone can see. Found on a consumer app whose video playback broke only in release, against one CDN. The driver now setspreserveHeaderCase: trueand passes the caller's key through untouched. Two things are worth knowing before you rely on it. It only holds where the IO adapter runs, so mobile and desktop keep the casing and web still lowercases, becausedio_web_adapterwrites headers throughXMLHttpRequest.setRequestHeaderand never reads the flag. And the response side is unchanged:MagicResponse.headerskeys still arrive lowercase, sinceHttpHeaderslowercases on receipt whatever the peer sent. (lib/src/network/drivers/dio_network_driver.dart) -
MagicPaginatedListViewwore its loading footer through a refresh. The footer means "there is more, and it is on its way", and a refresh is the opposite statement: those rows are being replaced rather than added to. It was gated onisLoading && items.isNotEmpty, which a refresh over a non-empty list satisfies, so every pull-to-refresh and every filter change grew a footer promising a page that had not been requested. Now gated onisLoadingMore. Covered bya refresh does not wear the loading-more footer, which holds the window open with a fetcherCompleterbecauseHttp.fakeanswers synchronously andtester.pump()drains microtasks before it builds. (lib/src/ui/magic_paginated_list_view.dart)
0.0.9 - 2026-08-26 #
Added #
-
MagicPaginator.fetcher, for a collection that does not arrive from a bare url. The url constructor callsHttp.getitself, which is right for an endpoint and wrong for anything behind a contract: a rail or driver a consumer can swap, a store, a query that needs assembling. Found on a real one, a billing history that reaches the client through a payments service whose store build THROWS rather than answering, so pointing a url paginator at the endpoint it wraps would have walked around the abstraction that keeps that build honest.MagicPage<E>is what a fetcher reports: the rows, plus either anextCursoror ahasMorefor a source that pages by something the paginator never sees. The fetcher is handed aMagicPageRequestcarrying that cursor AND anisFirstflag, because a source keeping its own position has no cursor at all: without the flag arefresh()looks exactly like aloadMore()to it, and the reset that clears the rows would then render whatever page it was up to as the whole list.modereports the newPaginationMode.fetcherrather than borrowingcursor, since how the pages are addressed is the fetcher's business. Only anExceptionbecomeserror: anErrorout of a fetcher is that code being wrong and propagates rather than arriving on screen as a TypeError message. Everything else is shared with the url mode, which is the point of putting it here rather than letting each consumer re-implement the accumulation, the in-flight and disposal guards, and keeping the rows when a page fails. A fetcher signals failure by throwing, where an endpoint signals it with a status code, so the catch is what the!response.successfulbranch is on the other path. (lib/src/http/magic_paginator.dart) -
MagicPaginator<E>andMagicPaginatedListView<E>: a collection that arrives one page at a time and costs the viewport rather than the result.fetchListreads thedatakey, replaces whatever was there, and ignores the pagination envelope entirely, so the only shape it supports is "fetch everything and render everything". That is fine for a settings screen and wrong for a log, a check history or a feed: rendering a long collection as a column of every row costs one build, one layout and one semantics node per row on the FIRST frame, whether or not the reader ever scrolls that far. The paginator holds the rows fetched so far, knows whether the server has more, and appends; the list widget builds only what the viewport can show and asks for the next page as the tail comes into view. Measured in a widget test: 500 rows in a 300px viewport cost fewer than 30itemBuildercalls. (lib/src/http/magic_paginator.dart,lib/src/ui/magic_paginated_list_view.dart) -
Both Laravel envelopes are read, and the mode is taken from the response rather than configured. A
meta.next_cursorkey meanscursorPaginate()and the next page is requested with?cursor=; ameta.current_pagekey meanspaginate()and the next page is?page=n+1; neither means a bare collection that is already complete. Reach forcursorPaginate()on anything that grows at the head, which is most live data: offset addresses a page by counting from the start, so a row inserted at the top between two requests shifts everything down and page two repeats the last row of page one. A cursor names a position in the ordering, so it cannot drift, and the database answers it without counting past the rows it skips. The KEY identifies the mode and its VALUE decideshasMore, becausenext_cursoris present and null on the last cursor page. -
The failure modes are guarded here rather than left to every caller.
loadMore()is a no-op while a request is in flight, because an infinite-scroll list fires it from a scroll callback that runs on every frame near the end; without the guard the same page is fetched and appended several times and every row in it shows two or three times. A failedloadMore()keeps the rows already on screen and leaveshasMorealone: losing page one because page two timed out is worse than the timeout, and the retry needs a target. A transport failure is one of them:DioNetworkDriverreports a timeout or a dead link as statusCode 0, which is neitherfailed(>= 400) norsuccessful, so the check is!response.successfuland an offline first page reports an error rather than rendering as an empty collection. Disposing mid-request is safe (every notify is guarded, the wayMagicController.refreshUIis), and arefresh()issued while the tail is auto-fetching waits for that page and then starts over instead of silently doing nothing.itemsis a liveUnmodifiableListViewrather than aList.unmodifiablecopy, since the widget reads it once per build and a copy per frame is the cost this class exists to avoid. -
A first page shorter than the viewport still fetches its successor, and stops when fetching stops helping. Scroll notifications only fire when a list actually scrolls, so a page that does not fill the viewport left the reader with a truncated list and no way to extend it, which any
perPagesmaller than a tall viewport reaches.MagicPaginatedListViewchecksmaxScrollExtentafter the frame and asks for the next page when there is nothing to scroll. That check re-arms on every build and a failedloadMoreleaveshasMoretrue on purpose, so it needs its own brakes or the two compose into a fetch per frame against a failing endpoint (measured at 22 requests across 20 frames). Two gates close it: anerrorstops the fill, because a failure is not an invitation to retry harder and the retry belongs to whoever renders it; and a page that added no rows to that collection stops it, because a server handing back a cursor beside an emptydataarray never grows the list and nothing else would say stop. The second gate is keyed on(generation, count)rather than on the count alone, andMagicPaginator.generationis public for that reason: it increments on every landed reset, so arefresh()re-arms whatever the count had disarmed. Keyed on length alone, a refresh that rebuilds page one at the same length reads as "nothing was added" and strands the reader on the retry path, which is the same defect the fill exists to prevent.
0.0.8 - 2026-08-25 #
Fixed #
-
A
logout()that could not clear one thing cleared nothing after it. The steps ran in sequence, andVault.deletethrowsMagicVaultExceptionon a platform error (a locked keychain, a lost entitlement), so a failure on the very first delete left the cached user on disk, left the user in memory, and never bumpedstateNotifier: the app went on rendering a signed-in session while the caller was told the logout had failed. Every step is now attempted whatever the earlier ones did, the in-memory clear and the notify happen unconditionally because they are the parts that cannot fail, and the first failure is rethrown at the end so the caller still learns a credential may have survived.clearTokens()had the same shape one level down and left the refresh token behind, which is a live session on the next launch. (lib/src/auth/guards/base_guard.dart) -
MagicEncrypter's documentation promised a MAC it does not have. The class docstring said every encrypted value "is signed using a message authentication code (MAC) so that their underlying value can not be modified or tampered with once encrypted", anddecryptreferred to a "MAC signature check (handled internally)". There is no MAC anywhere: the payload isbase64(iv):base64(ciphertext)under AES-256-CBC, and Laravel'sEncrypter, whose wording this was, is where the HMAC-SHA256 actually lives. That makes CBC malleability reachable (flipping a bit in the IV predictably flips the matching bits of the first plaintext block, and decryption still succeeds) and makes a caller who reports the failure back to whoever supplied the payload into a padding oracle. No behaviour changed here; the docs now describe what the cipher does and does not give you, and a test flips an IV bit to prove the tamper goes undetected, so the claim is checkable rather than asserted. Adding the MAC is a separate decision because it breaks the payload format, and accepting the old format alongside it would be a downgrade attack rather than a fix. (lib/src/encryption/magic_encrypter.dart,lib/src/facades/crypt.dart) -
Reading a relation marked the model dirty.
getRelation/getRelationsmaterialise a nested Map into aModeland cache it back into the attribute map, but dirty tracking compares that map against the original snapshot, which still held the raw Map. Sopost.authorreported the model as modified andgetDirty()returned aModelobject where every other value is a storage primitive. Materialised relations now live in their own cache, so the attribute map stays raw and the dirty comparison is always raw against raw. That holds on a model hydrated withsync: trueand on one built withfill()alike; an earlier attempt only covered the first, and left a filled model returning aModelobject fromgetDirty()among storage primitives. Serialisation is unchanged: a relation that has been read still goes through its model's owntoMap, and one that has not is still the raw nested Map. (lib/src/database/eloquent/model.dart) -
A cast ran on every read.
getAttributere-ranCarbon.parseper call and stored nothing, so a widget readingincident.startedAtwhile building a row paid for it per row per frame. Measured over 18,000 reads across 50 models: 5,895ns per read before, 2,385ns after. Results are memoised in a separate map rather than in the attribute map, deliberately, so the defect above is not recreated: aCarbonsitting where a String belongs would report a read as a modification and change what a save sends.jsonis deliberately excluded because a decoded Map is mutable: sharing one instance would makeuser.settings['theme'] = 'light'stick for every later read while the raw attribute still held the old JSON, so the model would stay clean and a save would send the pre-mutation value. The mutation is lost either way, since nothing writes it back, but without the memo it is lost visibly on the next read rather than silently at save time. ACastsAttributesinstance is excluded too; it may derive its answer from something other than the attribute. Invalidated bysetAttributefor one key andsetRawAttributesfor all. (lib/src/database/eloquent/model.dart) -
Rebinding a key that had already been resolved did nothing.
make()reads the instance cache before the bindings andbind()never cleared it, so overriding a key a starter package had resolved kept serving the first instance with nothing to say the override was ignored. Ordering rule this introduces: a driver or fake installed withsetInstance(which is howLog.setDriver,Auth.setDriver,Cache.setDriver,Http.setDriver,Vault.setDriverandEcho.setManagerall work) is now evicted by a laterbind/singletonon the same key. Install fakes AFTERMagic.init()and after provider registration, not before. (lib/src/foundation/application.dart) -
A service provider registered after
boot()never booted.boot()early-returns once the app is booted, so a late registration ranregister()and silently skippedboot(), leaving the provider half initialised. That is the state a plugin installing itself lazily lands in. Registering the same provider INSTANCE twice also ran both hooks twice, which for a provider that starts a poller means two of them. The guard is identity rather than class, deliberately: a provider class parameterised per plugin and registered once per plugin is a legitimate shape here. (lib/src/foundation/application.dart) -
The cache wrote to disk on the read path.
get()is synchronous, so the_persist()it fired on an eviction could not be awaited: a failure had nowhere to go and surfaced as an unhandled async error rather than a cache miss, and reading N stale keys rewrote the whole file N times. Expiry and entry shape are re-checked on every read, so a row left on disk is inert and the next write drops it. Both the IO and web stores are fixed. An unparseable cache file now prints why it is being discarded instead of resetting silently. (lib/src/cache/drivers/file_store_io.dart,lib/src/cache/drivers/file_store_web.dart) -
Flushing the container left every event listener attached.
EventDispatcheris its own static singleton, soMagicApp.flush()andMagicApp.reset()dropped the providers but not the listeners they had registered, even thoughreset()documents itself as destroying the entire application instance. A re-bootstrap ended up with two of every listener, so one event sent two emails, and a test file that forgot to clear the dispatcher by hand leaked into the next. (lib/src/foundation/application.dart)
Changed #
-
MagicApp.register()andMagic.register()returnFuture<void>. They used to returnvoid. Callers that ignore the future are unaffected and it completes immediately before the boot phase; after boot it completes when the newly registered provider has finished booting, soawait Magic.register(p)no longer races the wiring it just asked for. The body stays synchronous on purpose: anasyncbody would capture a throw from the provider's ownregister()into the future, and sinceMagic.init()does not await, a bootstrap failure would stop being loud and arrive later as an unhandled zone error instead. (lib/src/foundation/application.dart,lib/src/foundation/magic.dart) -
EventDispatcher.dispatchdocuments its divergence honestly. A listener that throws is caught and logged and the rest still run, which is deliberate (on a client, one bad listener must not take down the frame) and differs from Laravel, whose dispatcher lets it propagate. The docstring claimed rethrowing "can be configured"; nothing configures it, and it now says so. (lib/src/events/event_dispatcher.dart)
0.0.7 - 2026-08-25 #
Fixed #
-
A validation error arriving after its controller was disposed threw. The five
notifyListeners()calls inValidatesRequestssat outsiderefreshUI()'sif (!_disposed)guard, andnotifyListeners()on a disposedChangeNotifierraises aFlutterError. A late API failure resolving onto a torn-down form controller, which is ordinary on a slow network, hit it. Routing those five throughrefreshUI()puts them behind the guard they never had. (lib/src/concerns/validates_requests.dart) -
A cold start with no working backend showed a blank window for as long as the client timeout, because
restore()waited for a call whose answer the cache had already given.AuthServiceProvider.boot()awaitsAuth.restore(), which holdsMagic.init(), which holdsrunApp, so everythingrestore()awaited was time the user spent looking at nothing. It awaited_syncUserFromApi()even afterloadCachedUser()had produced a user andsetUserhad put it in place. Against a backend that accepts the connection and then says nothing (a captive portal, a dead mobile link, a hung server) that is the entire timeout: measured on an iPhone 17 simulator against an app configured for 120s as roughly two minutes of white screen, with the console stopping dead onAuth: Cached user restoredand the theme's own boot logging not appearing until it let go. The class docblock has described the intent as "2. Sync from API in background" since it was written. The sync is now awaited only when the cache had nothing to show, because then there is nothing to render and no honest way to route; with a cached user the screen renders now and corrects itself when the sync lands, which is whatAuthRestoredalready exists to announce. (lib/src/auth/guards/base_guard.dart,test/auth/auth_test.dart) -
Losing the network signed the user out and destroyed the stored session.
_syncUserFromApi()treated any non-2xx as a rejected token and calledlogout(), andDioNetworkDriver._handleErrorreports a transport failure asstatusCode: 0, because a timeout, a DNS miss or a dead link has no response to report. So a phone going through a tunnel during the restore call cleared the token and the cached user and dropped the app on the sign-in screen, while the log saidAuth: Token invalidabout a server that never spoke. Reproduced on a device: after one offline cold start the next launch loggedAuth: No token found in storage. Only a401or a403ends a session now; every other failure keeps the cached one and logs what actually happened, including the status it saw. (lib/src/auth/guards/base_guard.dart,test/auth/auth_test.dart) -
Pick's two gallery fallbacks escaped their own error handling, and CI could not build until it was fixed.pickFromCameraandpickVideoFromCamerareturned the fallback future without awaiting it, so the future left thetryblock before completing: a failure inside the fallback never reached thecatch, and theonErrorcallback the caller supplied never fired. Flutter 3.47 addedunawaited_return_in_try_block, which turned the latent bug into two analyzer warnings and a redLint & Testjob on every branch, including ones that never touch this file. Both are awaited now, which fixes the reporting and the build together. (lib/src/facades/pick.dart) -
Every page this router built was anonymous, which silently disabled every screen-aware observer.
GoRoute.namenames the ROUTE and never reachesRouteSettings, so aNavigatorObserverreadingroute.settings.namegotnullon every push and could not tell one screen from another. Analytics, breadcrumb trails and Sentry's Flutter Web release health all key on exactly that value, and the last one fails in the worst possible way: the transport keeps working, events keep arriving, and the session count sits at zero forever with nothing in any log to explain it, becauseWebSessionHandler.startSessiononly fires when the name CHANGES (or on the first navigation when it is exactly/). Measured on a deployed app before this fix: a browser with no ad blocker made zero requests to Sentry's ingest across three route changes while a forcedcaptureMessagefrom the same page returned 200. All five pages the transition switch returns now carryroute.routeName ?? route.fullPath. The fallback is the path rather than nothing, because.name()is optional and most routes never call it, so keying only onrouteNamewould have left the common case exactly as broken as before; the path is always present, already unique per route, and on the root route it produces the/that the first-session rule wants. (lib/src/routing/magic_router.dart,test/routing/page_route_name_test.dart)
Improvements #
-
The FileStore expiration test no longer races the clock, so master stops going red at random.
it handles expirationwrote a value with a 100ms TTL and immediately asserted it was readable. That window had to survive a file write plus the scheduler, and on a loaded CI runner it did not: the entry expired before the read and the assertion failed withExpected: 'value' Actual: <null>while the store was behaving correctly. It failed twice today, once on a PR and once on master after merge. The readable case now uses a 5-minute TTL, and the expiry case passes an already-elapsed TTL soexpire_atlands in the past by construction, which removes the wall-clock delay entirely (a delay can only ever be too short, never too long). (test/cache/drivers/file_store_test.dart) -
The registry dispatch fires on a published release now, not on every push that touches the skill. Under the push trigger
fluttersdk/aiclimbed to v1.3.75, and most of those releases re-published identical skill content: a docs commit and a release commit each cost the registry a version. The registry version now tracks published magic releases instead of counting commits.workflow_dispatchstays as the manual escape hatch when a skill fix has to reach users before the next release. (.github/workflows/dispatch-to-registry.yml) -
Every plugin install command in the
magic-frameworkskill was unrunnable.plugin-notifications.mdsaiddart run magic_notifications install,plugin-deeplink.mdsaiddart run magic_deeplink install/generate, andplugin-starter.mdsaiddart run magic_starter:installand four siblings. None of those resolve: no plugin package declares anexecutables:entry or ships abin/directory, and the real command names arenotifications:install,deeplink:install,deeplink:generate,starter:install,starter:configure,starter:doctor,starter:publish,starter:uninstall,social:install. All of them now readdart run magic:artisan <plugin>:<command>, which reaches the plugin providers becauserunArtisandelegates to the consumer's dispatcher when one exists, and each file gained theplugin:install <package>step that registers the provider in the first place. (skills/magic-framework/references/plugin-{notifications,deeplink,starter,social-auth}.md) -
New
references/plugin-devtools.md.magic_devtoolswas the one ecosystem plugin with no reference file: the skill named it in a table, pointed at a doc page in another repo, and described a call shape (MagicDuskIntegration/MagicTelescopeIntegrationseparately) that 0.0.2 replaced with theMagicDevtools.installPre()/installPost()umbrella. The new file covers both phases and why they straddleMagic.init(), the call-sitekDebugModerule that the release tree-shake depends on, the four import barrels, and the entireMagicPreviewcatalog (PreviewEntry,MagicPreviewCatalog, the/previewand/preview/:componentroutes, the provider-boot()registration window before the router locks, and thekReleaseMode+PREVIEW_ENABLEDgate), which nothing in the skill mentioned. (skills/magic-framework/references/plugin-devtools.md,SKILL.md) -
plugin-starter.mdwas four releases behind (alpha.14 against alpha.18). It documented the looseuse*setters as the only setup path and missedMagicStarter.bootstrap(), the identity contract that makesuserFactory/onLogout/localesrequired and throws on a partial team-callback set. Also added:SessionScopedController+SessionScopeSync(the cross-tenant leak guard, with the clear-before-refetch rule), theEnsureAuthenticated/RedirectIfAuthenticatedroute guards, the plan upgrade wall (PlanUpgradeRequirement.fromResponse,UpgradePrompt.show,MSUpgradeDialog,MSUpgradeNudge),settingsMaxWidthClassName, and a note that the sixMagicStarter*alias widgets are aliases of the canonicalMS*names and disappear next release. (skills/magic-framework/references/plugin-starter.md) -
Magic.seed(List<Seeder>)is documented.make:seederscaffolds a seeder and the skill never said how to run one; there is nodb:seedcommand, seeders run from Dart afterMagic.init(). (skills/magic-framework/references/cli-commands.md) -
The
magic:installpost-install message pinnedmagic_devtools: ^0.0.1andfluttersdk_dusk: ^0.0.8. Under Dart's caret rules for0.0.xboth exclude the current releases (0.0.2 and 0.0.9), so a consumer copying the snippet resolved to superseded versions. It also still described the pre-umbrella four-block wiring. (install.yaml) -
plugin-notifications.mdclaimed versionv0.0.1-alpha.1, a pre-release that never shipped, and documented none of the sevennotifications:*commands or the two read-only MCP tools (notifications_doctor,notifications_channels).plugin-social-auth.mdhad no installation section at all. Both carry a version stamp now. (skills/magic-framework/references/plugin-{notifications,social-auth}.md) -
MagicControllerexposes a staticonRefreshUIhook, and every controller notification now goes throughrefreshUI(). One nullable static at the singlenotifyListeners()call site lets debug tooling observe controller activity without magic depending on anything. The hook alone was not enough to make that true:ValidatesRequests, a mixinon MagicController, callednotifyListeners()directly at five sites, so a controller setting validation errors repainted without the hook firing and a diagnostic built on it under-counted exactly the form-validation rebuilds it is most likely to be pointed at. Those five now callrefreshUI(). The hook itself is contained rather than swallowed: it is set by tooling outside this package and runs BEFOREnotifyListeners(), so an unguarded throw would stop the screen repainting for every latersetSuccessandsetErroron that path. A broken observer costs its own numbers, never the app's frames. (lib/src/http/magic_controller.dart,lib/src/concerns/validates_requests.dart,test/http/magic_controller_test.dart,skills/magic-framework/)
Removed #
magic:install --without-eventsis gone: it was accepted and then ignored. The flag was declared in the command signature, listed in_withoutFlagNames, and prompted for ininstall.yaml, so it reached the manifest aswithoutEventsand stopped there. Nothing read it: the conditional-config map publishes six files (auth / database / network / cache / logging / broadcasting) and events has no config file,_buildProviderEntriesnever emits anEventServiceProviderline because magic registers the dispatcher in core, and no directory creation branches on it either. An install run with--without-eventsproduced byte-identical output to one without it, whiledoc/packages/magic-cli.mdpromised it skippedlib/app/events/andlib/app/listeners/. Removing it is the honest fix: there is no events setup to skip.--without-localizationis unaffected and still dropsLocalizationServiceProviderfrom the generated providers list. (lib/src/cli/commands/magic_install_command.dart,install.yaml,test/cli/commands/fixtures/install.yaml,doc/packages/magic-cli.md,doc/getting-started/installation.md,skills/magic-framework/references/cli-commands.md)
0.0.6 - 2026-07-29 #
Contributing checklist (before merging into [Unreleased]) #
- ❌ CHANGELOG entry added under the appropriate bucket (BREAKING / Added / Changed / Removed / Fixed / Improvements)
- ❌
doc/updated when the change touches public-facing behavior - ❌
README.mdupdated when the change touches the overview or quick-start - ❌
skills/magic-framework/updated when the change touches APIs the skill documents - ❌
example/updated when the change touches the canonical consumer scaffold - ❌
flutter testgreen;dart analyzeclean;dart formatno diff;dart pub publish --dry-runno blocking errors
0.0.5 - 2026-07-26 #
Added #
-
Model.save()now exposes the backend's per-field 422 errors instead of discarding them.save()returned only abool, so a form that wrote through the ORM could tell that a remote save failed but not why, and every 422 collapsed into a generic "something went wrong" toast. A failed remote save now captures the Laravel validation shape ({"message": ..., "errors": {"field": ["message"]}}) into two new members onInteractsWithPersistence:validationErrors(Map<String, List<String>>) andvalidationError(field)(the first message for one field). The map is cleared at the start of every remote save, so it stays empty after a save that succeeded or carried no field errors, and it also stays empty when the remote leg throws (a transport failure), which is how a caller distinguishes a field-validation failure from a network failure: an empty map plus afalsereturn means "render a generic error". It is deeply unmodifiable (both the map and each message list), and it tracks the REMOTE leg rather thansave()'s return value, so a hybrid model (useRemoteanduseLocal) whose remote save 422s while its local write succeeds returnstruewith the errors filled. Theboolreturn contract is unchanged, so this is purely additive for existing callers. Toucheslib/src/database/eloquent/concerns/interacts_with_persistence.dart; covered by theInteractsWithPersistence validation errorsgroup intest/database/eloquent/model_test.dart; documented indoc/eloquent/getting-started.md(Inserting & Updating -> Validation Errors) andskills/magic-framework/references/forms-validation.md(Server Error Mapping). -
MagicRouternow re-runs its redirect chain when auth state changes, not only on navigation. The router evaluated its guards (the'auth'/'guest'redirects) only while resolving a route, so a login or logout that happened while the user was already sitting on a page (a token expiry, a background sign-out, a successful login on the auth screen) did not move them off a now-forbidden route until the next manual navigation. The router now listens to the auth guard's state notifier and refreshesrouterConfigon a change, so an auth transition re-evaluates redirects immediately (an expired session bounces to login; a login leaves the guest-only auth screen). Consumers with no boundauthguard are unaffected (the notifier is absent and the listener is a no-op). Toucheslib/src/routing/magic_router.dart; covered bytest/routing/router_auth_refresh_test.dart.
Fixed #
-
LocalizationServiceProvidernow bootsDateManager, solocalization.timezoneandauto_detect_timezonefinally do something on their own. Nothing in the framework ever calledDateManager.instance.boot(), which meant the IANA database was never initialized, both timezone config keys were inert, and theX-Timezoneheader thatLocalizationInterceptorsends on every request reported the unbooted default rather than the device's zone. Every consumer had to boot it by hand beforerunApp, and a consumer that did not know to do so shipped a wrong header silently. The provider now boots it as the first thing inboot(), symmetrically with how it already handlesauto_detect_locale. Booting is idempotent and cannot fail startup (an unresolvable zone degrades to UTC). Toucheslib/src/localization/localization_service_provider.dart; covered bytest/localization/localization_service_provider_boot_test.dart. -
DateManagerno longer crashes application startup when it falls back to UTC._setTimezoneInternalresolved every zone throughtz.getLocation, including its own UTC fallback, but the database loaded fromtimezone/data/latest.darthas NO entry namedUTC(it shipsEtc/UTC). SogetLocation('UTC')threw, and because that call sat inside thecatchblock that was supposed to handle an unresolvable zone, the exception escapedboot()and tookMagic.init()down with it. The same gap made_isValidTimezone('UTC')return false, so the documented default oflocalization.timezonewas reported as invalid. Both paths now resolve through the package's consttz.UTClocation, whose name is the canonical'UTC', so an app that cannot detect a zone (or that simply keeps the default) boots and reportsUTCinstead of throwing. This was latent until detection stopped guessing: whiledetectTimezone()always returned a plausible city, the fallback was unreachable. Toucheslib/src/support/date_manager.dart; covered by theDateManager UTC resolutiongroup intest/support/date_manager_timezone_test.dart. -
MagicApplicationnow followsLang.currentfor runtime locale changes, eliminating the need for consumer workarounds. WhenLang.setLocalewas called at runtime to change the app's language, the locale reverted on the next widget rebuild becauseMaterialApp.localewas wired to the static config value, not the liveLang.currentstate. Consumers had to hand-write aListenableBuilderthat listened toTranslator.instanceand passedlocale: Lang.currentdown the tree to make language switching work.MagicApplicationnow bindslocaletoLang.currentdirectly, so runtime language switching works transparently, and consumers with the hand-written workaround can delete it. Explicitlocale:arguments passed toMagicApplicationstill take precedence, and config stays authoritative while no runtime locale has been loaded (Lang.isLoadedfalse), so an app whose translator is bound but not yet booted keeps its configured locale instead of snapping to the translator'sendefault. The subscription is aListenableBuilderonTranslator.instancescoped insideMagicAppWidget, belowWindTheme, which is the same scopeMagic.reload()refreshes: rebuilding fromMagicApplicationitself would handWindThemea freshWindThemeDataon every locale change and put the live brightness toggle at stake. Toucheslib/src/foundation/magic_app_widget.dart; covered bytest/foundation/magic_app_widget_locale_test.dart; documented indoc/digging-deeper/localization.md. -
DateManager.detectTimezone()now reads the real IANA timezone identifier instead of guessing from UTC offset. The method first triedDateTime.now().timeZoneName, which returns an abbreviation like+03orEETon most platforms, then fell back to finding the first timezone-database location whose current UTC offset matched the device. An offset does not uniquely identify a zone; Istanbul and Kyiv share the same winter offset but differ in DST rules, so a device's timezone could be misidentified. Detection now uses theflutter_timezonepackage to read the real IANA identifier from the platform, and when no valid zone resolves, returnsnulland leaves the configured default in place instead of guessing. Consumers who worked around the issue by addingflutter_timezoneas a dependency and manually calling a detection service beforerunAppcan now remove that code. Detection stays opt-in throughlocalization.auto_detect_timezone(defaultfalse), and the private offset-scanning helper_findTimezoneByOffsetis deleted rather than left unreachable. Toucheslib/src/support/date_manager.dartandpubspec.yaml(addsflutter_timezone); covered bytest/support/date_manager_timezone_test.dart; documented indoc/digging-deeper/localization.md(Timezone Detection) anddoc/digging-deeper/carbon.md(Timezone Support). -
MagicFeedbacktoasts now show in Scaffold-less (Wind-only) views instead of throwing or silently doing nothing.Magic.error/Magic.success/MagicFeedback.inforouted throughScaffoldMessenger.of(context).showSnackBar, which asserts_scaffolds.isNotEmptywhen no MaterialScaffoldhosts the view. In a Wind-built screen (noScaffold) that assertion escaped the caller's owntry/catchand stalled the flow. Toast delivery now goes through the Navigator overlay, read fromnavigatorKey.currentState.overlay(NOTOverlay.maybeOf, which sits above that overlay), as a single non-interactive auto-dismissing bottom entry that replaces the previous one and degrades to a logged warning when no overlay is available (never throwing). TheMagic.error/success/toastAPI surface is unchanged; only the delivery path is. Toucheslib/src/ui/magic_feedback.dart; covered bytest/ui/magic_feedback_test.dart. -
MagicFeedbackoverlay toasts render clean text and degrade without throwing. The overlay toast content was not wrapped in aMaterial, so its text inherited the root fallbackDefaultTextStyle(the yellow debug double-underline); it now sits under a transparentMaterial, matching the dialog / loading builders. The unusedbackgroundColor/colorparameters onshowSnackbar(the overlay path never applied them) are removed, and the degrade-path warnings now log throughLogonly when thelogservice is bound (falling back todebugPrint), so feedback triggered beforeMagic.initbinds logging degrades instead of throwingService [log] is not registered. Toucheslib/src/ui/magic_feedback.dart; covered bytest/ui/magic_feedback_test.dart. -
TitleManagertreats a blank title suffix as absent. Anullsuffix was already skipped, but an empty or whitespace suffix (e.g. an unsetAPP_NAMEresolving to"") still produced"Route | "with a trailing separator and an empty tail. A blank suffix is now treated as absent (via_withSuffix), so the browser tab shows just the route title. Toucheslib/src/routing/title_manager.dart; covered bytest/routing/title_manager_test.dart.
0.0.4 - 2026-07-08 #
Added #
design:sync+design:lintcommands make DESIGN.md the single source of truth for the app theme. Two new commands joinMagicArtisanProvider.design:syncparses aDESIGN.md(YAML front matter: color roles with a single-filedark:overlay, typography,rounded,spacing, andcomponentscarrying{colors.x}/{rounded.x}/{spacing.x}references; the markdown body is ignored), resolves the references against a dotted-path symbol table with a cycle guard, and emits a wind theme source file (--output, defaultlib/config/wind_theme.g.dart). The generated file exposesMap<String, String> designAliasescarrying the 17 property-prefixed semantic keys (bg-surface,text-fg,border-color-border, ...) with arbitrary-hex light +dark:pairs ('bg-surface': 'bg-[#f9f9ff] dark:bg-[#0f1419]'), drop-in forWindThemeData(aliases: ...)and matching theMagicStarterTokens.defaultAliasescontract, plus a brandprimaryMaterialColorwith a generated 50-900 ramp (seeded from the DESIGN.mdprimarylight hex) forWindThemeData.toThemeData()Material interop. It writes atomically via.tmp+ rename and is idempotent (byte-identical output on re-run for an unchanged DESIGN.md).design:lintvalidates a DESIGN.md against six rules ported from the open design.md reference linter and adapted to the wind-flavored superset: broken-ref (error), missing-primary (warning), unknown-key (warning; thedark:overlay lives insidecolorsand is structurally never a top-level key, so it is never flagged), section-order (warning), missing-sections (info), orphaned-tokens (warning, with Material Design 3 baseline families exempt), and contrast-ratio (warning; a greenfield WCAG relative-luminance helper does sRGB channel linearization + the 4.5:1 ratio check on each componentbackgroundColor/textColorpair). The Tailwind/DTCG export-conformance and rem-based spacing/rounded rules from the reference linter are intentionally dropped (wind uses 4px logical spacing and arbitrary-hex aliases). The command exits nonzero only on an error-severity finding. Toucheslib/src/cli/commands/design_sync_command.dart,lib/src/cli/commands/design_lint_command.dart,lib/src/cli/helpers/design_md_parser.dart,lib/src/cli/magic_artisan_provider.dart; addstest/cli/commands/{design_sync,design_lint}_command_test.dart+test/cli/helpers/design_md_parser_test.dart; documented indoc/packages/magic-cli.md(including the DESIGN.md format page).previews:refresh+make:componentcodegen commands for the design-first preview catalog. Two new commands joinMagicArtisanProvider.previews:refreshscans a configurable target directory (--path, defaultlib) for*.preview.dartfiles, extracts the single public*Previewclass from each via regex (the private_*Statecompanion of a stateful preview is ignored), validates the class name is a clean PascalCase identifier before interpolation, fails fast on a slug collision, sorts deterministically, and renders<scan-dir>/_previews.g.dartthrough an atomic.tmp+ rename. The generated file returns a freshly-builtList<PreviewEntry>from thepreviewEntries()FUNCTION (never a top-level const list) so the dev-only catalog tree-shakes from release builds (dart-lang/sdk#33920); it importsPreviewEntryfrompackage:magic_devtools/preview.dartand each preview widget by its path relative to the generated file (preview files are not exported from any barrel). Re-running the command produces a byte-identical file.make:component <Name> [--variants=intent,size] [--slots]extendsArtisanGeneratorCommandand scaffolds the canonical 4-file atomic component folder underlib/ui/components/<name>/(<name>.dart,<name>.recipe.dart,<name>.preview.dart,index.dart): the class is unprefixed PascalCase, the recipe is seeded with the requested variant axes (or aWindSlotRecipeshape under--slots), the index re-exports the component + recipe but not the preview, then the command chainspreviews:refreshso the new preview lands in_previews.g.dart. Toucheslib/src/cli/commands/previews_refresh_command.dart,lib/src/cli/commands/make_component_command.dart,lib/src/cli/helpers/previews_index_writer.dart,lib/src/cli/helpers/magic_stub_loader.dart(addsloadFrom),lib/src/cli/magic_artisan_provider.dart, and six stubs underassets/stubs/; addstest/cli/commands/{previews_refresh,make_component}_command_test.dart.magic:install --with-devtoolswires the debug trio in one step. Installing the optional debug tooling (magic_devtools+fluttersdk_dusk+fluttersdk_telescope) previously meant a manual multi-step bootstrap: add three deps, runplugin:installtwice, thendusk:install+telescope:install. The new--with-devtoolsflag does all of it after the core install: it adds the three packages todependencies(regular, notdev_dependencies, becauselib/main.dartimports them and thekDebugModegate tree-shakes the subsystem from release builds, sodev_dependencieswould tripdepend_on_referenced_packages) and wireslib/main.dartunderkDebugModeexactly asdusk:install/telescope:installdo:DuskPlugin.install()andTelescopePlugin.install()(plusExceptionWatcher+DumpWatcher) beforeMagic.init(), thenMagicDuskIntegration.install()andMagicTelescopeIntegration.install()after it. The wiring is a pure-functional, idempotent transform (buildDevtoolsWiring) over the generated main.dart, and the dep-add rides the sameinstaller.addDependencymechanism the install already uses, so re-runningmagic:install --with-devtoolsnever duplicates a wiring block or a dependency entry. The injected package imports are placed within the existing package-import group (before the relativeconfig/...imports, withpackage:flutter/foundation.dartordered beforepackage:flutter/material.dart), so the generated main.dart staysdirectives_ordering-clean and a freshly installed app emits no analyzer warnings. Absent the flag, nothing changes for the existing install path. Toucheslib/src/cli/commands/magic_install_command.dart; adds theMagicInstallCommand.buildDevtoolsWiringtest group plus a real-FS full-install group totest/cli/commands/magic_install_command_test.dart.MagicMiddleware.redirectTarget(String location)for pre-build redirect guards. Redirect-style guards (auth / guest) can now return a redirect target synchronously, evaluated inside the router'sredirectcallback BEFORE any page builds. Previously the only way to redirect was an imperativeMagicRoute.to()insidehandle(), which runs post-mount and remounts the destination view, recreating its form state on every mount (the login-double-mount bug)._handleRedirectnow evaluates every matched route's global + route middlewareredirectTargetand returns the first non-null target. The default returnsnull, andhandle()now defaults tonext(), so a redirect-only guard overrides justredirectTarget. Fully backward compatible: existinghandle()-based guards keep working. Toucheslib/src/http/middleware/magic_middleware.dart,lib/src/routing/magic_router.dart; addstest/routing/redirect_guard_mount_test.dart(asserts the destination mounts exactly once, including through a layout ShellRoute).
Fixed #
Pick.saveFileis source-compatible with file_picker 12. file_picker 12 madeFilePicker.saveFile'sfileNameandbytesparameters required and non-null, which broke the analyzer build (argument_type_not_assignable) under a freshflutter pub getthat resolved the newer file_picker.Pick.saveFilekeeps its nullable facade surface but now guards both arguments before forwarding, so the call type-checks against file_picker 11 and 12 and a null argument fails with a clearArgumentErrorinstead of an unhelpful type error. Toucheslib/src/facades/pick.dart.file_pickerconstraint tightened to exclude the 12.0.0 prerelease line. The constraint is now>=11.0.2 <12.0.0-0to lock the 11.x stable releases and exclude every12.0.0-*prerelease. A<12.0.0bound would NOT have been enough: pub_semver orders prereleases below the stable release (12.0.0-beta < 12.0.0), so12.0.0-betastill satisfied it; the-0suffix is the lowest possible prerelease and excludes the entire12.0.0line. This pairs with thePick.saveFilesource-compatibility guard above as defense in depth. Touchespubspec.yaml.Cryptnow accepts thebase64:app key thatkey:generateproduces.key:generatewritesAPP_KEY=base64:<base64 of 32 random bytes>, butEncryptionServiceProviderrequiredapp.keyto be a raw 32-character string and threwApp Key must be 32 characters for AES-256on the generated key, soCrypt.encrypt/decryptwere unusable out of the box. AddedMagicEncrypter.fromAppKey(appKey)which base64-decodes abase64:-prefixed key to its 32 bytes (and still accepts a raw 32-character key);EncryptionServiceProvidernow binds through it. Toucheslib/src/encryption/magic_encrypter.dart,lib/src/encryption/encryption_service_provider.dart; adds threefromAppKeycases totest/encryption/magic_encrypter_test.dart.MagicStatefulViewnow calls the controller'sonInit()lifecycle hook.MagicStatefulViewState.initStatelistened to the controller and called the VIEW's ownonInit()hook, but never invoked the CONTROLLER'sonInit(), despite the documented contract. A controller that bootstraps inonInit(initial data load, table creation, subscriptions) silently never ran it when backed by aMagicStatefulView, so the screen rendered against uninitialized state (e.g. a query against a table the controller'sonInitwas supposed to create). It now calls_controller.onInit()guarded byMagicController.initialized, so aSimpleMagicControllerthat already initialized in its constructor is not double-initialized and a singleton controller reused across re-mounts initializes exactly once per lifetime. Toucheslib/src/ui/magic_view.dart; addstest/ui/magic_view_controller_oninit_test.dart.- Auth no longer warns on every boot of a fresh app.
AuthServiceProvider.boot()logged auserFactory not registeredwarning (blaming provider order) whenever no userFactory was set, even for apps with no stored session to restore. It now only warns when a stored session actually exists (Auth.hasToken()) but cannot be rebuilt; a fresh app or a logged-out user stays quiet (debug-level). The stored-session check is guarded so a misconfigured Auth (for example, no Vault registered) cannot crash boot from this warning-verbosity path. Toucheslib/src/auth/auth_service_provider.dart; adds three cases totest/auth/auth_test.dart.
Changed #
fluttersdk_windconstraint bumped to^1.2.0. Requires wind 1.2.0'sWindRecipe/WindSlotRecipe(thetv()-equivalent recipe API, NEW in 1.2.0 and re-exported bypackage:magic/magic.dart— the design-first component layer andmake:componentscaffolds depend on it), plus the intrinsic-safe flex, the seededprimarytoken, the min-width-stretch scroll, and theh-full-inside-vertical-scroll dev assert. Also picks up wind 1.1.0's Material-freeWInput/WTextrewrite, 1.1.1's two fixes that magic's W-widget UI depends on (WInputnative text selection restored (mouse drag-select, double-tap word, long-press), andWTextnow inherits an ancestorDefaultTextStylecolor (the CSS text-color cascade) before falling back to the OS-brightness baseline; the latter fixes invisible labels on magic's W-rendered surfaces (Magic*View,MagicFeedback, dialog buttons whose color lives on the container) when the app theme disagrees with the OS theme), and 1.1.2'sWPopoverfix: a popover with an interactive trigger (aWButton/WAnchorwith its ownonTap) now opens reliably and no longer dismisses itself on the opening gesture, with the trigger kept accessible via aSemanticstap action. This is the primitive behind magic_starter's team selector and user/notification dropdowns. Touchespubspec.yaml.- Debug-tooling install guidance corrected to regular
dependencies. Themagic:installpost-install message recommended addingmagic_devtools/fluttersdk_dusk/fluttersdk_telescopetodev_dependencies, but the install commands wire them intolib/main.dart(underkDebugMode), which trips thedepend_on_referenced_packageslint. They are now documented as regulardependencies(tree-shaken from release viakDebugMode), matching dusk/telescope's own install docs. Also bumps the message's stalefluttersdk_dusk ^0.0.7to^0.0.8. Touchesinstall.yaml.
0.0.3 - 2026-06-17 #
Stabilization (magic-stabilize-dusk-telescope plan) #
-
BREAKING: the Dusk + Telescope Magic adapters moved out of magic core into the new sibling
magic_devtoolspackage.MagicDuskIntegration(14 enrichers),MagicTelescopeIntegration(5 watchers +MagicHttpFacadeAdapter) and their tests now live inmagic_devtools; magic core no longer depends onfluttersdk_duskorfluttersdk_telescopeat all. The class and function names are unchanged; only the import path moves and ownership shifts to a dedicated dev-tooling package. Consumer migration (pre-1.0 clean break, no shim):// before (interim sub-barrel, never released): import 'package:magic/dusk_integration.dart'; import 'package:magic/telescope_integration.dart'; // after — add magic_devtools as a dev_dependency, then: import 'package:magic_devtools/dusk.dart'; import 'package:magic_devtools/telescope.dart'; // MagicDuskIntegration.install(); / MagicTelescopeIntegration.install();Deletes
lib/src/cli/{dusk,telescope}_integration.dart, thelib/{dusk,telescope}_integration.dartsub-barrels, andtest/cli/{dusk,telescope}_integration_test.dartfrom magic; drops the twofluttersdk_dusk/fluttersdk_telescopedependency lines frompubspec.yaml. -
Granular scaffold + documentation is the default (M1). The E2E-drivability defaults (
processingListenable+MagicBuilder, stableValueKey,semanticLabelon ambiguous interactive widgets) are documented in.claude/rules/testability.mdand reflected in generated view stubs. Opt-in, no runtime behavior break for existing consumers. -
Testability rules formalized (M2).
.claude/rules/testability.mddefines view drivability as the third gate of "done" alongside passing tests and correct appearance, with the three widget-identity rules dusk depends on. -
fluttersdk_artisanconstraint bumped^0.0.7->^0.0.8. Drop-in: magic uses no artisan symbol changed between the two versions.
Fixed (consumer-blocking bugs surfaced by /tmp fresh-app E2E test plan) #
make:*commands now work on consumers that pull magic from pub.dev / path: dependency.MakeControllerCommand,MakeModelCommand, and the other 12make:*commands used to callStubLoader.load('controller')directly, which searches$ARTISAN_STUBS_DIR→$MAGIC_CLI_STUBS_DIR→fluttersdk_artisan-<version>/assets/stubs/. Magic's own stubs live at<magic>/assets/stubs/; neither env var was set in typical environments, and the fluttersdk_artisan pub-cache fallback contained only artisan substrate stubs. The 14 generators now load raw stub content via the newMagicStubLoaderhelper (which resolves<magic>/assets/stubs/<name>.stubfrom the consumer's.dart_tool/package_config.jsonmagic entry) and pass the content throughgetStub()forArtisanGeneratorCommand.buildClassto consume as a literal template. Addslib/src/cli/helpers/magic_stub_loader.dart; toucheslib/src/cli/commands/make_*.dart× 14.magic:installis now self-registering — adds magic to.artisan/plugins.jsonbeforeplugins:refreshruns, soMagicArtisanProviderappears inlib/app/_plugins.g.dartautomatically. Consumers no longer need a separatedart run magic:artisan plugin:install magicstep before invokingmake:controlleretc. Toucheslib/src/cli/commands/magic_install_command.dart(adds_selfRegisterPlugin).plugin:install magicre-invocations no longer corruptlib/config/app.dart. The staticinstall/app_configpublish entry rendered the raw{{ allImports }}/{{ allProviders }}placeholders when invoked outsideMagicInstallCommand.handle(where the fluent override would overwrite with the dynamic providers list). Removedinstall/app_config: lib/config/app.dartfrominstall.yamlpublish:; the fluent override is now the sole writer. Touchesinstall.yaml.assets/lang/en.jsonis now scaffolded on install. Addsinstall/lang_en: assets/lang/en.jsontoinstall.yamlpublish:with a minimal stub coveringcommon.welcome,common.loading, …, and avalidation.*block matching the built-in rule names. Consumers usingLang.trans('common.welcome')now resolve out of the box; previously the lang dir was empty until the operator ranmake:lang. Touchesinstall.yaml, addsassets/stubs/install/lang_en.stub.
Fixed (PR #87 code review) #
- Cache hit/miss detection no longer misclassifies.
CacheManager.get()decided hit-vs-miss withvalue == defaultValue, which dispatched aCacheMisswhen the stored value happened to equal the caller'sdefaultValue, or when a storednullwas read with anulldefault. It now usesdriver().has(key)for presence. Toucheslib/src/cache/cache_manager.dart; adds two regression cases totest/cache/cache_manager_event_dispatch_test.dart. KeyGenerateCommandreuses a singleRandom.secure()instead of constructing one per byte. Toucheslib/src/cli/commands/key_generate_command.dart.- Removed the unused
yaml_editdependency frompubspec.yaml(nolib/,test/, orbin/references), trimming transitive deps and publish surface. - Example app shows a real title.
example/.envAPP_NAMEis now"Magic Example"(was"") andwelcome_view.dartfalls back to a non-emptyapp.name, so the example no longer renders a blank title. Touchesexample/.env,example/lib/resources/views/welcome_view.dart.
Improvements (UX) #
magic:installpost-install message documents the optional Dusk + Telescope setup chain. Removed the obsolete sqlite3.wasm warning (the install command auto-fetches sqlite3.wasm 3.3.1 since the artisan-install-command-magic plan). Added a setup recipe pointing operators at themagic_devtoolsdev_dependency (plusfluttersdk_dusk/fluttersdk_telescope) and thepackage:magic_devtools/{dusk,telescope}.dartadapter imports, so the debug-tooling path is discoverable without consulting the docs. Touchesinstall.yaml(post_install.message).
Changed #
- Documentation: CLAUDE.local.md updated to reflect artisan-based CLI. The stale
magic_clicompanion-project sync protocol (cross-repo stub sync, provider coupling) has been retired. Magic now owns its CLI and generators underlib/src/cli/on thefluttersdk_artisansubstrate. UpdatedCLAUDE.local.mdto document the current architecture (command locations, install manifest, stub loading) and deprecation of the legacy magic_cli sync procedure.
Deferred #
magic:install --with-debug-toolingsingle-command flag that chains the 6-step Dusk + Telescope setup recipe (currently the post_install message documents the recipe; the flag would auto-execute it). Tracking issue: TBD.MainDartSmartMergershould consolidate the 4if (kDebugMode) { ... }blocks thatdusk:install+telescope:installemit into 2 blocks (pre-Magic.init()host plugins + post-Magic.init()Magic adapters). Currently each install command writes its own block, producing four single-statement blocks. Tracking issue: TBD.
Changed (artisan-install-command-magic plan) #
magic:installnow delegates canonical Flutter scaffold to artisan'sinstallcommand in-process. AfterstagedInstaller.commit()returns Success,delegateArtisanInstallinvokesInstallCommand.scaffoldInto(from the artisan public barrel) to writebin/dispatcher.dart+ barrels + pubspec dep + bin/fsa. Gated inside the existingif (result is Success)block so dry-run / Conflict / Error results skip the delegation and atomic-commit semantics are preserved. Magic-specific extras (conditional configs, dynamiclib/config/app.dart,lib/main.dartsmart-merge, sqlite3.wasm) remain magic-side.
Removed (artisan-install-command-magic plan) #
install.yaml11th publish entry (install/consumer_artisan: bin/artisan.dart) dropped. Artisan'sinstallcommand now writes the canonical dispatcher tobin/dispatcher.dart; magic no longer ships a separate consumer wrapper. Magic-managed consumers reach the same canonical state via the delegation flow.
Added (dusk-magic-wind enrichment Wave 3 / Wave 4 wiring) #
MagicHttpFacadeAdapter.pendingCountoverride (Step 3.4 cross-package). Proxies to the file-private_TelescopeNetworkInterceptor._pending.length(null-guarded pre-install, returns 0). Reads the live in-flight FIFO soTelescopeStore.pendingHttpCountcan sum across registered adapters. Powers dusk'sext.dusk.wait_for_network_idleend-to-end.- Magic-side reader wiring for dusk's telescope-backed tools
(Steps 3.4 + 3.5).
MagicTelescopeIntegration.install()now also assigns three function-pointer readers exported frompackage:fluttersdk_dusk/dusk.dart:pendingHttpCountReader = () => TelescopeStore.pendingHttpCountrecentLogsReader = TelescopeStore.recentLogs(...) → dusk envelope(renamesloggerName→logger, ISO-formats timestamps)recentExceptionsReader = TelescopeStore.recentExceptions(...) → dusk envelope(renamesexceptionType→type, truncates stackTrace to first 3 lines asstackHead) The indirection lives on the dusk side; dusk has no hard dep on telescope. Magic is the only crossover point. Dusk hosts that do not shipfluttersdk_telescopeget the default empty-list readers (missing-telescope graceful path).
- New
test/cli/telescope_integration_test.dart(6 cases): pre-install null-guard, post-install zero, in-flight count, FIFO decrement, post-uninstall null-guard, end-to-end viaTelescopeStore.pendingHttpCount.
Changed (BREAKING for magic_cli legacy users; non-breaking via legacy fallback) #
-
magic:installrewrite to PluginInstaller DSL + install.yaml manifest. The command extendsArtisanInstallCommand(from fluttersdk_artisan ^1.0.0-alpha.1+) and delegates the install.yaml-expressible 60% toManifestInstaller. The conditional 40% (per-flag config emission, dynamiclib/main.dartconfigFactories list, dynamiclib/config/app.dartprovider list, app name extraction from pubspec.yaml) lives in a fluent override hook onManifestInstaller.prepare(). Existing--without-*flags map 1:1 to install.yamlprompts:(bool type, default false).Backward compat:
dart run :artisan magic:installcontinues to work via legacy fallback; the new canonical workflow isdart run :artisan plugin:install magic(auto-detects install.yaml, routes through ManifestInstaller in one step). -
REVERTED: First install on a fresh
flutter createapp NO LONGER requires--force.MagicInstallCommand._resolveMainDartStrategycallsMainDartScaffoldDetector.isFlutterCreateScaffoldBEFORE the ConflictDetector path; when the existinglib/main.dartmatches the flutter create scaffold heuristic,scaffoldDetected=trueflows intoPluginInstaller.commit(force: true)and bypasses the unmanaged-file check silently. Operators now rundart run magic:artisan magic:installon a freshflutter createapp without any flag; customizedlib/main.dartstill requires--forceor--preserveexplicitly. (CHANGELOG entry from an earlier alpha was stale; the scaffold detector landed before alpha-15 but the entry was not removed.) -
sqlite3.wasmauto-download wired intomagic:install. When the database feature is enabled (no--without-databaseflag) and the run is not a dry-run,MagicInstallCommandnow fetches the matchingsqlite3.wasmfromsimolus3/sqlite3.dart(pinned to 3.3.1) and writes it toweb/sqlite3.wasmafter the install commits. Closes the white-screen /WebAssembly TypeErrorfailure mode that hit fresh Flutter web targets on first run.
✨ New Features #
-
Dusk enricher expansion (7 new enrichers + 1 extension):
magicControllerFlagsEnricher- captures FutureOr status, loading/success/error flags fromMagicStateMixinmagicRouteParamsEnricher- emits route parameters (path params + query string)magicFormErrorsEnricher(extension) - now quotes per-field error messages to preserve whitespacemagicEchoConnectionEnricher- reports broadcast connection state (connecting/connected/disconnected/reconnecting)magicGateResultsAllEnricher- emits last N gate check results (ability: allowed/denied) from MRU cachemagicRecentHttpEnricher- emits last 5 HTTP requests (method, URL, status, elapsed time)magicRecentLogsEnricher- emits last 5 log entries (level, message, timestamp)magicRecentExceptionsEnricher- emits last 5 exceptions (type, message, stack trace truncated to 500 chars)
All new enrichers guard
kDebugModeand handle missing dependencies gracefully (telescope-not-installed returns null buffer). Registered byMagicDuskIntegration.install(). Combined with existing 7 enrichers (magicControllerState,magicFormErrors,magicGateResult,magicMiddleware,magicAuthUser,magicFormField,magicRoute), magic-side surface now totals 14 enrichers. Ships in coordinated bump with fluttersdk_dusk 1.0.0-alpha.3+. -
Dusk integration: 5 new snapshot enrichers (
magicControllerState,magicFormErrors,magicGateResult,magicMiddleware,magicAuthUser) registered byMagicDuskIntegration.install()for richer LLM-agent E2E context. Combined with the 2 alpha-1 enrichers (magicFormField,magicRoute) this brings the magic-side surface to 7 enrichers; with Wind's 6-fieldWindClassNameEnricherthe total enricher surface is 8. Ships in coordinated bump with fluttersdk_dusk 1.0.0-alpha.2 (seereferences/fluttersdk_dusk/CHANGELOG.mdfor the matching dusk-side contract additions: 7 new handlers, 10 new MCP descriptors, 8 new CLI commands, actionability gate,dusk_findLocator pattern, Chrome reaper,dusk:doctor). Requires fluttersdk_dusk ^1.0.0-alpha.2 — theDuskSnapshotEnrichertypedef is frozen across both repos for the alpha-2 cycle. -
Cache events:
CacheHit,CacheMiss,CachePut,CacheForget,CacheFlushevent classes added underlib/src/cache/events/cache_events.dartand exported frompackage:magic/magic.dart.CacheManager.get/put/forget/flushnow dispatch the matching event throughEventDispatcher.instanceafter the underlying store operation completes. Enablesfluttersdk_telescope'sMagicCacheWatcher(and any user-defined listener) to observe the full cache lifecycle. -
Test coverage: new
MagicInstallCommandexercised by 27 tests using InstallContext.test + InMemoryFs + FakePromptDriver + FakeStubDriver injection; one test per--without-Xflag plus first-install--force- app name extraction edge cases. Coverage: 76.5% (defensive error paths not covered; accepted per Risks Accepted in the migration plan).
🔧 Improvements #
- Routing:
MagicRouter.currentRoutepublic getter for the currently-resolved RouteDefinition. - Auth:
GateManager.lastResult(ability)accessor backed by an MRU cache (64 entries) of the most recent gate-check outcome per ability.
✨ New Features #
- Eloquent:
Model.fillnow accepts astrictflag. Whentrue, any non-fillable key throwsMassAssignmentExceptioninstead of being silently dropped. Pair with validated request payloads to catch schema drift at the boundary. (#69) - Validation:
FormRequest— Laravel-style request object that collapses authorize → prepare → validate into a single class. ThrowsAuthorizationExceptionon denied access andValidationExceptionwith a field-keyed error map on rule failure. Pairs withModel.fill(validated, strict: true). (#66) - HTTP:
MagicController.authorize(ability, [arguments]), a Laravel-style controller helper that delegates toGate.allows()and throwsAuthorizationExceptionon denial. Avoids hand-rolling gate checks in every action. (#72) - Auth:
Gate.allowsAny(abilities, [arguments])andGate.allowsAll(abilities, [arguments]), short-circuiting sugar for checking multiple abilities at once. (#72) - Routing:
MagicRoute.resource(name, controller, {only, except})auto-wires up to four canonical routes (index, create, show, edit) to a controller that mixes inResourceController. Controllers declare supported methods viaresourceMethods;only/exceptnarrow the set further. Each route gets an auto-assigned{slug}.{method}name and title. (#67) - Validation:
AsyncRulecontract plusUnique(endpoint, field: ...)rule, an async uniqueness check with per-instance debounce (coalesces rapid calls) and a pluggable.via()resolver. Network errors log and pass so they never block submission.Validator.validateAsync()runs async rules after sync rules; sync failures short-circuit per field. (#68) - Session: Add
Sessionfacade with Laravel-style flash data —Session.flash(data),Session.flashErrors(errors),Session.old(field, [fallback]),Session.error(field),Session.errors(field),Session.hasError(field),Session.hasFlash,Session.tick(). Two-bucket store promotes flashed data exactly one navigation hop so forms can repopulate after a failed submit. Top-level helpersold()anderror()mirror Laravel's Blade API - UI:
MagicFormData.validate()automatically flashes form data on validation failure — downstream views can repopulate viaold('field')without manual wiring - Validation:
In<T>rule accepts a primitive whitelist (strings, ints, etc.) andInList<T extends Enum>validates enum-backed fields, accepting either the enum instance or a wire string.InListsupportscaseInsensitive:and an optionalwire:mapper for snake_case or custom representations. Both emit the sharedvalidation.inmessage with a comma-joined:valuesparameter. (#81)
1.0.0-alpha.13 - 2026-04-16 #
✨ New Features #
- Routing: Add
currentPathgetter toMagicRouter— returns the current route path without query string, complementing the existingcurrentLocationproperty
🐛 Bug Fixes #
- Routing: Use
GoRouter.pop()instead ofNavigator.pop()inback()— syncs router state and preserves custom page transitions on reverse animation. AddStateErrorguard when router is not initialized, consistent withto()andreplace()
🔧 Improvements #
- Skill: Optimize
magic-frameworkskill for Claude Code progressive disclosure — split frontmatter, extract templates to references, compress sections (669 → 416 lines). Add version frontmatter and source-to-skill mapping in release command - Deps: Bump magic version constraint in example app
1.0.0-alpha.12 - 2026-04-09 #
✨ New Features #
- Broadcasting: Client-side activity monitor — detects silent connection loss using Pusher protocol
activity_timeoutandpusher:ping/pusher:pong. Automatically reconnects when the server stops responding - Broadcasting: Random jitter (up to 30%) on reconnection backoff delay — prevents thundering herd when many clients reconnect simultaneously after a server restart
- Broadcasting: Configurable connection establishment timeout (default 15s) — prevents indefinite hang when server doesn't complete the Pusher handshake. Automatically triggers reconnect on timeout
1.0.0-alpha.11 - 2026-04-07 #
🐛 Bug Fixes #
- Routing: Fix intermittent page title loss on web — Flutter's
Titlewidget was overwriting TitleManager's route-level title ondidChangeDependencies()rebuilds. UseonGenerateTitleto keep both in sync
⚠️ Breaking Changes #
- file_picker: Upgrade from
^10.3.10to^11.0.2— migrates to static API (FilePicker.platformremoved). Consumers usingFilePicker.platformdirectly (viamagic.dartre-export) must switch to static calls (FilePicker.pickFiles(),FilePicker.getDirectoryPath(),FilePicker.saveFile()). Includes Android path traversal security fix (CWE-22) and WASM web support
1.0.0-alpha.10 - 2026-04-07 #
✨ New Features #
- Routing: Route-level page title management with
TitleManagersingleton. Per-route titles viaRouteDefinition.title(), automatic suffix pattern viaMagicApplication(titleSuffix:), declarativeMagicTitlewidget for data-dependent titles, and imperativeMagicRoute.setTitle()/MagicRoute.currentTitleAPI. Title resolution: MagicTitle > setTitle > RouteDefinition.title > MagicApplication.title. (#49)
🔧 Improvements #
- Dependencies: Bump
magic_clito^0.0.1-alpha.6(scaffold templates now include.title()andtitleSuffix)
1.0.0-alpha.9 - 2026-04-07 #
🐛 Bug Fixes #
- Broadcasting: Auth failures in private/presence channels now surface via
Log.error()and interceptoronError()chain instead of being silently swallowed. Reconnect resubscribes all channels withawait—onReconnectstream emits only after completion. Per-channel error handling ensures one auth failure does not block other channels. (#45) - Database:
sqlite3.wasmnow loads via absolute URI (/sqlite3.wasm) instead of relative — fixes 404s on deep routes when using path URL strategy. (#46)
1.0.0-alpha.8 - 2026-04-07 #
✨ Features #
- feat: config-driven path URL strategy for Flutter web (#40)
1.0.0-alpha.7 - 2026-04-06 #
✨ Features #
- Broadcasting:
Echofacade,BroadcastManager,ReverbBroadcastDriver(Pusher-compatible WebSocket with reconnection, dedup, heartbeat),NullBroadcastDriver,BroadcastInterceptorpipeline,FakeBroadcastManager,BroadcastServiceProvider. Laravel Echo equivalent for real-time channels. (#38) - Router Observers:
MagicRouter.instance.addObserver()enables NavigatorObserver integration for analytics/monitoring (Sentry, Firebase Analytics, custom observers). Observers are passed to GoRouter automatically. (#34) - Network Driver Plugin Hook:
DioNetworkDriver.configureDriver()exposes the underlying Dio instance for SDK integrations (sentry_dio, certificate pinning, custom adapters). (#35) - Custom Log Drivers:
LogManager.extend()enables custom LoggerDriver registration (Sentry, file, Slack). Config-driven resolution with built-in override support. (#36)
1.0.0-alpha.6 - 2026-04-05 #
✨ Features #
- Http Faking:
Http.fake()enables Laravel-style HTTP faking for testing. Swap the real network driver with aFakeNetworkDriverthat records requests and returns stubbed responses. Supports URL pattern stubs, callback stubs, and assertion methods (assertSent,assertNotSent,assertNothingSent,assertSentCount). (#18) - Facade Faking:
Auth.fake(),Cache.fake(),Vault.fake(),Log.fake()— Laravel-style facade faking for testing. Swap real service implementations with in-memory fakes that record operations and expose assertion helpers. (#19) - Fetch Helpers:
fetchList()/fetchOne()onMagicStateMixin— auto state management for HTTP fetches with defensive type guards against malformed responses (#20) - MagicTest:
MagicTest.init()/MagicTest.boot()— standardized test bootstrap helper,package:magic/testing.dartbarrel export (#21)
🐛 Bug Fixes #
- Log.channel(): Now returns
LoggerDrivervia_manager.driver(name)instead ofLogManager, enablingLog.channel('slack').error(...)as documented (#27) - Http.response() null data: Sentinel pattern allows
Http.response(null, 204)for No Content stubs whileHttp.response()still returns mutable empty map (#26) - URL pattern escaping:
FakeNetworkDriverstub patterns now escape regex metacharacters (.,?,+) viaRegExp.escape()— only*is treated as wildcard (#26) - fetchList/fetchOne defensive guards: Type-check
response.dataasMapbefore indexing, filter non-Mapelements in lists viawhereType<Map>(), guardfetchOnedata cast (#28)
1.0.0-alpha.5 - 2026-03-29 #
🐛 Bug Fixes #
- Route Back Navigation:
MagicRoute.back()now works aftergo()-based navigation (cross-shell). Maintains lightweight history stack with automatic fallback. Optionalfallbackparameter for explicit control. (#11)
1.0.0-alpha.4 - 2026-03-29 #
🔧 Improvements #
- Localization Hot Restart: Translation JSON changes now reflect on hot restart during development. Uses fetch with cache-busting on web and best-effort disk reads on desktop, bypassing Flutter's asset bundle cache. Zero impact on release builds.
1.0.0-alpha.3 - 2026-03-24 #
1.0.0-alpha.2 - 2026-03-24 #
⚠️ Breaking Changes #
- Pub.dev Migration: Replaced git submodule path dependencies with pub.dev hosted packages (
fluttersdk_wind: ^1.0.0-alpha.4,magic_cli: ^0.0.1-alpha.3). Removedplugins/directory entirely. - SDK Bump: Dart
>=3.11.0 <4.0.0, Flutter>=3.41.0(previously Dart >=3.4.0, Flutter >=3.22.0)
✨ New Features #
- Launch Facade: URL, email, phone, and SMS launching via
url_launcherwithLaunch.url(),Launch.email(),Launch.phone(),Launch.sms() - Form Processing:
process(),isProcessing, andprocessingListenableonMagicFormDatafor form-scoped loading state - Reactive Auth State:
stateNotifieron Guard contract and BaseGuard for reactive auth state UI - Query Parameters:
Request.query(),Request.queryAll,MagicRouter.queryParameter()for URL query parameter access - Localization Interceptor: Automatic
Accept-LanguageandX-Timezoneheaders on HTTP requests - Theme Persistence: Auto-persist dark/light theme preference via Vault in
MagicApplication - Validation Helpers:
clearErrors()andclearFieldError()onValidatesRequestsmixin - Route Names: Route name registration on
RouteDefinition
🐛 Bug Fixes #
- Auth Config: Default config now properly wrapped under
'auth'key - Session Restore: Guards against missing
userFactory— gracefully skips instead of throwing - Barrel Export:
FileStoreexported from barrel file - Package Name: Renamed internal references from
fluttersdk_magictomagic
🔧 Improvements #
- Dependency Upgrades: go_router ^17.1.0, sqlite3 ^3.2.0, share_plus ^12.0.1, file_picker ^10.3.10, flutter_lints ^6.0.0, and more
- CLI Docs: Rewrote Magic CLI documentation with all 16 commands and
dart run magic:magicsyntax - Wind UI Docs: Moved to wind.fluttersdk.com, removed local copy
- Example App: Rebuilt with fresh
flutter createandmagic install - CI Pipeline: Upgraded GitHub Actions, added validate gate to publish workflow
- Claude Code: Added path-scoped
.claude/rules/for 8 domains, auto-format and auto-analyze hooks
1.0.0-alpha.1 - 2026-02-05 #
✨ Core Features #
- Laravel-inspired MVC architecture
- Eloquent-style ORM with relationships
- GoRouter-based routing with middleware support
- Service Provider pattern
- Facade pattern for global access
- Policy-based authorization
📦 Package Structure #
- Complete model system with HasTimestamps, InteractsWithPersistence
- HTTP client with interceptors
- Form validation system
- Event/Listener system
🔧 Developer Experience #
- Magic CLI integration
- Hot reload support
- AI agent documentation