magic 0.0.9
magic: ^0.0.9 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.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