feat(nav): discoverable sidebar — instant tooltips, shortcuts made visible, pinnable rail
Some checks are pending
Security / Security check (push) Waiting to run

The icon-only rail forced first-time users to guess (usertest:
Senior, a11y, UX personas). Three changes:
- Nav tooltips appear instantly and carry the page shortcut
  (Cmd+1..9, Ctrl on non-mac — Ctrl activators added); expanded
  labels show the same hint. Explicit button semantics for
  screen readers on every destination.
- A visible 'Search & commands' row above the footer opens the
  existing Cmd+K palette, which nothing in the UI advertised.
- Settings -> Appearance gains 'Keep the navigation expanded':
  pins the rail with permanent labels (persisted preference).
Footer strip and pillar toggle made overflow-safe for the
animating rail; responsive test scrolls the by-design scrollable
destinations list.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-17 23:36:33 +02:00
parent ca01fd2ec0
commit c436e12601
11 changed files with 364 additions and 30 deletions

View file

@ -0,0 +1,23 @@
// Operator preference: keep the sidebar permanently expanded so
// every destination shows its text label without hovering. Off by
// default (hover-expand rail); a11y/senior operators flip it in
// Settings Appearance.
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SidebarPrefs {
static const _kPinnedKey = 'sidebar.pinned';
static final ValueNotifier<bool> pinned = ValueNotifier(false);
static Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
pinned.value = prefs.getBool(_kPinnedKey) ?? false;
}
static Future<void> setPinned(bool value) async {
pinned.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kPinnedKey, value);
}
}

View file

@ -1549,6 +1549,9 @@
"doctorPathStudioErrors": "Studio-Fehler", "doctorPathStudioErrors": "Studio-Fehler",
"settingsCategoryGeneral": "Allgemein", "settingsCategoryGeneral": "Allgemein",
"settingsCategoryAppearance": "Darstellung", "settingsCategoryAppearance": "Darstellung",
"sidebarSearchLabel": "Suchen & Befehle",
"settingsSidebarPinnedTitle": "Navigation immer ausgeklappt",
"settingsSidebarPinnedBody": "Zeigt die Beschriftungen der Seitenleiste dauerhaft an, statt sie nur beim Überfahren mit der Maus einzublenden.",
"settingsCategoryAi": "System-KI", "settingsCategoryAi": "System-KI",
"settingsCategoryIntegrations": "Integrationen", "settingsCategoryIntegrations": "Integrationen",
"settingsCategorySecurity": "Sicherheit", "settingsCategorySecurity": "Sicherheit",

View file

@ -1573,6 +1573,9 @@
"doctorPathStudioErrors": "Studio errors", "doctorPathStudioErrors": "Studio errors",
"settingsCategoryGeneral": "General", "settingsCategoryGeneral": "General",
"settingsCategoryAppearance": "Appearance", "settingsCategoryAppearance": "Appearance",
"sidebarSearchLabel": "Search & commands",
"settingsSidebarPinnedTitle": "Keep the navigation expanded",
"settingsSidebarPinnedBody": "Shows the sidebar labels permanently instead of only while hovering with the mouse.",
"settingsCategoryAi": "System AI", "settingsCategoryAi": "System AI",
"settingsCategoryIntegrations": "Integrations", "settingsCategoryIntegrations": "Integrations",
"settingsCategorySecurity": "Security", "settingsCategorySecurity": "Security",

View file

