mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
## Summary
New users had no cue that the slot at the bottom of Home is where they
make an agent. A one-time, deliberately loud "Create your first agent"
card now sits above the empty Bestie slot and points at it.
- The card spans the screen above the bottom bar, with agent pictures
(the same ones the empty slot cycles through) scattered around its edges
and the message centered as plain text. It's allowed to cover part of
the chat list.
- It waits three seconds on Home, then pops up from the agent slot with
the popover entrance (a quick fade, rise and scale). Leaving Home
restarts the wait, so it never pops over another screen. With reduced
motion it appears without animation after the same wait.
- Tapping the card starts agent creation. The close button dismisses it
for good, as does a screen reader's dismiss action, activating the slot,
or ending up with a Bestie. Dismissal is remembered per account and
community. Other taps on Home go through to the page and leave the card
up.
- It shows when remote agents are on, the Bestie preference has loaded
with none set, and the account owns no agents yet. The last check keeps
existing users who never picked a Bestie from being told to create their
"first" agent.
- It only shows while Home is the current route, so sheets and pushed
screens hide it and it comes back afterwards if it was never dismissed.
The card is a new `BuzzCallout.card` variant of the reusable
`BuzzCallout` in the mobile design system (the plain tip variant stays
available). It uses the inverse surface in light mode and the raised
dark surface in dark mode (matching toasts), with an arrow, existing
radius, spacing and popover elevation tokens, and stays phone-sized on
wider screens. It grows to fit large text below its close button, and it
has live-region semantics, so screen readers announce it.
## Screenshots
These come from the widget test harness with the real agent pictures, at
small (320), standard (393) and landscape phone sizes, plus a tablet.
The harness can't render the native iOS glass bar, so these use the
Flutter bar; the card is the same on both platforms. Pictures look a
little soft because the harness renders at 1x and scales up. In
landscape and on tablets the card stops at large-phone width so the
pictures don't spread too thin.
| Standard, light | Standard, dark | Small, light |
| --- | --- | --- |
| 
| 
| 
|
| Landscape, light | Landscape, dark |
| --- | --- |
| 
| 
|
| Tablet, dark |
| --- |
| 
|
## Testing
- Launcher tests cover first show and placement, Home staying tappable
underneath without dismissing, the card tap starting creation, the close
button, slot activation without a touch, screen reader dismiss, a Bestie
appearing, no reshow after dismissal, a separate community getting its
own first run, staying hidden for people with agents or without remote
agents, hiding under a sheet, the three-second wait and entrance,
reduced motion, and leaving Home during the wait (each
mutation-checked).
- Component tests cover the tip beside an edge target (320 and 1024
wide, 3x text) and the card: full width at 320, capped on wide screens,
growing at 3x text so the message stays clear of the close button
(mutation-checked), and the close button staying its own screen reader
control.
- The full mobile suite passes locally with CI's dart-defines, and
pre-push mobile checks passed at e2fdb791c.
- Still needs a device check on a real iPhone with a fresh account.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz>
155 lines
4.7 KiB
Dart
155 lines
4.7 KiB
Dart
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:buzz/shared/widgets/buzz_callout.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
Future<Rect> pumpCard(
|
|
WidgetTester tester, {
|
|
required double width,
|
|
double height = 700,
|
|
String message = 'Make one',
|
|
double scale = 1,
|
|
VoidCallback? onTap,
|
|
VoidCallback? onDismiss,
|
|
VoidCallback? onPage,
|
|
}) async {
|
|
tester.view.physicalSize = Size(width, height);
|
|
tester.view.devicePixelRatio = 1;
|
|
tester.platformDispatcher.textScaleFactorTestValue = scale;
|
|
addTearDown(tester.view.reset);
|
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: Stack(
|
|
children: [
|
|
Center(
|
|
child: TextButton(
|
|
onPressed: onPage ?? () {},
|
|
child: const Text('Page'),
|
|
),
|
|
),
|
|
Align(
|
|
alignment: Alignment.bottomRight,
|
|
child: BuzzCallout(
|
|
message: message,
|
|
visible: true,
|
|
onTap: onTap ?? () {},
|
|
onDismiss: onDismiss ?? () {},
|
|
artwork: const [Text('Art')],
|
|
child: const SizedBox.square(
|
|
key: Key('target'),
|
|
dimension: 56,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final card = tester.getRect(
|
|
find.ancestor(of: find.text(message), matching: find.byType(Stack)).first,
|
|
);
|
|
expect(
|
|
card.bottom,
|
|
lessThan(tester.getRect(find.byKey(const Key('target'))).top),
|
|
);
|
|
return card;
|
|
}
|
|
|
|
testWidgets('a card spans a phone between the margins', (tester) async {
|
|
final card = await pumpCard(tester, width: 320);
|
|
expect(card.left, Grid.xs);
|
|
expect(card.right, 320 - Grid.xs);
|
|
});
|
|
|
|
testWidgets('a card grows to fit large text below its close button', (
|
|
tester,
|
|
) async {
|
|
final card = await pumpCard(tester, width: 320, scale: 3);
|
|
final message = tester.getRect(find.text('Make one'));
|
|
final close = tester.getRect(find.byTooltip('Dismiss'));
|
|
expect(card.contains(message.topLeft), isTrue);
|
|
expect(card.contains(message.bottomRight), isTrue);
|
|
expect(message.top, greaterThanOrEqualTo(close.bottom));
|
|
});
|
|
|
|
testWidgets('a card scrolls large text that outgrows a short screen', (
|
|
tester,
|
|
) async {
|
|
const message = 'Create your first agent';
|
|
final card = await pumpCard(
|
|
tester,
|
|
width: 568,
|
|
height: 320,
|
|
message: message,
|
|
scale: 3,
|
|
);
|
|
expect(tester.takeException(), isNull);
|
|
final close = tester.getRect(find.byTooltip('Dismiss'));
|
|
expect(card.contains(close.topLeft), isTrue);
|
|
expect(card.contains(close.bottomRight), isTrue);
|
|
final scrollable = find.descendant(
|
|
of: find.byType(BuzzCallout),
|
|
matching: find.byType(Scrollable),
|
|
);
|
|
expect(
|
|
tester.state<ScrollableState>(scrollable).position.maxScrollExtent,
|
|
greaterThan(0),
|
|
);
|
|
await tester.drag(scrollable, const Offset(0, -1000));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.getRect(find.text(message)).bottom,
|
|
lessThanOrEqualTo(card.bottom),
|
|
);
|
|
});
|
|
|
|
testWidgets('a card stays phone-sized on a wide screen', (tester) async {
|
|
final card = await pumpCard(tester, width: 1024);
|
|
expect(card.width, Grid.xxxl * 6);
|
|
expect(card.right, lessThanOrEqualTo(1024 - Grid.xs));
|
|
});
|
|
|
|
testWidgets('a card opens on tap and closes only from its close button', (
|
|
tester,
|
|
) async {
|
|
var taps = 0;
|
|
var dismissals = 0;
|
|
var pageTaps = 0;
|
|
await pumpCard(
|
|
tester,
|
|
width: 390,
|
|
onTap: () => taps++,
|
|
onDismiss: () => dismissals++,
|
|
onPage: () => pageTaps++,
|
|
);
|
|
expect(find.text('Art'), findsOneWidget);
|
|
final semantics = tester.ensureSemantics();
|
|
// The close button stays its own control inside the tappable card.
|
|
expect(
|
|
tester.getSemantics(find.byTooltip('Dismiss')),
|
|
matchesSemantics(
|
|
tooltip: 'Dismiss',
|
|
isButton: true,
|
|
hasTapAction: true,
|
|
hasEnabledState: true,
|
|
isEnabled: true,
|
|
isFocusable: true,
|
|
hasFocusAction: true,
|
|
),
|
|
);
|
|
semantics.dispose();
|
|
await tester.tap(find.text('Page'));
|
|
expect((pageTaps, taps, dismissals), (1, 0, 0));
|
|
await tester.tap(find.text('Make one'));
|
|
expect((pageTaps, taps, dismissals), (1, 1, 0));
|
|
await tester.tap(find.byTooltip('Dismiss'));
|
|
expect((pageTaps, taps, dismissals), (1, 1, 1));
|
|
});
|
|
}
|