From bd667c62fd03f89d59a8f93198bc8b687979d768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 25 Jun 2026 21:47:03 +0300 Subject: [PATCH 1/9] feat: add dev-only MagicPreview catalog framework Add the MagicPreview framework: a PreviewEntry contract, a MagicPreviewCatalog (sidebar nav + per-preview side-by-side light/dark panes + a global toggle bound to wind's WindThemeController), and a /preview ShellRoute with :component children registered through magic's router. The catalog is dev-only: registerRoutes() is gated by kReleaseMode + a const bool.fromEnvironment('PREVIEW_ENABLED') and snapshots entries inside the function body, so release builds tree-shake the whole catalog (verified in Step 19). Adds the fluttersdk_wind dependency. --- CHANGELOG.md | 17 ++ README.md | 26 ++- lib/preview.dart | 30 ++++ lib/src/preview/magic_preview.dart | 246 ++++++++++++++++++++++++++ lib/src/preview/preview_routes.dart | 139 +++++++++++++++ pubspec.yaml | 1 + test/preview/magic_preview_test.dart | 130 ++++++++++++++ test/preview/preview_routes_test.dart | 90 ++++++++++ 8 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 lib/preview.dart create mode 100644 lib/src/preview/magic_preview.dart create mode 100644 lib/src/preview/preview_routes.dart create mode 100644 test/preview/magic_preview_test.dart create mode 100644 test/preview/preview_routes_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f86b0..37b56f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `MagicPreview` framework: a dev-only component preview catalog hosted behind a + `/preview` ShellRoute. New `package:magic_devtools/preview.dart` barrel exports + the `PreviewEntry` contract (`label`, `slug`, `builder`), the + `MagicPreviewCatalog` widget (sidebar nav plus a light/dark pair per preview, + with a global theme 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` 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 ` drives GoRouter through `MagicRouter.instance.to(path)` instead of falling diff --git a/README.md b/README.md index 86791b4..6faa2c0 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,11 @@ 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/preview.dart` — `MagicPreview` hosts a dev-only component preview catalog behind a `/preview` ShellRoute, tree-shaken from release builds. ## Install @@ -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 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` from a function (never a top-level const, the dart-lang/sdk#33920 retention foot-gun). + ## Ecosystem | Package | | diff --git a/lib/preview.dart b/lib/preview.dart new file mode 100644 index 0000000..a368f92 --- /dev/null +++ b/lib/preview.dart @@ -0,0 +1,30 @@ +/// Magic dev-only component preview catalog barrel. +/// +/// Import this file to host the auto-discovered component previews behind a +/// `/preview` ShellRoute. 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 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'; diff --git a/lib/src/preview/magic_preview.dart b/lib/src/preview/magic_preview.dart new file mode 100644 index 0000000..3baf977 --- /dev/null +++ b/lib/src/preview/magic_preview.dart @@ -0,0 +1,246 @@ +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` 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 sidebar of [PreviewEntry] labels next to the active preview, +/// shown simultaneously in BOTH light and dark so dark/light parity (the +/// catalog's stated purpose) is verifiable at a glance. Each pane wraps the +/// entry's body in its own nested [WindTheme] with a fixed [Brightness], so +/// both brightnesses render no matter which way the global toggle points. +/// +/// The header carries a theme toggle bound to wind's [WindThemeController] via +/// `WindTheme.of(context).toggleTheme()`; it flips the brightness of the host +/// app theme (and any descendant that reads the ambient [WindTheme]), which is +/// how a consumer eyeballs how the whole surface reacts to a global toggle. +/// +/// ### 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 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` ShellRoute wires + /// this to navigation; when null, selection updates local state only. + final ValueChanged? onSelect; + + @override + State createState() => _MagicPreviewCatalogState(); +} + +class _MagicPreviewCatalogState extends State { + 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) { + // 1. Layout: a fixed sidebar next to a scrollable preview surface. + 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), + WDiv(className: 'flex-1 p-6', child: _buildPreviewPanes()), + ], + ), + ], + ); + } + + /// The left navigation rail listing every registered preview. + Widget _buildSidebar() { + return WDiv( + className: + 'flex flex-col w-56 h-full p-3 gap-1 ' + 'bg-surface-container border-r border-color-border', + children: [ + const WText( + 'Previews', + className: 'text-fg-muted text-xs font-semibold uppercase px-3 py-2', + ), + 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 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'), + // Bind dark/light to wind's theme controller. This flips the ambient + // brightness for the host app theme; the per-pane previews below + // pin their own brightness so both always render. + 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 twice: once forced light, once forced dark. + Widget _buildPreviewPanes() { + 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: 'flex flex-row gap-6 items-start', + children: [ + _buildPane(active, Brightness.light, 'Light'), + _buildPane(active, Brightness.dark, 'Dark'), + ], + ); + } + + /// A single brightness-pinned pane wrapping [entry] in its own [WindTheme]. + Widget _buildPane(PreviewEntry entry, Brightness brightness, String label) { + return WDiv( + className: 'flex flex-col flex-1 gap-2', + children: [ + WText( + label, + className: 'text-fg-muted text-xs font-semibold uppercase', + ), + WindTheme( + // A fresh WindThemeData with a fixed brightness so this pane renders + // in [brightness] regardless of the global toggle state. + data: WindThemeData(brightness: brightness, syncWithSystem: false), + child: Builder( + builder: (paneContext) => WDiv( + className: 'p-6 rounded-lg border border-color-border bg-surface', + child: entry.builder(paneContext), + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/preview/preview_routes.dart b/lib/src/preview/preview_routes.dart new file mode 100644 index 0000000..89fc91d --- /dev/null +++ b/lib/src/preview/preview_routes.dart @@ -0,0 +1,139 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import 'magic_preview.dart'; + +/// Compile-time switch for the preview catalog. +/// +/// Defaults to [kDebugMode]: the catalog is reachable in debug and profile +/// builds, never in release. A host can force it off in any mode with +/// `--dart-define=PREVIEW_ENABLED=false`. Because this is a `const`, the +/// release-mode optimizer can fold the guarded branch in +/// [MagicPreview.registerRoutes] dead and tree-shake the entire catalog, +/// every [PreviewEntry], and every builder it transitively references. +const bool kPreviewEnabled = bool.fromEnvironment( + 'PREVIEW_ENABLED', + defaultValue: kDebugMode, +); + +/// **The dev-only preview registration entrypoint.** +/// +/// The generated `_previews.g.dart` (Step 18) returns a `List` +/// from a FUNCTION and feeds it here: +/// +/// ```dart +/// // lib/_previews.g.dart (generated) +/// List previewEntries() => [ ... ]; +/// +/// // RouteServiceProvider.boot() +/// MagicPreview.register(previewEntries()); +/// MagicPreview.registerRoutes(); +/// ``` +/// +/// Entries are held in a function-scoped static list (never a top-level const +/// holding widget refs — the dart-lang/sdk#33920 foot-gun), assigned only from +/// [register], so the release-mode tree-shaker can prove them unreachable once +/// [registerRoutes] folds dead behind [kReleaseMode] + [kPreviewEnabled]. +final class MagicPreview { + MagicPreview._(); + + /// The registered previews. Populated by [register]; empty until then. + static List _entries = const []; + + /// The currently registered previews (read-only view). + static List get entries => List.unmodifiable(_entries); + + /// Register the catalog [entries]. + /// + /// Call this BEFORE [registerRoutes], typically from the consumer's + /// `RouteServiceProvider.boot()`. In release builds the guard in + /// [registerRoutes] short-circuits, so even if entries are registered they + /// are never wired into a route and stay tree-shakeable. + static void register(List entries) { + _entries = entries; + } + + /// Register the `/preview` ShellRoute and its `:component` children. + /// + /// ## Router-lock timing (load-bearing) + /// + /// [MagicRouter] locks its route table the first time `routerConfig` is + /// accessed (`MagicRouter.routerConfig` builds the GoRouter and sets + /// `_isBuilt`, after which `addRoute`/`addLayout` throw `StateError`). The + /// consumer MUST therefore call [registerRoutes] inside a provider `boot()` + /// — which runs during the Magic bootstrap lifecycle, BEFORE `MaterialApp` + /// reads `MagicRouter.instance.routerConfig` — otherwise the preview shell is + /// registered too late and `/preview` silently never appears. + /// + /// ## Release boundary + /// + /// The body is gated by `kReleaseMode` (early return) and [kPreviewEnabled] + /// (a `const bool.fromEnvironment`). Both fold to a dead branch in release, + /// so the route, the [MagicPreviewCatalog], and every registered + /// [PreviewEntry] are proven unreachable and tree-shaken from the bundle. + static void registerRoutes() { + // 1. Hard release boundary: nothing below this line survives a release + // build (const-folded dead by the optimizer). + if (kReleaseMode) return; + if (!kPreviewEnabled) return; + + // 2. Snapshot the entries inside this function body (never a top-level + // const list — sdk#33920) so the catalog widget receives them by value. + final List entries = _entries; + + // 3. The /preview ShellRoute: one persistent catalog shell wrapping an + // index page and one `:component` child page per entry. The shell is the + // catalog itself; child routes drive which entry is active via the + // `component` path parameter. + MagicRoute.group( + prefix: '/preview', + layoutId: 'magic-preview', + layout: (child) => _PreviewShell(entries: entries, child: child), + routes: () { + // Index: /preview shows the first entry. + MagicRoute.page( + '/', + () => const SizedBox.shrink(), + ).name('magic-preview.index'); + + // /preview/:component shows the matching entry; the shell reads the + // `component` path parameter to select it. + MagicRoute.page('/:component', (String component) { + return const SizedBox.shrink(); + }).name('magic-preview.component'); + }, + ); + } + + /// Test-only reset of the registered entries. + @visibleForTesting + static void resetForTesting() { + _entries = const []; + } +} + +/// The persistent shell for the `/preview` route group. +/// +/// The shell IS the catalog: it reads the active `component` path parameter +/// from the router and renders [MagicPreviewCatalog] over the full entry set. +/// The nested child page is intentionally empty — the catalog owns the visual +/// surface; the child only carries the path parameter that selects the entry. +class _PreviewShell extends StatelessWidget { + const _PreviewShell({required this.entries, required this.child}); + + final List entries; + final Widget child; + + @override + Widget build(BuildContext context) { + // Read the active `:component` slug from the router and let the catalog + // navigate between entries by pushing `/preview/`. + final String? slug = MagicRouter.instance.pathParameters['component']; + return MagicPreviewCatalog( + entries: entries, + activeSlug: slug, + onSelect: (entry) => MagicRoute.to('/preview/${entry.slug}'), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1319f4c..bc0b05e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: magic: ^0.0.3 fluttersdk_dusk: ^0.0.8 fluttersdk_telescope: ^0.0.4 + fluttersdk_wind: ^1.1.2 dev_dependencies: flutter_test: diff --git a/test/preview/magic_preview_test.dart b/test/preview/magic_preview_test.dart new file mode 100644 index 0000000..a9e2f27 --- /dev/null +++ b/test/preview/magic_preview_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:magic_devtools/preview.dart'; + +/// Tests for the [MagicPreviewCatalog] dev-only component preview framework. +/// +/// The catalog is the host surface for auto-discovered [PreviewEntry] widgets. +/// Its stated purpose is dark/light parity: every preview must render in BOTH +/// brightnesses. These tests mount the catalog with two fake entries, prove +/// both-brightness rendering, and exercise the wind theme toggle binding. + +/// A trivial preview body that paints a brightness-derived label so a test can +/// read which [Brightness] the surrounding [WindTheme] resolved to. +class _BrightnessProbe extends StatelessWidget { + const _BrightnessProbe({required this.tag}); + + final String tag; + + @override + Widget build(BuildContext context) { + final Brightness brightness = WindTheme.dataOf(context).brightness; + final String mode = brightness == Brightness.dark ? 'dark' : 'light'; + return WText('$tag:$mode', className: 'text-fg'); + } +} + +List _fakeEntries() { + return [ + PreviewEntry( + label: 'Alpha', + slug: 'alpha', + builder: (context) => const _BrightnessProbe(tag: 'alpha'), + ), + PreviewEntry( + label: 'Beta', + slug: 'beta', + builder: (context) => const _BrightnessProbe(tag: 'beta'), + ), + ]; +} + +Widget _mountCatalog(List entries, {String? slug}) { + return WindTheme( + data: WindThemeData(brightness: Brightness.light, syncWithSystem: false), + builder: (context, controller) => MaterialApp( + theme: controller.toThemeData(), + home: MagicPreviewCatalog(entries: entries, activeSlug: slug), + ), + ); +} + +void main() { + setUp(WindParser.clearCache); + + group('PreviewEntry', () { + test('carries label, slug, and builder', () { + final entry = PreviewEntry( + label: 'Button', + slug: 'button', + builder: (context) => const SizedBox.shrink(), + ); + + expect(entry.label, 'Button'); + expect(entry.slug, 'button'); + expect(entry.builder, isNotNull); + }); + }); + + group('MagicPreviewCatalog', () { + testWidgets('renders the active preview in BOTH light and dark', ( + tester, + ) async { + await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'alpha')); + await tester.pump(); + + // The catalog shows the active preview twice: a light pane and a dark + // pane, side by side. Each pane wraps the body in its own WindTheme so + // both brightnesses render regardless of the global toggle. + expect(find.text('alpha:light'), findsOneWidget); + expect(find.text('alpha:dark'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('lists every entry in the sidebar', (tester) async { + await tester.pumpWidget(_mountCatalog(_fakeEntries())); + await tester.pump(); + + expect(find.text('Alpha'), findsWidgets); + expect(find.text('Beta'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('defaults to the first entry when no slug is given', ( + tester, + ) async { + await tester.pumpWidget(_mountCatalog(_fakeEntries())); + await tester.pump(); + + expect(find.text('alpha:light'), findsOneWidget); + expect(find.text('alpha:dark'), findsOneWidget); + }); + + testWidgets('toggling the wind theme flips the global brightness', ( + tester, + ) async { + await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'beta')); + await tester.pump(); + + // 1. Capture the controller through the catalog subtree. + final BuildContext context = tester.element( + find.byType(MagicPreviewCatalog), + ); + final WindThemeController controller = WindTheme.of(context); + expect(controller.brightness, Brightness.light); + + // 2. The toggle control drives WindTheme.of(context).toggleTheme(). + await tester.tap( + find.byKey(const ValueKey('magic-preview-theme-toggle')), + ); + await tester.pump(); + + expect(controller.brightness, Brightness.dark); + // Both panes still render after the global toggle, no exception. + expect(find.text('beta:light'), findsOneWidget); + expect(find.text('beta:dark'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/preview/preview_routes_test.dart b/test/preview/preview_routes_test.dart new file mode 100644 index 0000000..5c01e08 --- /dev/null +++ b/test/preview/preview_routes_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_devtools/preview.dart'; + +/// Tests for [MagicPreview]'s registration entrypoint and the `/preview` +/// ShellRoute it wires into [MagicRouter]. +/// +/// The release boundary itself (the `kReleaseMode` early return + tree-shaking) +/// is asserted by Step 19 (a release-bundle symbol grep); these tests cover the +/// debug-mode behavior: entries round-trip, and `registerRoutes` adds exactly +/// one `magic-preview` layout BEFORE the router locks. + +List _entries() { + return [ + PreviewEntry( + label: 'Alpha', + slug: 'alpha', + builder: (context) => const SizedBox.shrink(), + ), + PreviewEntry( + label: 'Beta', + slug: 'beta', + builder: (context) => const SizedBox.shrink(), + ), + ]; +} + +void main() { + setUp(() { + MagicRouter.reset(); + MagicPreview.resetForTesting(); + }); + + tearDown(() { + MagicRouter.reset(); + MagicPreview.resetForTesting(); + }); + + group('MagicPreview.register', () { + test('round-trips the registered entries', () { + final entries = _entries(); + MagicPreview.register(entries); + + expect(MagicPreview.entries, hasLength(2)); + expect(MagicPreview.entries.map((e) => e.slug), ['alpha', 'beta']); + }); + + test('exposes an unmodifiable view of the entries', () { + MagicPreview.register(_entries()); + + expect( + () => MagicPreview.entries.add( + PreviewEntry( + label: 'X', + slug: 'x', + builder: (context) => const SizedBox.shrink(), + ), + ), + throwsUnsupportedError, + ); + }); + }); + + group('MagicPreview.registerRoutes', () { + test('registers a single magic-preview layout in debug builds', () { + // In the test runtime kReleaseMode is false and kPreviewEnabled defaults + // to kDebugMode (true), so the guarded body runs. + MagicPreview.register(_entries()); + MagicPreview.registerRoutes(); + + final layouts = MagicRouter.instance.mergedLayouts; + final previewLayouts = layouts + .where((l) => l.id == 'magic-preview') + .toList(); + + expect(previewLayouts, hasLength(1)); + // The shell wraps an index page plus one `:component` child page. + expect(previewLayouts.single.children, hasLength(2)); + }); + + test('registers before the router locks (no StateError)', () { + MagicPreview.register(_entries()); + + // Registration must succeed because we have not accessed routerConfig + // yet; doing so would lock the route table and make addLayout throw. + expect(MagicPreview.registerRoutes, returnsNormally); + }); + }); +} From a230b0302a1c996524aaf6525b6b035f25be7d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 00:10:07 +0300 Subject: [PATCH 2/9] fix: /preview crash + catalog theme/overflow (found via real e2e) Three runtime defects the analyze/build/unit-test gates missed, caught by actually running the app in a browser: - The catalog group's index child path '/' composed to '/preview/', which trips go_router's trailing-slash assertion and blanked the WHOLE app on every route. Index child path is now '' (composes to '/preview'). - Each light/dark preview pane used a bare WindThemeData with no aliases, so component semantic tokens resolved to no-ops and previews rendered Flutter's red unstyled-text fallback. Panes now copyWith(brightness:) the ambient app theme, keeping aliases + brand colors. - The preview surface and panes now scroll (vertical surface, horizontal panes) so wide variant matrices no longer overflow the side-by-side layout. Release strip re-verified: the catalog still tree-shakes from release. --- CHANGELOG.md | 5 +++++ lib/src/preview/magic_preview.dart | 33 +++++++++++++++++++++++------ lib/src/preview/preview_routes.dart | 7 ++++-- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b56f9..ae1bc46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **`/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. + ### Added - `MagicPreview` framework: a dev-only component preview catalog hosted behind a diff --git a/lib/src/preview/magic_preview.dart b/lib/src/preview/magic_preview.dart index 3baf977..2dadb21 100644 --- a/lib/src/preview/magic_preview.dart +++ b/lib/src/preview/magic_preview.dart @@ -131,7 +131,13 @@ class _MagicPreviewCatalogState extends State { className: 'flex flex-col flex-1 h-full', children: [ _buildHeader(context), - WDiv(className: 'flex-1 p-6', child: _buildPreviewPanes()), + // The preview surface scrolls vertically; tall variant matrices do + // not overflow the viewport. Each pane scrolls horizontally. + Expanded( + child: SingleChildScrollView( + child: WDiv(className: 'p-6', child: _buildPreviewPanes()), + ), + ), ], ), ], @@ -222,6 +228,14 @@ class _MagicPreviewCatalogState extends State { /// A single brightness-pinned pane wrapping [entry] in its own [WindTheme]. Widget _buildPane(PreviewEntry entry, Brightness brightness, String label) { + // Inherit the host app's theme (its semantic aliases + brand colors) and + // only PIN the brightness. A fresh bare WindThemeData would carry no + // aliases, so the components' semantic tokens (text-fg, bg-surface, ...) + // would resolve to no-ops and every preview would render Flutter's red + // unstyled-text fallback. copyWith keeps the ambient aliases/colors. + final WindThemeData paneTheme = WindTheme.of( + context, + ).data.copyWith(brightness: brightness, syncWithSystem: false); return WDiv( className: 'flex flex-col flex-1 gap-2', children: [ @@ -230,13 +244,18 @@ class _MagicPreviewCatalogState extends State { className: 'text-fg-muted text-xs font-semibold uppercase', ), WindTheme( - // A fresh WindThemeData with a fixed brightness so this pane renders - // in [brightness] regardless of the global toggle state. - data: WindThemeData(brightness: brightness, syncWithSystem: false), + data: paneTheme, child: Builder( - builder: (paneContext) => WDiv( - className: 'p-6 rounded-lg border border-color-border bg-surface', - child: entry.builder(paneContext), + // The pane scrolls horizontally so a wide variant matrix does not + // overflow the half-width pane (the catalog shows light + dark side + // by side); vertical scrolling is handled by the outer surface. + builder: (paneContext) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: WDiv( + className: + 'p-6 rounded-lg border border-color-border bg-surface', + child: entry.builder(paneContext), + ), ), ), ), diff --git a/lib/src/preview/preview_routes.dart b/lib/src/preview/preview_routes.dart index 89fc91d..fdece85 100644 --- a/lib/src/preview/preview_routes.dart +++ b/lib/src/preview/preview_routes.dart @@ -91,9 +91,12 @@ final class MagicPreview { layoutId: 'magic-preview', layout: (child) => _PreviewShell(entries: entries, child: child), routes: () { - // Index: /preview shows the first entry. + // Index: /preview shows the first entry. The child path is EMPTY (not + // '/') so the composed full path is exactly '/preview'; go_router + // asserts a route path may not end with '/' (except the root), so a '/' + // child here ('/preview/') crashes router configuration at boot. MagicRoute.page( - '/', + '', () => const SizedBox.shrink(), ).name('magic-preview.index'); From e924b4a2504506ab770e45ddbccea825d14ee424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 01:24:32 +0300 Subject: [PATCH 3/9] fix: /preview deep links select the right entry (drop persistent shell) The catalog used a persistent ShellRoute whose _PreviewShell read stale global pathParameters and never rebuilt when only the child route swapped, so /preview/ always showed the first entry. Replace it with two plain pages: /preview (index) and /preview/:component, the latter receiving the slug in its builder and rebuilding on navigation. Add a regression test asserting both paths register and none ends with '/' (the boot-crash guard). --- CHANGELOG.md | 1 + lib/src/preview/preview_routes.dart | 85 +++++++++------------------ test/preview/preview_routes_test.dart | 24 +++++--- 3 files changed, 47 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae1bc46..aa43484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **`/preview/` 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. diff --git a/lib/src/preview/preview_routes.dart b/lib/src/preview/preview_routes.dart index fdece85..7955fe5 100644 --- a/lib/src/preview/preview_routes.dart +++ b/lib/src/preview/preview_routes.dart @@ -1,5 +1,4 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import 'magic_preview.dart'; @@ -54,17 +53,18 @@ final class MagicPreview { _entries = entries; } - /// Register the `/preview` ShellRoute and its `:component` children. + /// Register the `/preview` catalog page and its `/preview/:component` deep + /// link. /// /// ## Router-lock timing (load-bearing) /// /// [MagicRouter] locks its route table the first time `routerConfig` is /// accessed (`MagicRouter.routerConfig` builds the GoRouter and sets - /// `_isBuilt`, after which `addRoute`/`addLayout` throw `StateError`). The - /// consumer MUST therefore call [registerRoutes] inside a provider `boot()` - /// — which runs during the Magic bootstrap lifecycle, BEFORE `MaterialApp` - /// reads `MagicRouter.instance.routerConfig` — otherwise the preview shell is - /// registered too late and `/preview` silently never appears. + /// `_isBuilt`, after which `addRoute` throws `StateError`). The consumer MUST + /// therefore call [registerRoutes] inside a provider `boot()` — which runs + /// during the Magic bootstrap lifecycle, BEFORE `MaterialApp` reads + /// `MagicRouter.instance.routerConfig` — otherwise the routes register too + /// late and `/preview` silently never appears. /// /// ## Release boundary /// @@ -82,31 +82,29 @@ final class MagicPreview { // const list — sdk#33920) so the catalog widget receives them by value. final List entries = _entries; - // 3. The /preview ShellRoute: one persistent catalog shell wrapping an - // index page and one `:component` child page per entry. The shell is the - // catalog itself; child routes drive which entry is active via the - // `component` path parameter. - MagicRoute.group( - prefix: '/preview', - layoutId: 'magic-preview', - layout: (child) => _PreviewShell(entries: entries, child: child), - routes: () { - // Index: /preview shows the first entry. The child path is EMPTY (not - // '/') so the composed full path is exactly '/preview'; go_router - // asserts a route path may not end with '/' (except the root), so a '/' - // child here ('/preview/') crashes router configuration at boot. - MagicRoute.page( - '', - () => const SizedBox.shrink(), - ).name('magic-preview.index'); + // 3. Two plain pages render the catalog DIRECTLY (no persistent shell): the + // index shows the first entry; `/preview/:component` selects an entry by + // its slug. The `:component` builder RECEIVES the slug and rebuilds on + // every navigation, so deep-linking (`/preview/`) and sidebar + // selection both resolve the right entry. A persistent ShellRoute would + // NOT rebuild when only the child route swapped, leaving the catalog + // stuck on the first entry. + MagicRoute.page( + '/preview', + () => MagicPreviewCatalog( + entries: entries, + onSelect: (entry) => MagicRoute.to('/preview/${entry.slug}'), + ), + ).name('magic-preview.index'); - // /preview/:component shows the matching entry; the shell reads the - // `component` path parameter to select it. - MagicRoute.page('/:component', (String component) { - return const SizedBox.shrink(); - }).name('magic-preview.component'); - }, - ); + MagicRoute.page( + '/preview/:component', + (String component) => MagicPreviewCatalog( + entries: entries, + activeSlug: component, + onSelect: (entry) => MagicRoute.to('/preview/${entry.slug}'), + ), + ).name('magic-preview.component'); } /// Test-only reset of the registered entries. @@ -115,28 +113,3 @@ final class MagicPreview { _entries = const []; } } - -/// The persistent shell for the `/preview` route group. -/// -/// The shell IS the catalog: it reads the active `component` path parameter -/// from the router and renders [MagicPreviewCatalog] over the full entry set. -/// The nested child page is intentionally empty — the catalog owns the visual -/// surface; the child only carries the path parameter that selects the entry. -class _PreviewShell extends StatelessWidget { - const _PreviewShell({required this.entries, required this.child}); - - final List entries; - final Widget child; - - @override - Widget build(BuildContext context) { - // Read the active `:component` slug from the router and let the catalog - // navigate between entries by pushing `/preview/`. - final String? slug = MagicRouter.instance.pathParameters['component']; - return MagicPreviewCatalog( - entries: entries, - activeSlug: slug, - onSelect: (entry) => MagicRoute.to('/preview/${entry.slug}'), - ); - } -} diff --git a/test/preview/preview_routes_test.dart b/test/preview/preview_routes_test.dart index 5c01e08..341a71f 100644 --- a/test/preview/preview_routes_test.dart +++ b/test/preview/preview_routes_test.dart @@ -63,20 +63,30 @@ void main() { }); group('MagicPreview.registerRoutes', () { - test('registers a single magic-preview layout in debug builds', () { + test('registers the /preview page and the :component deep link', () { // In the test runtime kReleaseMode is false and kPreviewEnabled defaults // to kDebugMode (true), so the guarded body runs. MagicPreview.register(_entries()); MagicPreview.registerRoutes(); - final layouts = MagicRouter.instance.mergedLayouts; - final previewLayouts = layouts - .where((l) => l.id == 'magic-preview') + final paths = MagicRouter.instance.routes + .map((RouteDefinition r) => r.fullPath) .toList(); - expect(previewLayouts, hasLength(1)); - // The shell wraps an index page plus one `:component` child page. - expect(previewLayouts.single.children, hasLength(2)); + // Two plain pages render the catalog directly (no persistent shell): the + // index and the slug deep link. The :component builder rebuilds on nav so + // direct navigation to /preview/ selects the right entry. + expect(paths, contains('/preview')); + expect(paths, contains('/preview/:component')); + + // Regression guard for the boot crash: go_router asserts a route path may + // not end with '/' (except the root). A '/preview/' here blanks the whole + // app at router-config build time. + expect( + paths.where((String p) => p != '/' && p.endsWith('/')), + isEmpty, + reason: 'no registered route path may end with "/"', + ); }); test('registers before the router locks (no StateError)', () { From 1325a8deb1ddd2236726f7963e6ffa33ffa6d157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 01:31:14 +0300 Subject: [PATCH 4/9] docs: reorder CHANGELOG (Added before Fixed) + drop em-dashes from README Keep a Changelog section order; replace em-dashes with ':'/';'/',' in the README to satisfy the no-em-dash convention. Content unchanged. --- CHANGELOG.md | 13 +++++++------ README.md | 18 +++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa43484..02e5aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed -- **`/preview/` 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. - ### Added - `MagicPreview` framework: a dev-only component preview catalog hosted behind a @@ -39,6 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `false` when the router is not yet initialised (catches `StateError`). `resetForTesting()` clears the adapter with `DuskPlugin.registerNavigateAdapter(null)`. +### Fixed + +- **`/preview/` 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 diff --git a/README.md b/README.md index 6faa2c0..2eb652c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Magic adapters for the FlutterSDK dev-tooling ecosystem.
- Wire Magic's runtime into fluttersdk_dusk (E2E driver) and fluttersdk_telescope (runtime inspector) — debug-only, zero release cost. + Wire Magic's runtime into fluttersdk_dusk (E2E driver) and fluttersdk_telescope (runtime inspector), debug-only with zero release cost.

@@ -21,23 +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. 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/preview.dart` — `MagicPreview` hosts a dev-only component preview catalog behind a `/preview` ShellRoute, tree-shaken from release builds. +- `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 behind a `/preview` ShellRoute, 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: @@ -134,11 +134,11 @@ dependency_overrides: ## License -MIT — see [LICENSE](LICENSE) for details. +MIT, see [LICENSE](LICENSE) for details. ---

Built with care by FlutterSDK
- If magic_devtools helps you, give it a star — it helps others discover it. + If magic_devtools helps you, give it a star; it helps others discover it.

From 4c138101c02a52a7374a6ad649b827c7024478c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 03:27:11 +0300 Subject: [PATCH 5/9] feat(preview): render a single pane instead of side-by-side light/dark The catalog now shows one preview pane under the ambient wind theme; the header "Toggle theme" button flips brightness for the whole catalog. A single pane also mounts each controller-backed feature-screen preview once, avoiding the duplicate-mount churn of the two-pane layout. --- lib/src/preview/magic_preview.dart | 71 ++++++++-------------------- test/preview/magic_preview_test.dart | 23 +++++---- 2 files changed, 32 insertions(+), 62 deletions(-) diff --git a/lib/src/preview/magic_preview.dart b/lib/src/preview/magic_preview.dart index 2dadb21..dce35e3 100644 --- a/lib/src/preview/magic_preview.dart +++ b/lib/src/preview/magic_preview.dart @@ -33,11 +33,11 @@ final class PreviewEntry { /// **The dev-only component preview catalog.** /// -/// Renders a sidebar of [PreviewEntry] labels next to the active preview, -/// shown simultaneously in BOTH light and dark so dark/light parity (the -/// catalog's stated purpose) is verifiable at a glance. Each pane wraps the -/// entry's body in its own nested [WindTheme] with a fixed [Brightness], so -/// both brightnesses render no matter which way the global toggle points. +/// Renders a sidebar of [PreviewEntry] labels next to the active preview in a +/// single pane under the ambient wind theme. The header carries a "Toggle +/// theme" button so the consumer flips light/dark for the whole catalog from +/// one pane (no side-by-side light/dark columns). The single pane also mounts +/// each controller-backed feature-screen preview once. /// /// The header carries a theme toggle bound to wind's [WindThemeController] via /// `WindTheme.of(context).toggleTheme()`; it flips the brightness of the host @@ -190,8 +190,8 @@ class _MagicPreviewCatalogState extends State { WAnchor( key: const ValueKey('magic-preview-theme-toggle'), // Bind dark/light to wind's theme controller. This flips the ambient - // brightness for the host app theme; the per-pane previews below - // pin their own brightness so both always render. + // brightness for the whole catalog; the single preview pane below + // re-renders in the toggled brightness. onTap: () => WindTheme.of(context).toggleTheme(), child: WDiv( className: @@ -207,7 +207,13 @@ class _MagicPreviewCatalogState extends State { ); } - /// Render the active preview twice: once forced light, once forced dark. + /// Render the active preview in a SINGLE pane under the ambient wind theme. + /// + /// The catalog shows one rendering; the header "Toggle theme" button flips + /// the global wind brightness so the consumer sees light or dark from the + /// same pane (no side-by-side light/dark columns). A single pane also means + /// each controller-backed feature-screen preview mounts ONCE, avoiding the + /// duplicate-mount churn of a two-pane layout. Widget _buildPreviewPanes() { final PreviewEntry? active = _active; if (active == null) { @@ -217,49 +223,14 @@ class _MagicPreviewCatalogState extends State { ); } + // Full-width single pane: component matrices use the `wrap` utility and + // feature screens are responsive, so content fits the pane width. No + // horizontal scroll wrapper (an unbounded-width parent would break overlay + // positioning for popovers/selects and give children no width to lay out + // against); the outer surface handles vertical scrolling. return WDiv( - className: 'flex flex-row gap-6 items-start', - children: [ - _buildPane(active, Brightness.light, 'Light'), - _buildPane(active, Brightness.dark, 'Dark'), - ], - ); - } - - /// A single brightness-pinned pane wrapping [entry] in its own [WindTheme]. - Widget _buildPane(PreviewEntry entry, Brightness brightness, String label) { - // Inherit the host app's theme (its semantic aliases + brand colors) and - // only PIN the brightness. A fresh bare WindThemeData would carry no - // aliases, so the components' semantic tokens (text-fg, bg-surface, ...) - // would resolve to no-ops and every preview would render Flutter's red - // unstyled-text fallback. copyWith keeps the ambient aliases/colors. - final WindThemeData paneTheme = WindTheme.of( - context, - ).data.copyWith(brightness: brightness, syncWithSystem: false); - return WDiv( - className: 'flex flex-col flex-1 gap-2', - children: [ - WText( - label, - className: 'text-fg-muted text-xs font-semibold uppercase', - ), - WindTheme( - data: paneTheme, - child: Builder( - // The pane scrolls horizontally so a wide variant matrix does not - // overflow the half-width pane (the catalog shows light + dark side - // by side); vertical scrolling is handled by the outer surface. - builder: (paneContext) => SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: WDiv( - className: - 'p-6 rounded-lg border border-color-border bg-surface', - child: entry.builder(paneContext), - ), - ), - ), - ), - ], + className: 'p-6 rounded-lg border border-color-border bg-surface', + child: Builder(builder: (paneContext) => active.builder(paneContext)), ); } } diff --git a/test/preview/magic_preview_test.dart b/test/preview/magic_preview_test.dart index a9e2f27..1635b03 100644 --- a/test/preview/magic_preview_test.dart +++ b/test/preview/magic_preview_test.dart @@ -6,9 +6,9 @@ import 'package:magic_devtools/preview.dart'; /// Tests for the [MagicPreviewCatalog] dev-only component preview framework. /// /// The catalog is the host surface for auto-discovered [PreviewEntry] widgets. -/// Its stated purpose is dark/light parity: every preview must render in BOTH -/// brightnesses. These tests mount the catalog with two fake entries, prove -/// both-brightness rendering, and exercise the wind theme toggle binding. +/// The catalog renders a single pane in the ambient brightness; the header +/// toggle flips light/dark. These tests mount the catalog with two fake +/// entries, prove single-pane rendering, and exercise the theme toggle binding. /// A trivial preview body that paints a brightness-derived label so a test can /// read which [Brightness] the surrounding [WindTheme] resolved to. @@ -68,17 +68,16 @@ void main() { }); group('MagicPreviewCatalog', () { - testWidgets('renders the active preview in BOTH light and dark', ( + testWidgets('renders the active preview once in the ambient brightness', ( tester, ) async { await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'alpha')); await tester.pump(); - // The catalog shows the active preview twice: a light pane and a dark - // pane, side by side. Each pane wraps the body in its own WindTheme so - // both brightnesses render regardless of the global toggle. + // The catalog shows a SINGLE pane in the ambient brightness (light here); + // the header toggle flips it. There is no side-by-side dark pane. expect(find.text('alpha:light'), findsOneWidget); - expect(find.text('alpha:dark'), findsOneWidget); + expect(find.text('alpha:dark'), findsNothing); expect(tester.takeException(), isNull); }); @@ -98,10 +97,10 @@ void main() { await tester.pump(); expect(find.text('alpha:light'), findsOneWidget); - expect(find.text('alpha:dark'), findsOneWidget); + expect(find.text('alpha:dark'), findsNothing); }); - testWidgets('toggling the wind theme flips the global brightness', ( + testWidgets('toggling the wind theme flips the pane brightness', ( tester, ) async { await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'beta')); @@ -121,9 +120,9 @@ void main() { await tester.pump(); expect(controller.brightness, Brightness.dark); - // Both panes still render after the global toggle, no exception. - expect(find.text('beta:light'), findsOneWidget); + // The single pane re-renders in the toggled (dark) brightness. expect(find.text('beta:dark'), findsOneWidget); + expect(find.text('beta:light'), findsNothing); expect(tester.takeException(), isNull); }); }); From 0d9cb10d62236509281b2d82110a1b2206f6c2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 11:48:51 +0300 Subject: [PATCH 6/9] feat(preview): stacked single-page catalog with jump-to-section sidebar Rework MagicPreviewCatalog to match the idea-design reference catalog: a single vertically scrolling page that stacks every registered preview as its own labeled section (heading + bordered card), instead of showing one preview at a time. The left sidebar is now jump-to-section navigation - tapping an item (or deep-linking /preview/) scrolls that section into view via Scrollable.ensureVisible. Because controller-backed screen previews defer-mount and settle their heights over a few frames, the scroll re-runs once after a short, dispose-cancelled delay so the section lands at the top precisely. Header keeps the global light/dark toggle. --- CHANGELOG.md | 7 +- lib/src/preview/magic_preview.dart | 193 +++++++++++++++++---------- test/preview/magic_preview_test.dart | 60 ++++++--- 3 files changed, 169 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e5aec..40e9d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MagicPreview` framework: a dev-only component preview catalog hosted behind a `/preview` ShellRoute. New `package:magic_devtools/preview.dart` barrel exports the `PreviewEntry` contract (`label`, `slug`, `builder`), the - `MagicPreviewCatalog` widget (sidebar nav plus a light/dark pair per preview, - with a global theme toggle bound to wind's `WindTheme.of(context).toggleTheme()`), + `MagicPreviewCatalog` widget (a single vertically scrolling page that stacks + every registered preview as its own labeled section; the left sidebar is + jump-to-section navigation — tapping an item, or deep-linking + `/preview/`, scrolls that section into view — 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 diff --git a/lib/src/preview/magic_preview.dart b/lib/src/preview/magic_preview.dart index dce35e3..84e34f7 100644 --- a/lib/src/preview/magic_preview.dart +++ b/lib/src/preview/magic_preview.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; @@ -33,16 +35,13 @@ final class PreviewEntry { /// **The dev-only component preview catalog.** /// -/// Renders a sidebar of [PreviewEntry] labels next to the active preview in a -/// single pane under the ambient wind theme. The header carries a "Toggle -/// theme" button so the consumer flips light/dark for the whole catalog from -/// one pane (no side-by-side light/dark columns). The single pane also mounts -/// each controller-backed feature-screen preview once. -/// -/// The header carries a theme toggle bound to wind's [WindThemeController] via -/// `WindTheme.of(context).toggleTheme()`; it flips the brightness of the host -/// app theme (and any descendant that reads the ambient [WindTheme]), which is -/// how a consumer eyeballs how the whole surface reacts to a global toggle. +/// Modeled on the `idea-design` reference catalog: a single, vertically +/// scrolling page that stacks EVERY registered [PreviewEntry] as its own +/// labeled section (heading + bordered card), with a left sidebar that acts as +/// jump-to-section navigation rather than a one-at-a-time selector. Tapping a +/// sidebar item scrolls its section into view; deep-linking `/preview/` +/// scrolls to that section on mount. The header carries a "Toggle theme" button +/// so the consumer flips light/dark for the whole catalog from one place. /// /// ### Release boundary /// @@ -55,8 +54,8 @@ final class PreviewEntry { class MagicPreviewCatalog extends StatefulWidget { /// Creates the catalog over [entries]. /// - /// [activeSlug] selects which entry is shown; when null (or unmatched) the - /// first entry is shown. + /// [activeSlug] selects which section to scroll to on mount; when null (or + /// unmatched) the page opens at the top. const MagicPreviewCatalog({ super.key, required this.entries, @@ -68,11 +67,13 @@ class MagicPreviewCatalog extends StatefulWidget { /// release boundary can stay airtight. final List entries; - /// The slug of the entry to display; null shows the first entry. + /// The slug of the section to scroll into view on mount; null opens at the + /// top. final String? activeSlug; - /// Invoked when a sidebar item is tapped. The `/preview` ShellRoute wires - /// this to navigation; when null, selection updates local state only. + /// Invoked when a sidebar item is tapped. The `/preview` route wires this to + /// navigation (deep-link sync); the section is also scrolled into view + /// locally regardless. final ValueChanged? onSelect; @override @@ -80,49 +81,95 @@ class MagicPreviewCatalog extends StatefulWidget { } class _MagicPreviewCatalogState extends State { - late String _selectedSlug; + final ScrollController _scrollController = ScrollController(); + + /// One key per entry slug, attached to that section so a sidebar tap (or a + /// deep-link on mount) can scroll the section into view via + /// [Scrollable.ensureVisible]. + late Map _sectionKeys; + + /// The slug highlighted in the sidebar (the last selected / deep-linked one). + String? _activeSlug; + + /// Pending retry of the scroll-into-view (see [_scrollToSlug]); cancelled on + /// dispose so no timer outlives the widget (flutter_test flags stragglers). + Timer? _scrollRetryTimer; @override void initState() { super.initState(); - _selectedSlug = _resolveInitialSlug(); + _rebuildKeys(); + _activeSlug = widget.activeSlug; + _scheduleScrollTo(widget.activeSlug); } @override void didUpdateWidget(MagicPreviewCatalog oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.activeSlug != oldWidget.activeSlug || - widget.entries != oldWidget.entries) { - _selectedSlug = _resolveInitialSlug(); + if (widget.entries != oldWidget.entries) { + _rebuildKeys(); + } + if (widget.activeSlug != oldWidget.activeSlug) { + _activeSlug = widget.activeSlug; + _scheduleScrollTo(widget.activeSlug); } } - /// 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; + @override + void dispose() { + _scrollRetryTimer?.cancel(); + _scrollController.dispose(); + super.dispose(); } - 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 _rebuildKeys() { + _sectionKeys = { + for (final PreviewEntry entry in widget.entries) + entry.slug: GlobalKey( + debugLabel: 'magic-preview-section-${entry.slug}', + ), + }; + } + + /// Scroll the [slug] section into view after the next frame (so the section's + /// key has a mounted context to resolve against). + void _scheduleScrollTo(String? slug) { + if (slug == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToSlug(slug)); + } + + void _scrollToSlug(String slug) { + _ensureVisible(slug); + // Controller-backed screen sections defer-mount and settle their heights + // over a few frames, which can leave the first scroll short. Re-run once + // the layout has settled so the section lands at the top precisely. + _scrollRetryTimer?.cancel(); + _scrollRetryTimer = Timer( + const Duration(milliseconds: 450), + () => _ensureVisible(slug), + ); + } + + void _ensureVisible(String slug) { + if (!mounted) return; + final BuildContext? sectionContext = _sectionKeys[slug]?.currentContext; + if (sectionContext == null) return; + Scrollable.ensureVisible( + sectionContext, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: 0.0, + ); } void _select(PreviewEntry entry) { - setState(() => _selectedSlug = entry.slug); + setState(() => _activeSlug = entry.slug); + _scrollToSlug(entry.slug); widget.onSelect?.call(entry); } @override Widget build(BuildContext context) { - // 1. Layout: a fixed sidebar next to a scrollable preview surface. return WDiv( className: 'flex flex-row w-full h-full bg-surface', children: [ @@ -131,11 +178,24 @@ class _MagicPreviewCatalogState extends State { className: 'flex flex-col flex-1 h-full', children: [ _buildHeader(context), - // The preview surface scrolls vertically; tall variant matrices do - // not overflow the viewport. Each pane scrolls horizontally. + // The whole catalog scrolls vertically; every section is stacked + // here and reachable via the sidebar jump links. Expanded( child: SingleChildScrollView( - child: WDiv(className: 'p-6', child: _buildPreviewPanes()), + controller: _scrollController, + child: WDiv( + className: 'flex flex-col gap-12 p-6', + children: [ + if (widget.entries.isEmpty) + const WText( + 'Register a preview to see it here.', + className: 'text-fg-muted text-sm', + ) + else + for (final PreviewEntry entry in widget.entries) + _buildSection(entry), + ], + ), ), ), ], @@ -144,7 +204,7 @@ class _MagicPreviewCatalogState extends State { ); } - /// The left navigation rail listing every registered preview. + /// The left navigation rail: a jump link per registered preview. Widget _buildSidebar() { return WDiv( className: @@ -160,12 +220,12 @@ class _MagicPreviewCatalogState extends State { key: ValueKey('magic-preview-nav-${entry.slug}'), onTap: () => _select(entry), child: WDiv( - className: entry.slug == _selectedSlug + className: entry.slug == _activeSlug ? '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 + className: entry.slug == _activeSlug ? 'text-sm text-fg' : 'text-sm text-fg-muted', ), @@ -177,21 +237,15 @@ class _MagicPreviewCatalogState extends State { /// The toolbar with the 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', - ), + const WText('Components', className: 'text-fg text-lg font-semibold'), WAnchor( key: const ValueKey('magic-preview-theme-toggle'), - // Bind dark/light to wind's theme controller. This flips the ambient - // brightness for the whole catalog; the single preview pane below - // re-renders in the toggled brightness. + // Flip dark/light for the whole catalog via wind's theme controller. onTap: () => WindTheme.of(context).toggleTheme(), child: WDiv( className: @@ -207,30 +261,23 @@ class _MagicPreviewCatalogState extends State { ); } - /// Render the active preview in a SINGLE pane under the ambient wind theme. - /// - /// The catalog shows one rendering; the header "Toggle theme" button flips - /// the global wind brightness so the consumer sees light or dark from the - /// same pane (no side-by-side light/dark columns). A single pane also means - /// each controller-backed feature-screen preview mounts ONCE, avoiding the - /// duplicate-mount churn of a two-pane layout. - Widget _buildPreviewPanes() { - final PreviewEntry? active = _active; - if (active == null) { - return const WText( - 'Register a preview to see it here.', - className: 'text-fg-muted text-sm', - ); - } - - // Full-width single pane: component matrices use the `wrap` utility and - // feature screens are responsive, so content fits the pane width. No - // horizontal scroll wrapper (an unbounded-width parent would break overlay - // positioning for popovers/selects and give children no width to lay out - // against); the outer surface handles vertical scrolling. + /// A single labeled section: heading + underline + a bordered card hosting + /// the entry's preview body. Keyed so the sidebar can scroll to it. + Widget _buildSection(PreviewEntry entry) { return WDiv( - className: 'p-6 rounded-lg border border-color-border bg-surface', - child: Builder(builder: (paneContext) => active.builder(paneContext)), + key: _sectionKeys[entry.slug], + className: 'flex flex-col gap-4', + children: [ + WText( + entry.label, + className: + 'text-fg text-lg font-semibold border-b border-color-border pb-2', + ), + WDiv( + className: 'p-6 rounded-lg border border-color-border bg-surface', + child: Builder(builder: (paneContext) => entry.builder(paneContext)), + ), + ], ); } } diff --git a/test/preview/magic_preview_test.dart b/test/preview/magic_preview_test.dart index 1635b03..9eb21ed 100644 --- a/test/preview/magic_preview_test.dart +++ b/test/preview/magic_preview_test.dart @@ -6,9 +6,11 @@ import 'package:magic_devtools/preview.dart'; /// Tests for the [MagicPreviewCatalog] dev-only component preview framework. /// /// The catalog is the host surface for auto-discovered [PreviewEntry] widgets. -/// The catalog renders a single pane in the ambient brightness; the header -/// toggle flips light/dark. These tests mount the catalog with two fake -/// entries, prove single-pane rendering, and exercise the theme toggle binding. +/// It stacks EVERY entry as its own section on one scrolling page (idea-design +/// parity); the sidebar is jump-to-section navigation, and the header toggle +/// flips light/dark for the whole page. These tests mount the catalog with two +/// fake entries, prove every section renders, exercise the theme toggle, and +/// check that a sidebar tap reports the selected entry. /// A trivial preview body that paints a brightness-derived label so a test can /// read which [Brightness] the surrounding [WindTheme] resolved to. @@ -40,12 +42,20 @@ List _fakeEntries() { ]; } -Widget _mountCatalog(List entries, {String? slug}) { +Widget _mountCatalog( + List entries, { + String? slug, + ValueChanged? onSelect, +}) { return WindTheme( data: WindThemeData(brightness: Brightness.light, syncWithSystem: false), builder: (context, controller) => MaterialApp( theme: controller.toThemeData(), - home: MagicPreviewCatalog(entries: entries, activeSlug: slug), + home: MagicPreviewCatalog( + entries: entries, + activeSlug: slug, + onSelect: onSelect, + ), ), ); } @@ -68,16 +78,18 @@ void main() { }); group('MagicPreviewCatalog', () { - testWidgets('renders the active preview once in the ambient brightness', ( + testWidgets('stacks every entry as a section in the ambient brightness', ( tester, ) async { await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'alpha')); await tester.pump(); - // The catalog shows a SINGLE pane in the ambient brightness (light here); - // the header toggle flips it. There is no side-by-side dark pane. + // Every entry renders its own section (all on one scrolling page) in the + // ambient brightness (light here); the header toggle flips them. expect(find.text('alpha:light'), findsOneWidget); + expect(find.text('beta:light'), findsOneWidget); expect(find.text('alpha:dark'), findsNothing); + expect(find.text('beta:dark'), findsNothing); expect(tester.takeException(), isNull); }); @@ -85,24 +97,21 @@ void main() { await tester.pumpWidget(_mountCatalog(_fakeEntries())); await tester.pump(); + // Each label appears in the sidebar nav and as a section heading. expect(find.text('Alpha'), findsWidgets); expect(find.text('Beta'), findsWidgets); expect(tester.takeException(), isNull); }); - testWidgets('defaults to the first entry when no slug is given', ( - tester, - ) async { + testWidgets('renders all sections when no slug is given', (tester) async { await tester.pumpWidget(_mountCatalog(_fakeEntries())); await tester.pump(); expect(find.text('alpha:light'), findsOneWidget); - expect(find.text('alpha:dark'), findsNothing); + expect(find.text('beta:light'), findsOneWidget); }); - testWidgets('toggling the wind theme flips the pane brightness', ( - tester, - ) async { + testWidgets('toggling the wind theme flips every section', (tester) async { await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'beta')); await tester.pump(); @@ -120,10 +129,29 @@ void main() { await tester.pump(); expect(controller.brightness, Brightness.dark); - // The single pane re-renders in the toggled (dark) brightness. + // Every section re-renders in the toggled (dark) brightness. + expect(find.text('alpha:dark'), findsOneWidget); expect(find.text('beta:dark'), findsOneWidget); + expect(find.text('alpha:light'), findsNothing); expect(find.text('beta:light'), findsNothing); expect(tester.takeException(), isNull); }); + + testWidgets('tapping a sidebar item reports the selected entry', ( + tester, + ) async { + PreviewEntry? selected; + await tester.pumpWidget( + _mountCatalog(_fakeEntries(), onSelect: (entry) => selected = entry), + ); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('magic-preview-nav-beta'))); + await tester.pumpAndSettle(); + + expect(selected, isNotNull); + expect(selected!.slug, 'beta'); + expect(tester.takeException(), isNull); + }); }); } From b03a110899429d7e7c006efb237df530557cda96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 12:20:29 +0300 Subject: [PATCH 7/9] fix(preview): make the catalog sidebar scroll The jump-link sidebar listed every entry in a fixed flex column; with a large component set (~39 entries) it overflowed the viewport (RenderFlex bottom overflow). The nav list now scrolls independently under a fixed header. --- lib/src/preview/magic_preview.dart | 43 +++++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/lib/src/preview/magic_preview.dart b/lib/src/preview/magic_preview.dart index 84e34f7..6591526 100644 --- a/lib/src/preview/magic_preview.dart +++ b/lib/src/preview/magic_preview.dart @@ -204,33 +204,44 @@ class _MagicPreviewCatalogState extends State { ); } - /// The left navigation rail: a jump link per registered preview. + /// The left navigation rail: a jump link per registered preview. 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 p-3 gap-1 ' + '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-3 py-2', + className: 'text-fg-muted text-xs font-semibold uppercase px-6 py-4', ), - for (final PreviewEntry entry in widget.entries) - WAnchor( - key: ValueKey('magic-preview-nav-${entry.slug}'), - onTap: () => _select(entry), + Expanded( + child: SingleChildScrollView( child: WDiv( - className: entry.slug == _activeSlug - ? '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 == _activeSlug - ? 'text-sm text-fg' - : 'text-sm text-fg-muted', - ), + 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 == _activeSlug + ? '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 == _activeSlug + ? 'text-sm text-fg' + : 'text-sm text-fg-muted', + ), + ), + ), + ], ), ), + ), ], ); } From fcef55868867929f2f1f9c524c4eb1d3e573c58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 12:27:28 +0300 Subject: [PATCH 8/9] fix(preview): single active pane instead of stacking every section Stacking all sections on one scrolling page janked once the catalog grew to ~39 entries including heavy controller-backed screen previews (all mounted at once). Revert to a single active pane: the scrollable sidebar selects, and only the chosen preview is built. Heavy screens mount only when selected, so the catalog stays responsive (index now shows zero exceptions vs the prior setState-during-build churn). Sidebar stays scrollable for the large entry set; header shows the active label. --- CHANGELOG.md | 10 +- lib/src/preview/magic_preview.dart | 176 +++++++++------------------ test/preview/magic_preview_test.dart | 47 +++---- 3 files changed, 85 insertions(+), 148 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e9d86..6738cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MagicPreview` framework: a dev-only component preview catalog hosted behind a `/preview` ShellRoute. New `package:magic_devtools/preview.dart` barrel exports the `PreviewEntry` contract (`label`, `slug`, `builder`), the - `MagicPreviewCatalog` widget (a single vertically scrolling page that stacks - every registered preview as its own labeled section; the left sidebar is - jump-to-section navigation — tapping an item, or deep-linking - `/preview/`, scrolls that section into view — plus a global light/dark - toggle bound to wind's `WindTheme.of(context).toggleTheme()`), + `MagicPreviewCatalog` widget (a scrollable sidebar next to a SINGLE active + preview pane — tapping a sidebar item, or deep-linking `/preview/`, + 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 diff --git a/lib/src/preview/magic_preview.dart b/lib/src/preview/magic_preview.dart index 6591526..09a81bb 100644 --- a/lib/src/preview/magic_preview.dart +++ b/lib/src/preview/magic_preview.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/widgets.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; @@ -35,13 +33,13 @@ final class PreviewEntry { /// **The dev-only component preview catalog.** /// -/// Modeled on the `idea-design` reference catalog: a single, vertically -/// scrolling page that stacks EVERY registered [PreviewEntry] as its own -/// labeled section (heading + bordered card), with a left sidebar that acts as -/// jump-to-section navigation rather than a one-at-a-time selector. Tapping a -/// sidebar item scrolls its section into view; deep-linking `/preview/` -/// scrolls to that section on mount. The header carries a "Toggle theme" button -/// so the consumer flips light/dark for the whole catalog from one place. +/// Renders a scrollable sidebar of [PreviewEntry] labels next to a SINGLE +/// active preview pane: tapping a sidebar item (or deep-linking +/// `/preview/`) 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 /// @@ -54,8 +52,8 @@ final class PreviewEntry { class MagicPreviewCatalog extends StatefulWidget { /// Creates the catalog over [entries]. /// - /// [activeSlug] selects which section to scroll to on mount; when null (or - /// unmatched) the page opens at the top. + /// [activeSlug] selects which entry is shown; when null (or unmatched) the + /// first entry is shown. const MagicPreviewCatalog({ super.key, required this.entries, @@ -67,13 +65,11 @@ class MagicPreviewCatalog extends StatefulWidget { /// release boundary can stay airtight. final List entries; - /// The slug of the section to scroll into view on mount; null opens at the - /// top. + /// 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 (deep-link sync); the section is also scrolled into view - /// locally regardless. + /// navigation; when null, selection updates local state only. final ValueChanged? onSelect; @override @@ -81,90 +77,43 @@ class MagicPreviewCatalog extends StatefulWidget { } class _MagicPreviewCatalogState extends State { - final ScrollController _scrollController = ScrollController(); - - /// One key per entry slug, attached to that section so a sidebar tap (or a - /// deep-link on mount) can scroll the section into view via - /// [Scrollable.ensureVisible]. - late Map _sectionKeys; - - /// The slug highlighted in the sidebar (the last selected / deep-linked one). - String? _activeSlug; - - /// Pending retry of the scroll-into-view (see [_scrollToSlug]); cancelled on - /// dispose so no timer outlives the widget (flutter_test flags stragglers). - Timer? _scrollRetryTimer; + late String _selectedSlug; @override void initState() { super.initState(); - _rebuildKeys(); - _activeSlug = widget.activeSlug; - _scheduleScrollTo(widget.activeSlug); + _selectedSlug = _resolveInitialSlug(); } @override void didUpdateWidget(MagicPreviewCatalog oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.entries != oldWidget.entries) { - _rebuildKeys(); + if (widget.activeSlug != oldWidget.activeSlug || + widget.entries != oldWidget.entries) { + _selectedSlug = _resolveInitialSlug(); } - if (widget.activeSlug != oldWidget.activeSlug) { - _activeSlug = widget.activeSlug; - _scheduleScrollTo(widget.activeSlug); - } - } - - @override - void dispose() { - _scrollRetryTimer?.cancel(); - _scrollController.dispose(); - super.dispose(); - } - - void _rebuildKeys() { - _sectionKeys = { - for (final PreviewEntry entry in widget.entries) - entry.slug: GlobalKey( - debugLabel: 'magic-preview-section-${entry.slug}', - ), - }; - } - - /// Scroll the [slug] section into view after the next frame (so the section's - /// key has a mounted context to resolve against). - void _scheduleScrollTo(String? slug) { - if (slug == null) return; - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToSlug(slug)); } - void _scrollToSlug(String slug) { - _ensureVisible(slug); - // Controller-backed screen sections defer-mount and settle their heights - // over a few frames, which can leave the first scroll short. Re-run once - // the layout has settled so the section lands at the top precisely. - _scrollRetryTimer?.cancel(); - _scrollRetryTimer = Timer( - const Duration(milliseconds: 450), - () => _ensureVisible(slug), - ); + /// 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; } - void _ensureVisible(String slug) { - if (!mounted) return; - final BuildContext? sectionContext = _sectionKeys[slug]?.currentContext; - if (sectionContext == null) return; - Scrollable.ensureVisible( - sectionContext, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - alignment: 0.0, - ); + 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(() => _activeSlug = entry.slug); - _scrollToSlug(entry.slug); + setState(() => _selectedSlug = entry.slug); widget.onSelect?.call(entry); } @@ -178,24 +127,11 @@ class _MagicPreviewCatalogState extends State { className: 'flex flex-col flex-1 h-full', children: [ _buildHeader(context), - // The whole catalog scrolls vertically; every section is stacked - // here and reachable via the sidebar jump links. + // Only the active preview is mounted; it scrolls vertically so a + // tall matrix or screen does not overflow the viewport. Expanded( child: SingleChildScrollView( - controller: _scrollController, - child: WDiv( - className: 'flex flex-col gap-12 p-6', - children: [ - if (widget.entries.isEmpty) - const WText( - 'Register a preview to see it here.', - className: 'text-fg-muted text-sm', - ) - else - for (final PreviewEntry entry in widget.entries) - _buildSection(entry), - ], - ), + child: WDiv(className: 'p-6', child: _buildActivePane()), ), ), ], @@ -204,9 +140,8 @@ class _MagicPreviewCatalogState extends State { ); } - /// The left navigation rail: a jump link per registered preview. The list - /// scrolls independently (it can hold many more entries than fit the - /// viewport height) under a fixed header. + /// 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: @@ -227,12 +162,12 @@ class _MagicPreviewCatalogState extends State { key: ValueKey('magic-preview-nav-${entry.slug}'), onTap: () => _select(entry), child: WDiv( - className: entry.slug == _activeSlug + 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 == _activeSlug + className: entry.slug == _selectedSlug ? 'text-sm text-fg' : 'text-sm text-fg-muted', ), @@ -246,14 +181,18 @@ class _MagicPreviewCatalogState extends State { ); } - /// The toolbar with the title and the wind theme toggle. + /// 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: [ - const WText('Components', className: 'text-fg text-lg font-semibold'), + 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. @@ -272,23 +211,18 @@ class _MagicPreviewCatalogState extends State { ); } - /// A single labeled section: heading + underline + a bordered card hosting - /// the entry's preview body. Keyed so the sidebar can scroll to it. - Widget _buildSection(PreviewEntry entry) { + /// 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( - key: _sectionKeys[entry.slug], - className: 'flex flex-col gap-4', - children: [ - WText( - entry.label, - className: - 'text-fg text-lg font-semibold border-b border-color-border pb-2', - ), - WDiv( - className: 'p-6 rounded-lg border border-color-border bg-surface', - child: Builder(builder: (paneContext) => entry.builder(paneContext)), - ), - ], + className: 'p-6 rounded-lg border border-color-border bg-surface', + child: Builder(builder: (paneContext) => active.builder(paneContext)), ); } } diff --git a/test/preview/magic_preview_test.dart b/test/preview/magic_preview_test.dart index 9eb21ed..ebb7564 100644 --- a/test/preview/magic_preview_test.dart +++ b/test/preview/magic_preview_test.dart @@ -5,12 +5,12 @@ import 'package:magic_devtools/preview.dart'; /// Tests for the [MagicPreviewCatalog] dev-only component preview framework. /// -/// The catalog is the host surface for auto-discovered [PreviewEntry] widgets. -/// It stacks EVERY entry as its own section on one scrolling page (idea-design -/// parity); the sidebar is jump-to-section navigation, and the header toggle -/// flips light/dark for the whole page. These tests mount the catalog with two -/// fake entries, prove every section renders, exercise the theme toggle, and -/// check that a sidebar tap reports the selected entry. +/// The catalog hosts auto-discovered [PreviewEntry] widgets. It renders a +/// scrollable sidebar next to a SINGLE active pane: only the selected entry is +/// built (so a large, screen-heavy catalog stays responsive), and the header +/// toggle flips light/dark. These tests mount the catalog with two fake +/// entries, prove single-pane rendering, exercise the theme toggle, and check +/// that a sidebar tap reports + shows the selected entry. /// A trivial preview body that paints a brightness-derived label so a test can /// read which [Brightness] the surrounding [WindTheme] resolved to. @@ -78,18 +78,16 @@ void main() { }); group('MagicPreviewCatalog', () { - testWidgets('stacks every entry as a section in the ambient brightness', ( + testWidgets('renders only the active preview in the ambient brightness', ( tester, ) async { await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'alpha')); await tester.pump(); - // Every entry renders its own section (all on one scrolling page) in the - // ambient brightness (light here); the header toggle flips them. + // Single pane: only the selected entry's body is mounted (light here). expect(find.text('alpha:light'), findsOneWidget); - expect(find.text('beta:light'), findsOneWidget); + expect(find.text('beta:light'), findsNothing); expect(find.text('alpha:dark'), findsNothing); - expect(find.text('beta:dark'), findsNothing); expect(tester.takeException(), isNull); }); @@ -97,47 +95,45 @@ void main() { await tester.pumpWidget(_mountCatalog(_fakeEntries())); await tester.pump(); - // Each label appears in the sidebar nav and as a section heading. expect(find.text('Alpha'), findsWidgets); expect(find.text('Beta'), findsWidgets); expect(tester.takeException(), isNull); }); - testWidgets('renders all sections when no slug is given', (tester) async { + testWidgets('defaults to the first entry when no slug is given', ( + tester, + ) async { await tester.pumpWidget(_mountCatalog(_fakeEntries())); await tester.pump(); expect(find.text('alpha:light'), findsOneWidget); - expect(find.text('beta:light'), findsOneWidget); + expect(find.text('beta:light'), findsNothing); }); - testWidgets('toggling the wind theme flips every section', (tester) async { + testWidgets('toggling the wind theme flips the active pane brightness', ( + tester, + ) async { await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'beta')); await tester.pump(); - // 1. Capture the controller through the catalog subtree. final BuildContext context = tester.element( find.byType(MagicPreviewCatalog), ); final WindThemeController controller = WindTheme.of(context); expect(controller.brightness, Brightness.light); - // 2. The toggle control drives WindTheme.of(context).toggleTheme(). await tester.tap( find.byKey(const ValueKey('magic-preview-theme-toggle')), ); await tester.pump(); expect(controller.brightness, Brightness.dark); - // Every section re-renders in the toggled (dark) brightness. - expect(find.text('alpha:dark'), findsOneWidget); expect(find.text('beta:dark'), findsOneWidget); - expect(find.text('alpha:light'), findsNothing); expect(find.text('beta:light'), findsNothing); expect(tester.takeException(), isNull); }); - testWidgets('tapping a sidebar item reports the selected entry', ( + testWidgets('tapping a sidebar item selects and shows that entry', ( tester, ) async { PreviewEntry? selected; @@ -146,11 +142,18 @@ void main() { ); await tester.pump(); + // Starts on the first entry. + expect(find.text('alpha:light'), findsOneWidget); + expect(find.text('beta:light'), findsNothing); + await tester.tap(find.byKey(const ValueKey('magic-preview-nav-beta'))); - await tester.pumpAndSettle(); + await tester.pump(); + // Swaps the pane to the tapped entry and reports it. expect(selected, isNotNull); expect(selected!.slug, 'beta'); + expect(find.text('beta:light'), findsOneWidget); + expect(find.text('alpha:light'), findsNothing); expect(tester.takeException(), isNull); }); }); From b2cfd8667f59a52673370aa5c53f7a2a7f4d585b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 26 Jun 2026 14:48:44 +0300 Subject: [PATCH 9/9] =?UTF-8?q?docs(preview):=20address=20PR=20#7=20review?= =?UTF-8?q?=20=E2=80=94=20drop=20stale=20ShellRoute=20wording,=20harden=20?= =?UTF-8?q?register?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace every 'ShellRoute' reference (barrel doc, README, CHANGELOG, test doc) with the actual two-plain-pages wiring (/preview + /preview/:component); the persistent-ShellRoute rationale comment stays where it explains the avoidance. - Correct kPreviewEnabled doc: ON in debug, OFF in profile+release by default (kDebugMode is false in profile); profile opts in via --dart-define. - register() now snapshots entries into List.unmodifiable so later caller mutation cannot change the registered catalog. --- CHANGELOG.md | 5 +++-- README.md | 2 +- lib/preview.dart | 5 +++-- lib/src/preview/preview_routes.dart | 15 +++++++++++---- test/preview/preview_routes_test.dart | 10 ++++++---- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6738cb7..bdcfe58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `MagicPreview` framework: a dev-only component preview catalog hosted behind a - `/preview` ShellRoute. New `package:magic_devtools/preview.dart` barrel exports +- `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/`, diff --git a/README.md b/README.md index 2eb652c..55c28d3 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ 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/preview.dart`: `MagicPreview` hosts a dev-only component preview catalog behind a `/preview` ShellRoute, tree-shaken from release builds. +- `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 diff --git a/lib/preview.dart b/lib/preview.dart index a368f92..6440f77 100644 --- a/lib/preview.dart +++ b/lib/preview.dart @@ -1,7 +1,8 @@ /// Magic dev-only component preview catalog barrel. /// -/// Import this file to host the auto-discovered component previews behind a -/// `/preview` ShellRoute. The whole surface is dev-only: it is reachable only +/// 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. diff --git a/lib/src/preview/preview_routes.dart b/lib/src/preview/preview_routes.dart index 7955fe5..dff154c 100644 --- a/lib/src/preview/preview_routes.dart +++ b/lib/src/preview/preview_routes.dart @@ -5,9 +5,14 @@ import 'magic_preview.dart'; /// Compile-time switch for the preview catalog. /// -/// Defaults to [kDebugMode]: the catalog is reachable in debug and profile -/// builds, never in release. A host can force it off in any mode with -/// `--dart-define=PREVIEW_ENABLED=false`. Because this is a `const`, the +/// Defaults to [kDebugMode]: ON in debug builds, OFF in profile and release +/// (`kDebugMode` is false in both). A profile build can opt in with +/// `--dart-define=PREVIEW_ENABLED=true`; a debug build can force it off with +/// `--dart-define=PREVIEW_ENABLED=false`. Release is always blocked by the +/// `kReleaseMode` guard in [MagicPreview.registerRoutes] regardless. Because +/// this is a `const`, the release-mode optimizer can fold the guarded branch in +/// [MagicPreview.registerRoutes] dead and tree-shake the entire catalog, +/// every [PreviewEntry], and every builder it transitively references. /// release-mode optimizer can fold the guarded branch in /// [MagicPreview.registerRoutes] dead and tree-shake the entire catalog, /// every [PreviewEntry], and every builder it transitively references. @@ -50,7 +55,9 @@ final class MagicPreview { /// [registerRoutes] short-circuits, so even if entries are registered they /// are never wired into a route and stay tree-shakeable. static void register(List entries) { - _entries = entries; + // Defensively snapshot into an unmodifiable list so later mutation of the + // caller's list cannot change the registered catalog after the fact. + _entries = List.unmodifiable(entries); } /// Register the `/preview` catalog page and its `/preview/:component` deep diff --git a/test/preview/preview_routes_test.dart b/test/preview/preview_routes_test.dart index 341a71f..e675b48 100644 --- a/test/preview/preview_routes_test.dart +++ b/test/preview/preview_routes_test.dart @@ -3,13 +3,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic_devtools/preview.dart'; -/// Tests for [MagicPreview]'s registration entrypoint and the `/preview` -/// ShellRoute it wires into [MagicRouter]. +/// Tests for [MagicPreview]'s registration entrypoint and the two plain +/// `/preview` pages (`/preview` and `/preview/:component`) it wires into +/// [MagicRouter] (a persistent ShellRoute is deliberately avoided — it would +/// not rebuild when only the child route swaps). /// /// The release boundary itself (the `kReleaseMode` early return + tree-shaking) /// is asserted by Step 19 (a release-bundle symbol grep); these tests cover the -/// debug-mode behavior: entries round-trip, and `registerRoutes` adds exactly -/// one `magic-preview` layout BEFORE the router locks. +/// debug-mode behavior: entries round-trip, and `registerRoutes` adds the +/// `magic-preview` pages BEFORE the router locks. List _entries() { return [