@ -4544,6 +4544,24 @@ abstract class AppLocalizations {
/// **'Appearance'** /// **'Appearance'**
String get settingsCategoryAppearance; String get settingsCategoryAppearance;
/// No description provided for @sidebarSearchLabel.
///
/// In en, this message translates to:
/// **'Search & commands'**
String get sidebarSearchLabel;
/// No description provided for @settingsSidebarPinnedTitle.
///
/// In en, this message translates to:
/// **'Keep the navigation expanded'**
String get settingsSidebarPinnedTitle;
/// No description provided for @settingsSidebarPinnedBody.
///
/// In en, this message translates to:
/// **'Shows the sidebar labels permanently instead of only while hovering with the mouse.'**
String get settingsSidebarPinnedBody;
/// No description provided for @settingsCategoryAi. /// No description provided for @settingsCategoryAi.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2666,6 +2666,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsCategoryAppearance => 'Darstellung'; String get settingsCategoryAppearance => 'Darstellung';
@override
String get sidebarSearchLabel => 'Suchen & Befehle';
@override
String get settingsSidebarPinnedTitle => 'Navigation immer ausgeklappt';
@override
String get settingsSidebarPinnedBody =>
'Zeigt die Beschriftungen der Seitenleiste dauerhaft an, statt sie nur beim Überfahren mit der Maus einzublenden.';
@override @override
String get settingsCategoryAi => 'System-KI'; String get settingsCategoryAi => 'System-KI';

View file

@ -2668,6 +2668,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsCategoryAppearance => 'Appearance'; String get settingsCategoryAppearance => 'Appearance';
@override
String get sidebarSearchLabel => 'Search & commands';
@override
String get settingsSidebarPinnedTitle => 'Keep the navigation expanded';
@override
String get settingsSidebarPinnedBody =>
'Shows the sidebar labels permanently instead of only while hovering with the mouse.';
@override @override
String get settingsCategoryAi => 'System AI'; String get settingsCategoryAi => 'System AI';

View file

@ -6,12 +6,14 @@
import 'dart:async'; import 'dart:async';
import 'package:chain_client_sdk/chain_client_sdk.dart'; import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'data/chain_log.dart'; import 'data/chain_log.dart';
import 'data/error_presentation.dart'; import 'data/error_presentation.dart';
import 'data/hub.dart'; import 'data/hub.dart';
import 'data/sidebar_prefs.dart';
import 'data/workspace.dart'; import 'data/workspace.dart';
import 'data/system_actions.dart'; import 'data/system_actions.dart';
import 'data/theme_plugin.dart'; import 'data/theme_plugin.dart';
@ -74,6 +76,10 @@ Future<void> main() async {
() => HubService.instance.loadLocale(), () => HubService.instance.loadLocale(),
); );
final themePlugin = await _restoreOr(null, loadActiveThemePlugin); final themePlugin = await _restoreOr(null, loadActiveThemePlugin);
await _restoreOr(null, () async {
await SidebarPrefs.load();
return null;
});
runApp( runApp(
StudioApp( StudioApp(
initialThemeMode: themeMode, initialThemeMode: themeMode,
@ -608,8 +614,9 @@ class StudioShellState extends State<StudioShell> {
final theme = Theme.of(context); final theme = Theme.of(context);
return Shortcuts( return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{ shortcuts: <ShortcutActivator, Intent>{
// Cmd+1..6 jumps to the matching destination. Numbered // Cmd+1..9 (Ctrl on non-mac) jumps to the matching
// 1-based to match the visual order in the sidebar. // destination. Numbered 1-based to match the visual
// order in the sidebar.
for (var i = 0; i < _pages.length; i++) for (var i = 0; i < _pages.length; i++)
SingleActivator( SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i), LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
@ -617,6 +624,13 @@ class StudioShellState extends State<StudioShell> {
): _GoToPageIntent( ): _GoToPageIntent(
i, i,
), ),
for (var i = 0; i < _pages.length; i++)
SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
control: true,
): _GoToPageIntent(
i,
),
// Settings dialog. macOS reserves Cmd+, for the native // Settings dialog. macOS reserves Cmd+, for the native
// app-Preferences menu item, which Flutter Desktop // app-Preferences menu item, which Flutter Desktop
// doesn't wire up — the keystroke is captured by the OS // doesn't wire up — the keystroke is captured by the OS
@ -661,15 +675,19 @@ class StudioShellState extends State<StudioShell> {
child: Scaffold( child: Scaffold(
body: Row( body: Row(
children: [ children: [
_Sidebar( ValueListenableBuilder<bool>(
selectedIndex: _selectedIndex, valueListenable: SidebarPrefs.pinned,
onSelect: (i) => setState(() => _selectedIndex = i), builder: (context, pinned, _) => _Sidebar(
pages: _pages, selectedIndex: _selectedIndex,
connected: _connected, onSelect: (i) => setState(() => _selectedIndex = i),
endpointLabel: HubService.instance.endpointLabel, pages: _pages,
activeChannel: _activeChannel, connected: _connected,
pendingApprovals: _pendingApprovals, endpointLabel: HubService.instance.endpointLabel,
forceExpanded: widget.startSidebarExpanded, activeChannel: _activeChannel,
pendingApprovals: _pendingApprovals,
forceExpanded: widget.startSidebarExpanded || pinned,
onOpenSearch: _openSearchPalette,
),
), ),
Container(width: 1, color: theme.colorScheme.outlineVariant), Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded( Expanded(
@ -787,6 +805,11 @@ class _OpenSearchIntent extends Intent {
const _OpenSearchIntent(); const _OpenSearchIntent();
} }
/// Platform-truthful label for the app's primary-modifier
/// shortcuts (the activators bind on macOS and Ctrl elsewhere).
String _metaShortcut(String key) =>
defaultTargetPlatform == TargetPlatform.macOS ? '$key' : 'Ctrl+$key';
class _Sidebar extends StatefulWidget { class _Sidebar extends StatefulWidget {
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onSelect; final ValueChanged<int> onSelect;
@ -804,11 +827,17 @@ class _Sidebar extends StatefulWidget {
/// without polling the page. /// without polling the page.
final int pendingApprovals; final int pendingApprovals;
/// Test-only: start fully expanded (controller at 1.0) and disable /// Keep the rail fully expanded and disable hover-collapse.
/// hover, so a widget test can measure the expanded layout without a /// True when the operator pins the sidebar (Settings
/// hover gesture. Always false in production. /// Appearance) and in widget tests measuring the expanded
/// layout.
final bool forceExpanded; final bool forceExpanded;
/// Opens the Cmd+K command palette the sidebar carries a
/// visible search row so the palette is discoverable without
/// knowing the shortcut.
final VoidCallback? onOpenSearch;
const _Sidebar({ const _Sidebar({
required this.selectedIndex, required this.selectedIndex,
required this.onSelect, required this.onSelect,
@ -818,6 +847,7 @@ class _Sidebar extends StatefulWidget {
required this.activeChannel, required this.activeChannel,
this.pendingApprovals = 0, this.pendingApprovals = 0,
this.forceExpanded = false, this.forceExpanded = false,
this.onOpenSearch,
}); });
@override @override
@ -863,6 +893,16 @@ class _SidebarState extends State<_Sidebar>
); );
} }
@override
void didUpdateWidget(covariant _Sidebar old) {
super.didUpdateWidget(old);
// The pin toggle flips at runtime (Settings Appearance):
// animate to the new resting state instead of jumping.
if (widget.forceExpanded != old.forceExpanded) {
widget.forceExpanded ? _ctrl.forward() : _ctrl.reverse();
}
}
@override @override
void dispose() { void dispose() {
_ctrl.dispose(); _ctrl.dispose();
@ -1026,11 +1066,33 @@ class _SidebarState extends State<_Sidebar>
widget.pendingApprovals > 0 widget.pendingApprovals > 0
? widget.pendingApprovals ? widget.pendingApprovals
: null, : null,
// Cmd+1..9 jump to the destination; the
// hint rides in tooltip + expanded label
// so the shortcut is discoverable.
shortcutHint: i < 9 ? _metaShortcut('${i + 1}') : null,
onTap: () => widget.onSelect(i), onTap: () => widget.onSelect(i),
), ),
], ],
), ),
), ),
// Visible entry point for the Cmd+K palette the
// shortcut exists since 0.3x but nothing in the UI
// said so (usertest: "no shortcuts anywhere").
if (widget.onOpenSearch != null)
Builder(
builder: (context) {
final l = AppLocalizations.of(context)!;
return _SidebarActionRow(
icon: Icons.search,
label: l.sidebarSearchLabel,
shortcutHint: _metaShortcut('K'),
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
onTap: widget.onOpenSearch!,
);
},
),
_Footer( _Footer(
t: t, t: t,
labelsInteractive: labelsInteractive, labelsInteractive: labelsInteractive,
@ -1535,6 +1597,11 @@ class _SidebarItem extends StatefulWidget {
/// Approvals item for pending count. /// Approvals item for pending count.
final int? badge; final int? badge;
/// Keyboard shortcut ('⌘2') shown in the tooltip and, subtly,
/// next to the expanded label discoverability for the
/// existing Cmd+1..9 navigation.
final String? shortcutHint;
const _SidebarItem({ const _SidebarItem({
super.key, super.key,
required this.page, required this.page,
@ -1544,6 +1611,7 @@ class _SidebarItem extends StatefulWidget {
required this.iconColumnWidth, required this.iconColumnWidth,
required this.onTap, required this.onTap,
this.badge, this.badge,
this.shortcutHint,
}); });
@override @override
@ -1627,13 +1695,33 @@ class _SidebarItemState extends State<_SidebarItem> {
opacity: widget.t, opacity: widget.t,
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: ChainSpace.md), padding: const EdgeInsets.only(right: ChainSpace.md),
child: Text( child: Row(
label, children: [
overflow: TextOverflow.fade, Expanded(
softWrap: false, child: Text(
style: theme.textTheme.labelMedium?.copyWith( label,
color: color, overflow: TextOverflow.fade,
), softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(
color: color,
),
),
),
if (widget.shortcutHint != null)
Flexible(
child: Text(
widget.shortcutHint!,
overflow: TextOverflow.clip,
softWrap: false,
maxLines: 1,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
fontSize: 10,
),
),
),
],
), ),
), ),
), ),
@ -1674,10 +1762,156 @@ class _SidebarItemState extends State<_SidebarItem> {
), ),
); );
// Explicit button semantics: the collapsed rail is icon-only,
// so a screen reader needs the destination name + role from
// here, not from a hover-only tooltip.
final semantic = Semantics(
button: true,
selected: widget.selected,
label: widget.shortcutHint == null
? label
: '$label (${widget.shortcutHint})',
child: ExcludeSemantics(child: inner),
);
// Tooltip only while the label is hidden once the label // Tooltip only while the label is hidden once the label
// is visible inline the tooltip becomes redundant noise. // is visible inline the tooltip becomes redundant noise.
if (widget.t > 0.5) return inner; // waitDuration zero: a first-time user hunting through
return Tooltip(message: label, preferBelow: false, child: inner); // unlabeled icons should not have to linger per icon.
if (widget.t > 0.5) return semantic;
return Tooltip(
message: widget.shortcutHint == null
? label
: '$label · ${widget.shortcutHint}',
preferBelow: false,
waitDuration: Duration.zero,
child: semantic,
);
}
}
/// Sidebar utility row (search/palette) same geometry and
/// hover behaviour as a destination item, minus selection state.
class _SidebarActionRow extends StatefulWidget {
final IconData icon;
final String label;
final String? shortcutHint;
final double t;
final bool labelsInteractive;
final double iconColumnWidth;
final VoidCallback onTap;
const _SidebarActionRow({
required this.icon,
required this.label,
required this.shortcutHint,
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onTap,
});
@override
State<_SidebarActionRow> createState() => _SidebarActionRowState();
}
class _SidebarActionRowState extends State<_SidebarActionRow> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final content = Row(
children: [
SizedBox(
width: widget.iconColumnWidth,
child: Center(child: Icon(widget.icon, size: 18, color: color)),
),
Expanded(
child: widget.t > 0
? IgnorePointer(
ignoring: !widget.labelsInteractive,
child: Opacity(
opacity: widget.t,
child: Padding(
padding: const EdgeInsets.only(right: ChainSpace.md),
child: Row(
children: [
Expanded(
child: Text(
widget.label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(
color: color,
),
),
),
if (widget.shortcutHint != null)
Flexible(
child: Text(
widget.shortcutHint!,
overflow: TextOverflow.clip,
softWrap: false,
maxLines: 1,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
fontSize: 10,
),
),
),
],
),
),
),
)
: const SizedBox.shrink(),
),
],
);
final inner = Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: AnimatedContainer(
duration: ChainMotion.fast,
padding: const EdgeInsets.symmetric(vertical: ChainSpace.md),
decoration: BoxDecoration(
color: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent,
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
child: content,
),
),
),
);
final semantic = Semantics(
button: true,
label: widget.shortcutHint == null
? widget.label
: '${widget.label} (${widget.shortcutHint})',
child: ExcludeSemantics(child: inner),
);
if (widget.t > 0.5) return semantic;
return Tooltip(
message: widget.shortcutHint == null
? widget.label
: '${widget.label} · ${widget.shortcutHint}',
preferBelow: false,
waitDuration: Duration.zero,
child: semantic,
);
} }
} }
@ -1724,10 +1958,14 @@ class _Footer extends StatelessWidget {
opacity: t, opacity: t,
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: ChainSpace.sm), padding: const EdgeInsets.only(right: ChainSpace.sm),
// Flexibles + clip: the strip lives inside the
// animating rail, so a transient narrow width
// must degrade gracefully instead of throwing
// a RenderFlex overflow.
child: Row( child: Row(
children: [ children: [
_ThemeToggle(), Flexible(child: ClipRect(child: _ThemeToggle())),
_LanguageToggle(), Flexible(child: ClipRect(child: _LanguageToggle())),
const Expanded(child: Center(child: _SidebarClock())), const Expanded(child: Center(child: _SidebarClock())),
], ],
), ),

