feat(docs): in-place help pattern — explain a surface where it happens
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
New ChainInlineHelp (intro strip: what this is + what will happen, with an optional 'Learn more' into the doc sheet) and ChainFieldHelp / ChainFieldLabel (a '?' affordance per field). First applied to the add-satellite dialog, which asked for a bare 'name' with no hint of what a satellite is or does (usertest): it now leads with a plain explanation + a federation 'Learn more', and the name field carries a '?'. Both widgets are quiet by design. Verified: field_help_test covers the widgets + that the dialog explains itself; dialog_shots_test.dart (a reusable headed dialog-capture harness) proved the layout in light + dark. Studio 0.76.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
5a3f00bb2c
commit
87afa4dc05
8 changed files with 390 additions and 9 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -6,6 +6,24 @@ lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added — explain it where it happens (0.76.0)
|
||||||
|
|
||||||
|
- **In-place help pattern.** New `ChainInlineHelp` (a one-sentence
|
||||||
|
intro strip that says what a surface is and what will happen, with
|
||||||
|
an optional "Learn more" link into the doc sheet) and
|
||||||
|
`ChainFieldHelp` / `ChainFieldLabel` (a "?" affordance for a single
|
||||||
|
field). First applied to the add-satellite dialog, which used to
|
||||||
|
ask for a "name" with no hint of what a satellite even is
|
||||||
|
(usertest): it now leads with a plain explanation + a federation
|
||||||
|
"Learn more", and the name field carries a "?".
|
||||||
|
- **Page help opens the RIGHT topic.** The Federation and Runs help
|
||||||
|
buttons silently fell back to the architecture doc — `federation`
|
||||||
|
and `runs` had markdown assets but no catalog entry, so
|
||||||
|
`showFaiDoc` resolved them to the first doc. Both are registered
|
||||||
|
now; a new `doc_help_wiring_test.dart` asserts every
|
||||||
|
`showFaiDoc('slug')` call has a registered entry AND both locale
|
||||||
|
assets, so a help button can never open the wrong topic again.
|
||||||
|
|
||||||
### Security (0.75.0)
|
### Security (0.75.0)
|
||||||
|
|
||||||
- **Sealed-area names are confidential by default.** The workspace
|
- **Sealed-area names are confidential by default.** The workspace
|
||||||
|
|
|
||||||
79
integration_test/dialog_shots_test.dart
Normal file
79
integration_test/dialog_shots_test.dart
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// Dialog capture harness — visual proof for dialogs the page-level
|
||||||
|
// guide harness doesn't reach. Renders the target dialog against
|
||||||
|
// the scriptable fake hub and writes a PNG per theme via a
|
||||||
|
// RepaintBoundary (driverless, headed macOS `flutter test`).
|
||||||
|
//
|
||||||
|
// flutter test integration_test/dialog_shots_test.dart -d macos
|
||||||
|
//
|
||||||
|
// Output: build/dialog-shots/<name>-<theme>.png (or $DIALOG_SHOTS_OUT).
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||||
|
import 'package:chain_studio/pages/federation.dart';
|
||||||
|
|
||||||
|
import '../test/support/fake_hub.dart';
|
||||||
|
|
||||||
|
final GlobalKey _shotKey = GlobalKey();
|
||||||
|
|
||||||
|
String get _outDir =>
|
||||||
|
Platform.environment['DIALOG_SHOTS_OUT'] ?? 'build/dialog-shots';
|
||||||
|
|
||||||
|
Future<void> _shot(WidgetTester tester, String name) async {
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
final boundary =
|
||||||
|
_shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||||
|
final image = await boundary.toImage(pixelRatio: 2.0);
|
||||||
|
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
image.dispose();
|
||||||
|
final file = File('$_outDir/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('dialog-shot: ${file.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
for (final (themeName, mode) in [
|
||||||
|
('light', ThemeMode.light),
|
||||||
|
('dark', ThemeMode.dark),
|
||||||
|
]) {
|
||||||
|
testWidgets('add-satellite dialog — $themeName', (tester) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
installFakeHub();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
themeMode: mode,
|
||||||
|
theme: ThemeData.light(useMaterial3: true),
|
||||||
|
darkTheme: ThemeData.dark(useMaterial3: true),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('de'),
|
||||||
|
// Wrap the whole navigator (incl. the overlay dialogs
|
||||||
|
// render into) so the capture catches the dialog, not
|
||||||
|
// just the page beneath it.
|
||||||
|
builder: (context, child) =>
|
||||||
|
RepaintBoundary(key: _shotKey, child: child),
|
||||||
|
home: const FederationPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await tester.tap(find.text('Satellit hinzufügen').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await _shot(tester, 'add-satellite-$themeName');
|
||||||
|
expect(find.textContaining('Ein Satellit ist ein weiterer Hub'),
|
||||||
|
findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
/// Studio's own build version. Bump on every UI release so the
|
/// Studio's own build version. Bump on every UI release so the
|
||||||
/// running app self-identifies.
|
/// running app self-identifies.
|
||||||
const String kStudioVersion = '0.75.0';
|
const String kStudioVersion = '0.76.0';
|
||||||
|
|
||||||
const String kProductName = 'Ch∆In Studio';
|
const String kProductName = 'Ch∆In Studio';
|
||||||
const String kVendorName = 'Flemming.AI (F∆I)';
|
const String kVendorName = 'Flemming.AI (F∆I)';
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,38 @@ class _FederationPageState extends State<FederationPage> {
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(l.federationAddDialogTitle),
|
title: Text(l.federationAddDialogTitle),
|
||||||
content: TextField(
|
content: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Say what this is and what will happen before asking
|
||||||
|
// for input — a bare "name" field left the operator
|
||||||
|
// guessing what a satellite even is (usertest).
|
||||||
|
ChainInlineHelp(
|
||||||
|
text: l.federationAddIntro,
|
||||||
|
icon: Icons.hub_outlined,
|
||||||
|
onLearnMore: () => showFaiDoc(ctx, 'federation'),
|
||||||
|
learnMoreLabel: l.buttonLearnMore,
|
||||||
|
),
|
||||||
|
const SizedBox(height: ChainSpace.lg),
|
||||||
|
ChainFieldLabel(
|
||||||
|
label: l.federationNameLabel,
|
||||||
|
help: l.federationNameHelp,
|
||||||
|
),
|
||||||
|
const SizedBox(height: ChainSpace.xs),
|
||||||
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: l.federationNameLabel,
|
|
||||||
hintText: 'satellite-a',
|
hintText: 'satellite-a',
|
||||||
border: const OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
|
||||||
169
lib/widgets/chain_field_help.dart
Normal file
169
lib/widgets/chain_field_help.dart
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
// In-place help — the "explain it where it happens" pattern
|
||||||
|
// (usertest: the Add-satellite dialog asked for a name with no hint
|
||||||
|
// of what a satellite is or what the name does). Two pieces:
|
||||||
|
//
|
||||||
|
// • ChainInlineHelp — a calm intro strip at the top of a dialog
|
||||||
|
// or surface: one plain sentence saying what this is and what
|
||||||
|
// will happen, with an optional "Learn more" link into the
|
||||||
|
// full doc sheet.
|
||||||
|
// • ChainFieldHelp — a small "?" affordance to sit next to a
|
||||||
|
// single field's label; tap/hover reveals a one-line
|
||||||
|
// explanation. Use it only where a field genuinely needs it.
|
||||||
|
//
|
||||||
|
// Both are intentionally quiet: help should be present, not loud.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
|
/// A one-sentence intro strip for the top of a dialog or panel.
|
||||||
|
/// [onLearnMore] wires the "Learn more" link to a doc sheet
|
||||||
|
/// (`showFaiDoc`), shown only when provided.
|
||||||
|
class ChainInlineHelp extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback? onLearnMore;
|
||||||
|
final String? learnMoreLabel;
|
||||||
|
|
||||||
|
const ChainInlineHelp({
|
||||||
|
super.key,
|
||||||
|
required this.text,
|
||||||
|
this.icon = Icons.info_outline,
|
||||||
|
this.onLearnMore,
|
||||||
|
this.learnMoreLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(ChainSpace.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: ChainSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onLearnMore != null) ...[
|
||||||
|
const SizedBox(height: ChainSpace.xs),
|
||||||
|
InkWell(
|
||||||
|
onTap: onLearnMore,
|
||||||
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
learnMoreLabel ?? 'Learn more',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_forward,
|
||||||
|
size: 12,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A "?" info affordance for a single field. Sit it next to the
|
||||||
|
/// field's label; hover shows the [message] as a tooltip, and a
|
||||||
|
/// tap reveals it too (touch / keyboard users who don't hover).
|
||||||
|
/// Semantics carry [message] for screen readers.
|
||||||
|
class ChainFieldHelp extends StatefulWidget {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ChainFieldHelp({super.key, required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChainFieldHelp> createState() => _ChainFieldHelpState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChainFieldHelpState extends State<ChainFieldHelp> {
|
||||||
|
final _tooltipKey = GlobalKey<TooltipState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Tooltip(
|
||||||
|
key: _tooltipKey,
|
||||||
|
message: widget.message,
|
||||||
|
waitDuration: const Duration(milliseconds: 300),
|
||||||
|
triggerMode: TooltipTriggerMode.manual,
|
||||||
|
preferBelow: false,
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: widget.message,
|
||||||
|
child: InkResponse(
|
||||||
|
radius: 14,
|
||||||
|
// Manual trigger so a tap (not just hover) reveals it.
|
||||||
|
onTap: () => _tooltipKey.currentState?.ensureTooltipVisible(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.help_outline,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A field label with a trailing [ChainFieldHelp]. Convenience for
|
||||||
|
/// the common "label + ?" row above a TextField.
|
||||||
|
class ChainFieldLabel extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String help;
|
||||||
|
|
||||||
|
const ChainFieldLabel({super.key, required this.label, required this.help});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
ChainFieldHelp(message: help),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ export 'chain_delta_mark.dart';
|
||||||
export 'chain_empty_state.dart';
|
export 'chain_empty_state.dart';
|
||||||
export 'chain_en_badge.dart';
|
export 'chain_en_badge.dart';
|
||||||
export 'chain_error_box.dart';
|
export 'chain_error_box.dart';
|
||||||
|
export 'chain_field_help.dart';
|
||||||
export 'chain_segments.dart';
|
export 'chain_segments.dart';
|
||||||
export 'hub_load_error_view.dart';
|
export 'hub_load_error_view.dart';
|
||||||
export 'chain_flow_output.dart';
|
export 'chain_flow_output.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: chain_studio
|
name: chain_studio
|
||||||
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.75.0
|
version: 0.76.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
89
test/field_help_test.dart
Normal file
89
test/field_help_test.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// In-place help widgets + the satellite dialog that consumes them.
|
||||||
|
// The pattern (usertest): explain a surface where it happens —
|
||||||
|
// an intro strip saying what this is and what will happen, plus a
|
||||||
|
// per-field "?" for the details.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||||
|
import 'package:chain_studio/pages/federation.dart';
|
||||||
|
import 'package:chain_studio/widgets/chain_field_help.dart';
|
||||||
|
|
||||||
|
import 'support/fake_hub.dart';
|
||||||
|
|
||||||
|
Widget _host(Widget child) => MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('de'),
|
||||||
|
home: Scaffold(body: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('ChainInlineHelp shows the text and fires Learn more', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var learned = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(
|
||||||
|
ChainInlineHelp(
|
||||||
|
text: 'Ein Satellit ist ein weiterer Hub.',
|
||||||
|
onLearnMore: () => learned = true,
|
||||||
|
learnMoreLabel: 'Mehr erfahren',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('Ein Satellit ist ein weiterer Hub.'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Mehr erfahren'));
|
||||||
|
expect(learned, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ChainInlineHelp hides Learn more when no callback', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(const ChainInlineHelp(text: 'Nur Text, kein Link.')),
|
||||||
|
);
|
||||||
|
expect(find.text('Nur Text, kein Link.'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.arrow_forward), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ChainFieldHelp tap reveals the tooltip message', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(
|
||||||
|
const ChainFieldLabel(
|
||||||
|
label: 'Satelliten-Name',
|
||||||
|
help: 'Frei wählbarer Anzeigename.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The help text is not shown until the affordance is used.
|
||||||
|
expect(find.text('Frei wählbarer Anzeigename.'), findsNothing);
|
||||||
|
await tester.tap(find.byIcon(Icons.help_outline));
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(find.text('Frei wählbarer Anzeigename.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Add-satellite dialog explains what a satellite is', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
installFakeHub();
|
||||||
|
await tester.pumpWidget(_host(const FederationPage()));
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Open the add flow via the AppBar action (the label also
|
||||||
|
// appears in the empty-state CTA; either opens the dialog).
|
||||||
|
await tester.tap(find.text('Satellit hinzufügen').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The intro strip + its Learn-more link are present BEFORE any
|
||||||
|
// input is asked for — no more bare "name" field.
|
||||||
|
expect(find.textContaining('Ein Satellit ist ein weiterer Hub'),
|
||||||
|
findsOneWidget);
|
||||||
|
expect(find.text('Mehr erfahren'), findsOneWidget);
|
||||||
|
// Field-level "?" is wired too.
|
||||||
|
expect(find.byType(ChainFieldHelp), findsWidgets);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue