From 4ca609c78b2e028a6a53878c3a38d8e0fcb11cd9 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Mon, 24 Aug 2026 23:45:09 +0530 Subject: [PATCH] feat: path URLs for the playground's components --- docs/.gitignore | 3 ++ docs/README.md | 13 ++++-- docs/astro.config.mjs | 11 +++-- docs/public/_redirects | 38 ++++++++++++++++ playground/README.md | 42 ++++++++++++----- playground/lib/main.dart | 29 ++++++++++-- playground/lib/src/embed.dart | 9 +--- playground/lib/src/playground_item.dart | 20 ++++++++ playground/lib/src/playground_shell.dart | 36 +++++++++------ playground/lib/src/router.dart | 58 ++++++++++++++++++++++++ playground/pubspec.lock | 21 +++++++++ playground/pubspec.yaml | 8 ++++ 12 files changed, 246 insertions(+), 42 deletions(-) create mode 100644 docs/public/_redirects create mode 100644 playground/lib/src/router.dart diff --git a/docs/.gitignore b/docs/.gitignore index cff129a..fe9b4fd 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -29,3 +29,6 @@ pnpm-debug.log* # macOS-specific files .DS_Store + +# wrangler pages dev, used to verify the playground redirects locally +.wrangler/ diff --git a/docs/README.md b/docs/README.md index 0cc3cb9..fedcbe5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,11 +15,18 @@ source. Brand colors in `src/styles/theme.css` mirror the package's design tokens in `lib/src/theme/flow_colors.dart`. `npm run playground` builds the repo's `playground/` Flutter app for the -web into `public/playground/` — served at `/playground/` and embedded on -component pages as live demos via `src/components/FlowDemo.astro` -(`/playground/?embed=&variant=&theme=`). +web into `public/playground/` — served at `/playground/` (path +URLs, one per component) and embedded on component pages as live demos via +`src/components/FlowDemo.astro` +(`/playground/index.html?embed=&variant=&theme=`). `npm run build:site` chains playground + docs into one `dist/`. +Those component paths are routes inside the app, not files, so they need a +rewrite: `public/_redirects` sends `/playground/*` to +`/playground/index.html` on Cloudflare Pages, and the `playground-dev-index` +integration in `astro.config.mjs` does the same for `npm run dev`. Removing +either makes deep links serve the 404 page. + Deploys to Cloudflare Pages from `.github/workflows/publish.yml` — every release tag ships the site (playground included) alongside the pub.dev publish, and `workflow_dispatch` redeploys it on its own. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 5e1c173..0189697 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -2,9 +2,10 @@ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; -/** Dev-only: serve /playground/ from public/playground/index.html. Real - * static hosts (and the production deploy) resolve directory indexes - * themselves; Astro's dev server does not. */ +/** Dev-only: serve /playground/ and its component routes from + * public/playground/index.html. Real static hosts resolve directory + * indexes themselves, and the deploy rewrites the component paths through + * public/_redirects; Astro's dev server does neither. */ const playgroundDevIndex = { name: 'playground-dev-index', hooks: { @@ -15,6 +16,10 @@ const playgroundDevIndex = { req.url = '/playground/index.html'; } else if (req.url?.startsWith('/playground/?')) { req.url = req.url.replace('/playground/?', '/playground/index.html?'); + } else if (/^\/playground\/[^.?]+(\?|$)/.test(req.url ?? '')) { + // A component path like /playground/composer. Extensionless + // only, so main.dart.js, canvaskit/ and assets/ pass through. + req.url = req.url.replace(/^\/playground\/[^?]*/, '/playground/index.html'); } next(); }); diff --git a/docs/public/_redirects b/docs/public/_redirects new file mode 100644 index 0000000..8d16619 --- /dev/null +++ b/docs/public/_redirects @@ -0,0 +1,38 @@ +# The playground is a single-page Flutter app with path URLs, so +# /playground/composer is a route inside the app, not a file on disk. The +# site ships a 404.html, which turns off Cloudflare Pages' automatic SPA +# fallback — without these rewrites a shared link or a refresh lands on +# the docs' 404 and the app never boots. +# +# Two details are load-bearing, both learned from `wrangler pages dev`: +# +# * One line per PlaygroundItem slug, not `/playground/*`. Pages rejects +# the splat outright ("infinite loop detected", rule ignored) because +# it strips `.html` and `/index` from the destination and then sees it +# match the source again. Exact paths can't collide with an asset. +# * The destination is the directory, not `/playground/index.html`. +# Naming index.html makes Pages normalise it into a 308 to +# `/playground/`, which throws the component away and lands every deep +# link on the default. The directory form rewrites in place, 200, URL +# intact. +# +# Adding a component means adding a line — see +# playground/lib/src/playground_item.dart. +/playground/full-chat /playground/ 200 +/playground/composer /playground/ 200 +/playground/modal-selector /playground/ 200 +/playground/message /playground/ 200 +/playground/streaming-message /playground/ 200 +/playground/code-block /playground/ 200 +/playground/markdown /playground/ 200 +/playground/error-state /playground/ 200 +/playground/add-to-chat /playground/ 200 +/playground/pill /playground/ 200 +/playground/attachments /playground/ 200 +/playground/thread /playground/ 200 +/playground/message-actions /playground/ 200 +/playground/streaming-text /playground/ 200 +/playground/shimmer-text /playground/ 200 +/playground/thinking-indicator /playground/ 200 +/playground/suggestions /playground/ 200 +/playground/greeting /playground/ 200 diff --git a/playground/README.md b/playground/README.md index d8d834c..451c569 100644 --- a/playground/README.md +++ b/playground/README.md @@ -1,17 +1,37 @@ -# playground +# Flow UI Playground -A new Flutter project. +The workbench for [flow_ui](../): every component on a stage, with variant +pills and the code that renders them. -## Getting Started +```bash +flutter pub get +flutter run -d chrome # or any device +``` -This project is a starting point for a Flutter application. +## URLs -A few resources to get you started if this is your first Flutter project: +Two contracts share one app. -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +**Components are paths.** `/playground/composer`, `/playground/full-chat` — +one address per `PlaygroundItem`, its `slug` (the enum name in kebab-case). +Linkable, bookmarkable, and walked by the browser's back button. Everything +else on screen — the variant pills, the theme, the device frame — is a +workbench setting that travels with the person, not the link, so it stays +out of the URL. -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +**Embeds are a query.** The docs site iframes single demos chrome-less: + +``` +/playground/index.html?embed=&variant=&theme= +``` + +`main` branches on `?embed=` before the app is built, so embeds never touch +the router. An unknown `embed` renders "Unknown demo" rather than crashing +inside somebody's docs page; an unknown `variant` falls back to the first. + +Path URLs need a rewrite on static hosting: **`docs/public/_redirects`** maps +`/playground/*` to `/playground/index.html`, and the dev-server equivalent +lives in `docs/astro.config.mjs`. Delete either and deep links start +serving the docs' 404. The build also depends on `--base-href /playground/` +(`docs/scripts/build-playground.sh`) to re-root assets from a component +path. diff --git a/playground/lib/main.dart b/playground/lib/main.dart index 5fc9707..92c57a2 100644 --- a/playground/lib/main.dart +++ b/playground/lib/main.dart @@ -1,8 +1,10 @@ import 'package:flow_ui/flow_ui.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; import 'src/embed.dart'; -import 'src/playground_shell.dart'; +import 'src/router.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -15,6 +17,13 @@ Future main() async { runApp(EmbedApp(request: embed)); return; } + // Path URLs for the components: /playground/composer, not a hash route. + // It goes *after* the embed branch on purpose — the docs iframe + // /playground/index.html?embed=…, and under this strategy that reads as + // the route name "/index.html", which the embed's plain Navigator can't + // build. The embed returns above, so it never sees one. Off the web this + // call is a documented no-op. + usePathUrlStrategy(); runApp(const PlaygroundApp()); } @@ -32,9 +41,21 @@ class _PlaygroundAppState extends State { // platforms that report no preference resolve to light. ThemeMode _mode = ThemeMode.system; + // Built once: rebuilding the router on a theme flip would throw away the + // location and the history with it. + late final GoRouter _router = playgroundRouter( + onThemeModeChanged: (mode) => setState(() => _mode = mode), + ); + + @override + void dispose() { + _router.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return MaterialApp( + return MaterialApp.router( title: 'Flow UI Playground · Flutter UI library for AI Chat Interfaces', debugShowCheckedModeBanner: false, themeMode: _mode, @@ -48,9 +69,7 @@ class _PlaygroundAppState extends State { brightness: Brightness.dark, extensions: [FlowTheme.dark()], ), - home: PlaygroundShell( - onThemeModeChanged: (mode) => setState(() => _mode = mode), - ), + routerConfig: _router, ); } } diff --git a/playground/lib/src/embed.dart b/playground/lib/src/embed.dart index 027676d..9e4ddad 100644 --- a/playground/lib/src/embed.dart +++ b/playground/lib/src/embed.dart @@ -11,7 +11,7 @@ import 'playground_item.dart'; /// /playground/?embed=composer&variant=streaming&theme=dark /// /// `embed` is a [PlaygroundItem] in kebab-case (`full-chat`, -/// `model-selector`…; the enum name itself is accepted too). `variant` +/// `modal-selector`…; the enum name itself is accepted too). `variant` /// must be one of the item's [variantsFor] ids and falls back to the /// default otherwise. `theme` is `light` or `dark` — fixed for the /// session; the docs page reloads the iframe when its theme flips. Left @@ -37,7 +37,7 @@ class EmbedRequest { PlaygroundItem? item; for (final candidate in PlaygroundItem.values) { - if (candidate.name == id || _kebab(candidate.name) == id) { + if (candidate.name == id || candidate.slug == id) { item = candidate; break; } @@ -59,11 +59,6 @@ class EmbedRequest { }, ); } - - static String _kebab(String name) => name.replaceAllMapped( - RegExp('[A-Z]'), - (match) => '-${match[0]!.toLowerCase()}', - ); } /// The embed app: the same FlowTheme setup as the playground, one demo on diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart index 45d0d0b..52e6a18 100644 --- a/playground/lib/src/playground_item.dart +++ b/playground/lib/src/playground_item.dart @@ -87,4 +87,24 @@ enum PlaygroundItem { /// Examples sit in their own sidebar section above the components. final bool isExample; + + /// The item's URL id: the enum name in kebab-case (`fullChat` → + /// `full-chat`). One id serves both contracts — the playground's path + /// (`/playground/full-chat`) and the docs' embed query + /// (`?embed=full-chat`) — so they can't drift apart, and renaming a + /// value moves both. + String get slug => + name.replaceAllMapped(_camelBoundary, (m) => '-${m[0]!.toLowerCase()}'); +} + +final RegExp _camelBoundary = RegExp('[A-Z]'); + +/// The item [PlaygroundItem.slug] names, or null when nothing matches — +/// the caller decides what an unknown id means: the router redirects to +/// the default, the embed renders its "Unknown demo" surface. +PlaygroundItem? playgroundItemForSlug(String slug) { + for (final item in PlaygroundItem.values) { + if (item.slug == slug) return item; + } + return null; } diff --git a/playground/lib/src/playground_shell.dart b/playground/lib/src/playground_shell.dart index da09555..9f8dc7a 100644 --- a/playground/lib/src/playground_shell.dart +++ b/playground/lib/src/playground_shell.dart @@ -8,10 +8,24 @@ import 'stage.dart'; import 'top_bar.dart'; /// The playground's single screen: top bar over sidebar + canvas + code -/// panel. Owns the selection, the stage device, and the panel state; the -/// theme lives with the app so the whole MaterialApp flips. +/// panel. Owns the stage device, the panel state and the per-item variant +/// memory; the component on the stage comes from the route, and the theme +/// lives with the app so the whole MaterialApp flips. class PlaygroundShell extends StatefulWidget { - const PlaygroundShell({super.key, required this.onThemeModeChanged}); + const PlaygroundShell({ + super.key, + required this.item, + required this.onSelect, + required this.onThemeModeChanged, + }); + + /// The component on the stage — the route's `:component`. The shell + /// reads it, the router owns it: picking a row navigates, and + /// navigating (the browser's back button included) restages. + final PlaygroundItem item; + + /// Selection intent; the app turns it into a route change. + final ValueChanged onSelect; final ValueChanged onThemeModeChanged; @@ -20,7 +34,6 @@ class PlaygroundShell extends StatefulWidget { } class _PlaygroundShellState extends State { - PlaygroundItem _selected = PlaygroundItem.fullChat; StageDevice _device = StageDevice.web; bool _codeOpen = true; @@ -28,9 +41,9 @@ class _PlaygroundShellState extends State { final Map _variants = {}; String? get _variant { - final variants = variantsFor(_selected); + final variants = variantsFor(widget.item); if (variants.isEmpty) return null; - return _variants[_selected] ?? variants.first.$1; + return _variants[widget.item] ?? variants.first.$1; } @override @@ -50,22 +63,19 @@ class _PlaygroundShellState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Sidebar( - selected: _selected, - onSelect: (item) => setState(() => _selected = item), - ), + Sidebar(selected: widget.item, onSelect: widget.onSelect), Expanded( child: Stage( device: _device, - item: _selected, + item: widget.item, variant: _variant, onVariantChanged: (id) => - setState(() => _variants[_selected] = id), + setState(() => _variants[widget.item] = id), ), ), CodePanel( open: _codeOpen, - item: _selected, + item: widget.item, variant: _variant, onClose: () => setState(() => _codeOpen = false), ), diff --git a/playground/lib/src/router.dart b/playground/lib/src/router.dart new file mode 100644 index 0000000..4a774c4 --- /dev/null +++ b/playground/lib/src/router.dart @@ -0,0 +1,58 @@ +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'playground_item.dart'; +import 'playground_shell.dart'; + +/// The playground's URL contract: one path per component — +/// `/playground/composer`, `/playground/full-chat` — so a component can be +/// linked, bookmarked and reached with the back button. +/// +/// Only the component is an address. The variant pills, the theme and the +/// device frame are workbench settings that travel with the person, not +/// the link, so they stay in the shell's state. +/// +/// The docs site's chrome-less embeds keep their own query contract +/// (`index.html?embed=…`) and never reach this router — `main` branches on +/// them before the app is built. +GoRouter playgroundRouter({ + required ValueChanged onThemeModeChanged, +}) { + return GoRouter( + initialLocation: _defaultLocation, + // Everything that isn't a known component — `/`, a typo, a stale link + // — lands on the example rather than a dead end, the same never-crash + // contract the embed's "Unknown demo" surface keeps. The rewrite + // replaces the history entry on first load, so Back still leaves. + redirect: (context, state) { + final segments = state.uri.pathSegments; + if (segments.length == 1 && + playgroundItemForSlug(segments.single) != null) { + return null; + } + return _defaultLocation; + }, + routes: [ + GoRoute( + path: '/:component', + // One page for every component, under one key: the Navigator + // updates it in place instead of swapping routes, so the shell's + // state — the per-item variant memory, the device, the code panel + // — survives navigation. No transition either: the stage swaps, + // the chrome around it doesn't move. + pageBuilder: (context, state) => NoTransitionPage( + key: const ValueKey('playground-shell'), + name: state.pathParameters['component'], + child: PlaygroundShell( + item: playgroundItemForSlug(state.pathParameters['component']!)!, + onSelect: (item) => context.go('/${item.slug}'), + onThemeModeChanged: onThemeModeChanged, + ), + ), + ), + ], + ); +} + +/// The example leads the sidebar, so it leads the playground. +final String _defaultLocation = '/${PlaygroundItem.fullChat.slug}'; diff --git a/playground/pubspec.lock b/playground/pubspec.lock index b6d0ec5..2632490 100644 --- a/playground/pubspec.lock +++ b/playground/pubspec.lock @@ -111,6 +111,19 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "2e7fcb9ac4d52ec06cd940552f12e66ee71fccbcadb09a067cb9fe20462b5bf3" + url: "https://pub.dev" + source: hosted + version: "18.0.0" http: dependency: transitive description: @@ -167,6 +180,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: diff --git a/playground/pubspec.yaml b/playground/pubspec.yaml index 6094e9e..504973e 100644 --- a/playground/pubspec.yaml +++ b/playground/pubspec.yaml @@ -40,6 +40,14 @@ dependencies: flutter_svg: ^2.0.10 # The icon set the design is drawn in. phosphoricons_flutter: ^1.0.0 + # Path URLs for the components: /playground/composer, not a hash route. + # flutter.dev-published — the app may take third-party deps; the package + # still may not. + go_router: ^18.0.0 + # usePathUrlStrategy(), a documented no-op off the web. Declared rather + # than leaned on through go_router, for depend_on_referenced_packages. + flutter_web_plugins: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.