View file

@ -444,10 +444,13 @@ class _PillarState extends State<_Pillar> {
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Flexible(
l.welcomePillarTechToggle, child: Text(
style: theme.textTheme.labelMedium?.copyWith( l.welcomePillarTechToggle,
color: theme.colorScheme.primary, overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
),
), ),
), ),
], ],

View file

@ -9,6 +9,7 @@ import '../data/error_presentation.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../data/hub_auth_token.dart'; import '../data/hub_auth_token.dart';
import '../data/registry_token.dart'; import '../data/registry_token.dart';
import '../data/sidebar_prefs.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../pages/welcome.dart' show showFaiDoc; import '../pages/welcome.dart' show showFaiDoc;
@ -604,6 +605,19 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
theme, theme,
), ),
const _ThemePluginPanel(), const _ThemePluginPanel(),
const SizedBox(height: ChainSpace.xl),
// Pinned rail: permanent sidebar labels for operators who
// don't want (or can't use) the hover-expand gesture.
ValueListenableBuilder<bool>(
valueListenable: SidebarPrefs.pinned,
builder: (context, pinned, _) => SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l.settingsSidebarPinnedTitle),
subtitle: Text(l.settingsSidebarPinnedBody),
value: pinned,
onChanged: (v) => SidebarPrefs.setPinned(v),
),
),
]; ];
} }

View file

@ -48,7 +48,13 @@ void main() {
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
for (final id in _destinations) { for (final id in _destinations) {
await tester.tap(find.byKey(ValueKey('sidebar-item-$id'))); // The destinations list scrolls by design: at 600-px-high
// windows the rail (destinations + search row + footer)
// exceeds the viewport, so bring the item into view first.
final item = find.byKey(ValueKey('sidebar-item-$id'));
await tester.scrollUntilVisible(item, 40,
scrollable: find.byType(Scrollable).first);
await tester.tap(item);
await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400));
expect( expect(
tester.takeException(), tester.takeException(),

View file

@ -41,6 +41,12 @@ void main() {
testWidgets( testWidgets(
'sidebar destination Y positions are stable across rail expansion', 'sidebar destination Y positions are stable across rail expansion',
(tester) async { (tester) async {
// Desktop-sized surface: the rail carries 8 destinations plus
// the search row and footer the 600-px default viewport
// scrolls the last destination out of the ListView.
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
// Collapsed (default). // Collapsed (default).
await tester.pumpWidget( await tester.pumpWidget(
const StudioApp( const StudioApp(