Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `MagicPreview` framework: a dev-only component preview catalog hosted via two
plain pages (`/preview` and `/preview/:component`). New
`package:magic_devtools/preview.dart` barrel exports
the `PreviewEntry` contract (`label`, `slug`, `builder`), the
`MagicPreviewCatalog` widget (a scrollable sidebar next to a SINGLE active
preview pane — tapping a sidebar item, or deep-linking `/preview/<slug>`,
swaps the pane to that entry; only the selected preview is mounted, so a
large screen-heavy catalog stays responsive — plus a global light/dark toggle
bound to wind's `WindTheme.of(context).toggleTheme()`),
and the `MagicPreview` registration entrypoint (`register` plus `registerRoutes`).
The route, catalog, and every registered `PreviewEntry` are reachable only
through `MagicPreview.registerRoutes`, which is guarded by `kReleaseMode` plus
`const bool.fromEnvironment('PREVIEW_ENABLED', defaultValue: kDebugMode)`, so
the whole surface const-folds dead and tree-shakes out of release builds.
Entries are held in a function-scoped list (never a top-level const, the
dart-lang/sdk#33920 foot-gun). The generated `_previews.g.dart` (Step 18) feeds
a `List<PreviewEntry>` into `MagicPreview.register`. Consumers must call
`MagicPreview.registerRoutes()` from a provider `boot()` BEFORE the router locks
on first `routerConfig` access, else `/preview` silently never registers.
- `fluttersdk_wind` is now a direct dependency (the catalog renders on
`WDiv`/`WText`/`WAnchor` and binds the theme toggle to `WindThemeController`).
- `MagicDuskIntegration.install()` now registers a navigate adapter via
`DuskPlugin.registerNavigateAdapter` so `ext.dusk.navigate --route <path>`
drives GoRouter through `MagicRouter.instance.to(path)` instead of falling
back to the `SystemNavigator` platform broadcast. Returns `true` on success
and `false` when the router is not yet initialised (catches `StateError`).
`resetForTesting()` clears the adapter with `DuskPlugin.registerNavigateAdapter(null)`.

### Fixed

- **`/preview/<slug>` deep links now select the right entry**: the catalog moved off a persistent ShellRoute (which did not rebuild when only the child route swapped, leaving every deep link stuck on the first entry) to two plain pages; the `/preview/:component` builder receives the slug and rebuilds on navigation. Known dev-only limitation: feature-SCREEN previews (full controller-backed `MagicStatefulView`s) emit a couple of non-fatal `setState() during build` warnings because the catalog mounts the same screen in both the light and dark panes sharing a singleton controller; the screens render correctly and the real app routes are clean (the catalog is stripped from release).
- **`/preview` route no longer crashes the app**: the catalog group's index child path was `/` which composed to `/preview/`, tripping go_router's `route path may not end with '/'` assertion and blanking the entire app on every route. Changed the index child path to `''` so the composed path is exactly `/preview`.
- **Catalog previews now inherit the host theme**: each light/dark pane copied a bare `WindThemeData` that carried no aliases, so component semantic tokens (`text-fg`, `bg-surface`, ...) resolved to no-ops and every preview rendered Flutter's red unstyled-text fallback. Panes now `copyWith(brightness:)` the ambient app theme, preserving aliases and brand colors.
- **Catalog overflow**: the preview surface now scrolls vertically and each pane scrolls horizontally, so wide variant matrices no longer trigger RenderFlex overflows in the side-by-side light/dark layout.

## [0.0.1] - 2026-06-17

### Added
Expand Down
42 changes: 33 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p align="center">
<strong>Magic adapters for the FlutterSDK dev-tooling ecosystem.</strong><br/>
Wire Magic's runtime into <a href="https://pub.dev/packages/fluttersdk_dusk">fluttersdk_dusk</a> (E2E driver) and <a href="https://pub.dev/packages/fluttersdk_telescope">fluttersdk_telescope</a> (runtime inspector)debug-only, zero release cost.
Wire Magic's runtime into <a href="https://pub.dev/packages/fluttersdk_dusk">fluttersdk_dusk</a> (E2E driver) and <a href="https://pub.dev/packages/fluttersdk_telescope">fluttersdk_telescope</a> (runtime inspector), debug-only with zero release cost.
</p>

<p align="center">
Expand All @@ -21,22 +21,23 @@

---

> **Alpha Release** part of the Magic ecosystem, under active development. APIs may change before stable. [Star the repo](https://github.com/fluttersdk/magic_devtools) to follow progress.
> **Alpha Release**: part of the Magic ecosystem, under active development. APIs may change before stable. [Star the repo](https://github.com/fluttersdk/magic_devtools) to follow progress.

## What is magic_devtools?

`magic_devtools` is the Magic adapter layer for [`fluttersdk_dusk`](https://pub.dev/packages/fluttersdk_dusk) and [`fluttersdk_telescope`](https://pub.dev/packages/fluttersdk_telescope). It enriches dusk snapshots and telescope records with Magic-aware context (forms, navigation, controllers, gates, auth, broadcasting, HTTP) so an LLM agent or CI driver sees your app the way Magic sees it.

It is **debug-only**: you install and wire it under `kDebugMode`, so release builds tree-shake it entirely and it carries no runtime cost in production. This is exactly why it lives outside `magic` core the framework keeps no dev-tooling production dependencies.
It is **debug-only**: you install and wire it under `kDebugMode`, so release builds tree-shake it entirely and it carries no runtime cost in production. This is exactly why it lives outside `magic` core; the framework keeps no dev-tooling production dependencies.

Two import barrels:
Three import barrels:

- `package:magic_devtools/dusk.dart` — `MagicDuskIntegration` registers 14 Magic-aware enrichers into fluttersdk_dusk's snapshot pipeline.
- `package:magic_devtools/telescope.dart` — `MagicTelescopeIntegration` registers 5 Magic watchers and `MagicHttpFacadeAdapter` into fluttersdk_telescope.
- `package:magic_devtools/dusk.dart`: `MagicDuskIntegration` registers 14 Magic-aware enrichers into fluttersdk_dusk's snapshot pipeline.
- `package:magic_devtools/telescope.dart`: `MagicTelescopeIntegration` registers 5 Magic watchers and `MagicHttpFacadeAdapter` into fluttersdk_telescope.
- `package:magic_devtools/preview.dart`: `MagicPreview` hosts a dev-only component preview catalog via two plain pages (`/preview` and `/preview/:component`), tree-shaken from release builds.

## Install

`magic_devtools` and the tooling packages are imported in `lib/main.dart` (under `kDebugMode`), so they are regular `dependencies`, not `dev_dependencies` `kDebugMode` tree-shakes them out of release builds, and because `lib/` imports them a `dev_dependencies` entry would trip the `depend_on_referenced_packages` lint. This matches how `fluttersdk_dusk` and `fluttersdk_telescope` are installed on their own.
`magic_devtools` and the tooling packages are imported in `lib/main.dart` (under `kDebugMode`), so they are regular `dependencies`, not `dev_dependencies`; `kDebugMode` tree-shakes them out of release builds, and because `lib/` imports them a `dev_dependencies` entry would trip the `depend_on_referenced_packages` lint. This matches how `fluttersdk_dusk` and `fluttersdk_telescope` are installed on their own.

```yaml
dependencies:
Expand Down Expand Up @@ -77,6 +78,29 @@ if (kDebugMode) {

You can wire either integration on its own, or both together: install each plugin before `Magic.init()` and each Magic integration after it. The `dusk:install` and `telescope:install` Artisan commands wire these blocks into `lib/main.dart` automatically when `magic_devtools` is a dependency.

### Preview catalog

`MagicPreview` hosts a dev-only component preview catalog: a sidebar of registered components next to each preview rendered in BOTH light and dark, with a global theme toggle bound to wind's `WindThemeController`. It is reachable only through `MagicPreview.registerRoutes()`, guarded by `kReleaseMode` plus `const bool.fromEnvironment('PREVIEW_ENABLED', defaultValue: kDebugMode)`, so the route, the catalog, and every registered `PreviewEntry` const-fold dead and tree-shake out of release builds.

The router-lock timing is load-bearing: `MagicRouter` locks its route table on the first `routerConfig` access, so registration MUST happen in a provider `boot()` (which runs during the Magic bootstrap lifecycle, before `MaterialApp` reads `routerConfig`). Register too late and `/preview` silently never appears.

```dart
class RouteServiceProvider extends ServiceProvider {
RouteServiceProvider(super.app);

@override
Future<void> boot() async {
registerAppRoutes();
if (kDebugMode) {
MagicPreview.register(previewEntries()); // from the generated _previews.g.dart
MagicPreview.registerRoutes();
}
}
}
```

The `previews:refresh` Artisan command scans `*.preview.dart` files and regenerates `previewEntries()` returning a `List<PreviewEntry>` from a function (never a top-level const, the dart-lang/sdk#33920 retention foot-gun).

## Ecosystem

| Package | |
Expand Down Expand Up @@ -110,11 +134,11 @@ dependency_overrides:

## License

MIT see [LICENSE](LICENSE) for details.
MIT, see [LICENSE](LICENSE) for details.

---

<p align="center">
<sub>Built with care by <a href="https://github.com/fluttersdk">FlutterSDK</a></sub><br/>
<sub>If magic_devtools helps you, <a href="https://github.com/fluttersdk/magic_devtools">give it a star</a> it helps others discover it.</sub>
<sub>If magic_devtools helps you, <a href="https://github.com/fluttersdk/magic_devtools">give it a star</a>; it helps others discover it.</sub>
</p>
31 changes: 31 additions & 0 deletions lib/preview.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/// Magic dev-only component preview catalog barrel.
///
/// Import this file to host the auto-discovered component previews behind two
/// plain pages (`/preview` and `/preview/:component`). The whole surface is
/// dev-only: it is reachable only
/// through [MagicPreview.registerRoutes], which is guarded by `kReleaseMode` +
/// `bool.fromEnvironment('PREVIEW_ENABLED')` and tree-shaken from release
/// builds.
///
/// Wiring (in the consumer's `RouteServiceProvider.boot()`, which runs BEFORE
/// `MagicRouter.instance.routerConfig` is first accessed — the router locks its
/// route table on that first access):
///
/// ```dart
/// @override
/// Future<void> boot() async {
/// registerAppRoutes();
/// if (kDebugMode) {
/// MagicPreview.register(previewEntries()); // from _previews.g.dart
/// MagicPreview.registerRoutes();
/// }
/// }
/// ```
///
/// See `src/preview/magic_preview.dart` for the [PreviewEntry] contract and the
/// [MagicPreviewCatalog] widget, and `src/preview/preview_routes.dart` for the
/// [MagicPreview] registration entrypoint and the release boundary.
library;

export 'src/preview/magic_preview.dart';
export 'src/preview/preview_routes.dart';
228 changes: 228 additions & 0 deletions lib/src/preview/magic_preview.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import 'package:flutter/widgets.dart';
import 'package:fluttersdk_wind/fluttersdk_wind.dart';

/// A single entry in the [MagicPreviewCatalog].
///
/// Each entry pairs a human [label] and a URL-safe [slug] with a [builder]
/// that renders the component (or component matrix) in isolation. The
/// generated `_previews.g.dart` (auto-discovered from `*.preview.dart` files)
/// returns a `List<PreviewEntry>` from a FUNCTION and feeds it to
/// [MagicPreview.register]; nothing here is ever a top-level const list, so the
/// release-mode tree-shaker can prove the whole catalog unreachable when the
/// dev-only boundary in [MagicPreview.registerRoutes] folds dead.
@immutable
final class PreviewEntry {
/// Creates a preview entry.
const PreviewEntry({
required this.label,
required this.slug,
required this.builder,
});

/// The display name shown in the catalog sidebar (e.g. `Button`).
final String label;

/// The URL-safe identifier used as the `:component` route segment
/// (e.g. `button`). Must be unique across the registered set; the
/// `previews:refresh` codegen (Step 18) collision-checks slugs at build time.
final String slug;

/// Builds the preview body for this entry.
final WidgetBuilder builder;
}

/// **The dev-only component preview catalog.**
///
/// Renders a scrollable sidebar of [PreviewEntry] labels next to a SINGLE
/// active preview pane: tapping a sidebar item (or deep-linking
/// `/preview/<slug>`) swaps the pane to that entry. Only the selected preview
/// is built, so a large catalog (including heavy controller-backed screen
/// previews) stays responsive instead of mounting every section at once. The
/// header shows the active entry's label plus a "Toggle theme" button bound to
/// wind's [WindThemeController] for a global light/dark flip.
///
/// ### Release boundary
///
/// This widget is only ever instantiated from within
/// [MagicPreview.registerRoutes], which is guarded by `kReleaseMode` +
/// `bool.fromEnvironment('PREVIEW_ENABLED')`. It must never be referenced from
/// a top-level const/final collection (the dart-lang/sdk#33920 foot-gun that
/// retains widget refs in release); keep every reference inside a function
/// body.
class MagicPreviewCatalog extends StatefulWidget {
/// Creates the catalog over [entries].
///
/// [activeSlug] selects which entry is shown; when null (or unmatched) the
/// first entry is shown.
const MagicPreviewCatalog({
super.key,
required this.entries,
this.activeSlug,
this.onSelect,
});

/// The previews to host. Passed in (never read from a top-level const) so the
/// release boundary can stay airtight.
final List<PreviewEntry> entries;

/// The slug of the entry to display; null shows the first entry.
final String? activeSlug;

/// Invoked when a sidebar item is tapped. The `/preview` route wires this to
/// navigation; when null, selection updates local state only.
final ValueChanged<PreviewEntry>? onSelect;

@override
State<MagicPreviewCatalog> createState() => _MagicPreviewCatalogState();
}

class _MagicPreviewCatalogState extends State<MagicPreviewCatalog> {
late String _selectedSlug;

@override
void initState() {
super.initState();
_selectedSlug = _resolveInitialSlug();
}

@override
void didUpdateWidget(MagicPreviewCatalog oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.activeSlug != oldWidget.activeSlug ||
widget.entries != oldWidget.entries) {
_selectedSlug = _resolveInitialSlug();
}
}

/// Pick the active slug: the requested one when it matches an entry,
/// otherwise the first entry's slug, otherwise the empty string.
String _resolveInitialSlug() {
if (widget.entries.isEmpty) return '';
final String? requested = widget.activeSlug;
if (requested != null && widget.entries.any((e) => e.slug == requested)) {
return requested;
}
return widget.entries.first.slug;
}

PreviewEntry? get _active {
for (final PreviewEntry entry in widget.entries) {
if (entry.slug == _selectedSlug) return entry;
}
return widget.entries.isEmpty ? null : widget.entries.first;
}

void _select(PreviewEntry entry) {
setState(() => _selectedSlug = entry.slug);
widget.onSelect?.call(entry);
}

@override
Widget build(BuildContext context) {
return WDiv(
className: 'flex flex-row w-full h-full bg-surface',
children: [
_buildSidebar(),
WDiv(
className: 'flex flex-col flex-1 h-full',
children: [
_buildHeader(context),
// Only the active preview is mounted; it scrolls vertically so a
// tall matrix or screen does not overflow the viewport.
Expanded(
child: SingleChildScrollView(
child: WDiv(className: 'p-6', child: _buildActivePane()),
),
),
],
),
],
);
}

/// The left navigation rail. The list scrolls independently (it can hold many
/// more entries than fit the viewport height) under a fixed header.
Widget _buildSidebar() {
return WDiv(
className:
'flex flex-col w-56 h-full '
'bg-surface-container border-r border-color-border',
children: [
const WText(
'Previews',
className: 'text-fg-muted text-xs font-semibold uppercase px-6 py-4',
),
Expanded(
child: SingleChildScrollView(
child: WDiv(
className: 'flex flex-col px-3 pb-3 gap-1',
children: [
for (final PreviewEntry entry in widget.entries)
WAnchor(
key: ValueKey('magic-preview-nav-${entry.slug}'),
onTap: () => _select(entry),
child: WDiv(
className: entry.slug == _selectedSlug
? 'px-3 py-2 rounded-md bg-primary-container'
: 'px-3 py-2 rounded-md hover:bg-surface-container-high',
child: WText(
entry.label,
className: entry.slug == _selectedSlug
? 'text-sm text-fg'
: 'text-sm text-fg-muted',
),
),
),
],
),
),
),
],
);
}

/// The toolbar with the active entry's title and the wind theme toggle.
Widget _buildHeader(BuildContext context) {
final PreviewEntry? active = _active;
return WDiv(
className:
'flex flex-row items-center justify-between '
'px-6 py-4 border-b border-color-border bg-surface',
children: [
WText(
active?.label ?? 'No previews',
className: 'text-fg text-lg font-semibold',
),
WAnchor(
key: const ValueKey('magic-preview-theme-toggle'),
// Flip dark/light for the whole catalog via wind's theme controller.
onTap: () => WindTheme.of(context).toggleTheme(),
child: WDiv(
className:
'px-3 py-2 rounded-md bg-surface-container '
'border border-color-border',
child: const WText(
'Toggle theme',
className: 'text-sm text-fg-muted',
),
),
),
],
);
}

/// Render the active preview in a bordered card under the ambient wind theme.
Widget _buildActivePane() {
final PreviewEntry? active = _active;
if (active == null) {
return const WText(
'Register a preview to see it here.',
className: 'text-fg-muted text-sm',
);
}
return WDiv(
className: 'p-6 rounded-lg border border-color-border bg-surface',
child: Builder(builder: (paneContext) => active.builder(paneContext)),
);
}
}
Loading