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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ pnpm-debug.log*

# macOS-specific files
.DS_Store

# wrangler pages dev, used to verify the playground redirects locally
.wrangler/
13 changes: 10 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<demo>&variant=<v>&theme=<light|dark>`).
web into `public/playground/` — served at `/playground/<component>` (path
URLs, one per component) and embedded on component pages as live demos via
`src/components/FlowDemo.astro`
(`/playground/index.html?embed=<demo>&variant=<v>&theme=<light|dark>`).
`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.
Expand Down
11 changes: 8 additions & 3 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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();
});
Expand Down
38 changes: 38 additions & 0 deletions docs/public/_redirects
Original file line number Diff line number Diff line change
@@ -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
42 changes: 31 additions & 11 deletions playground/README.md
Original file line number Diff line number Diff line change
@@ -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=<slug>&variant=<id>&theme=<light|dark>
```

`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.
29 changes: 24 additions & 5 deletions playground/lib/main.dart
Original file line number Diff line number Diff line change
@@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
Expand All @@ -15,6 +17,13 @@ Future<void> 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());
}

Expand All @@ -32,9 +41,21 @@ class _PlaygroundAppState extends State<PlaygroundApp> {
// 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,
Expand All @@ -48,9 +69,7 @@ class _PlaygroundAppState extends State<PlaygroundApp> {
brightness: Brightness.dark,
extensions: [FlowTheme.dark()],
),
home: PlaygroundShell(
onThemeModeChanged: (mode) => setState(() => _mode = mode),
),
routerConfig: _router,
);
}
}
9 changes: 2 additions & 7 deletions playground/lib/src/embed.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions playground/lib/src/playground_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
36 changes: 23 additions & 13 deletions playground/lib/src/playground_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlaygroundItem> onSelect;

final ValueChanged<ThemeMode> onThemeModeChanged;

Expand All @@ -20,17 +34,16 @@ class PlaygroundShell extends StatefulWidget {
}

class _PlaygroundShellState extends State<PlaygroundShell> {
PlaygroundItem _selected = PlaygroundItem.fullChat;
StageDevice _device = StageDevice.web;
bool _codeOpen = true;

/// The chosen variant per item; items absent fall back to their first.
final Map<PlaygroundItem, String> _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
Expand All @@ -50,22 +63,19 @@ class _PlaygroundShellState extends State<PlaygroundShell> {
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),
),
Expand Down
58 changes: 58 additions & 0 deletions playground/lib/src/router.dart
Original file line number Diff line number Diff line change
@@ -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<ThemeMode> 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<void>(
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}';
Loading