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",
"settingsCategoryGeneral": "Allgemein",
"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",
"settingsCategoryIntegrations": "Integrationen",
"settingsCategorySecurity": "Sicherheit",

View file

@ -1573,6 +1573,9 @@
"doctorPathStudioErrors": "Studio errors",
"settingsCategoryGeneral": "General",
"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",
"settingsCategoryIntegrations": "Integrations",
"settingsCategorySecurity": "Security",

View file

@ -4544,6 +4544,24 @@ abstract class AppLocalizations {
/// **'Appearance'**
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.
///
/// In en, this message translates to:

View file

@ -2666,6 +2666,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get settingsCategoryAi => 'System-KI';

View file

@ -2668,6 +2668,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get settingsCategoryAi => 'System AI';

View file

@ -6,12 +6,14 @@
import 'dart:async';
import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/chain_log.dart';
import 'data/error_presentation.dart';
import 'data/hub.dart';
import 'data/sidebar_prefs.dart';
import 'data/workspace.dart';
import 'data/system_actions.dart';
import 'data/theme_plugin.dart';
@ -74,6 +76,10 @@ Future<void> main() async {
() => HubService.instance.loadLocale(),
);
final themePlugin = await _restoreOr(null, loadActiveThemePlugin);
await _restoreOr(null, () async {
await SidebarPrefs.load();
return null;
});
runApp(
StudioApp(
initialThemeMode: themeMode,
@ -608,8 +614,9 @@ class StudioShellState extends State<StudioShell> {
final theme = Theme.of(context);
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
// Cmd+1..6 jumps to the matching destination. Numbered
// 1-based to match the visual order in the sidebar.
// Cmd+1..9 (Ctrl on non-mac) jumps to the matching
// destination. Numbered 1-based to match the visual
// order in the sidebar.
for (var i = 0; i < _pages.length; i++)
SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
@ -617,6 +624,13 @@ class StudioShellState extends State<StudioShell> {
): _GoToPageIntent(
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
// app-Preferences menu item, which Flutter Desktop
// doesn't wire up — the keystroke is captured by the OS
@ -661,15 +675,19 @@ class StudioShellState extends State<StudioShell> {
child: Scaffold(
body: Row(
children: [
_Sidebar(
selectedIndex: _selectedIndex,
onSelect: (i) => setState(() => _selectedIndex = i),
pages: _pages,
connected: _connected,
endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel,
pendingApprovals: _pendingApprovals,
forceExpanded: widget.startSidebarExpanded,
ValueListenableBuilder<bool>(
valueListenable: SidebarPrefs.pinned,
builder: (context, pinned, _) => _Sidebar(
selectedIndex: _selectedIndex,
onSelect: (i) => setState(() => _selectedIndex = i),
pages: _pages,
connected: _connected,
endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel,
pendingApprovals: _pendingApprovals,
forceExpanded: widget.startSidebarExpanded || pinned,
onOpenSearch: _openSearchPalette,
),
),
Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded(
@ -787,6 +805,11 @@ class _OpenSearchIntent extends Intent {
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 {
final int selectedIndex;
final ValueChanged<int> onSelect;
@ -804,11 +827,17 @@ class _Sidebar extends StatefulWidget {
/// without polling the page.
final int pendingApprovals;
/// Test-only: start fully expanded (controller at 1.0) and disable
/// hover, so a widget test can measure the expanded layout without a
/// hover gesture. Always false in production.
/// Keep the rail fully expanded and disable hover-collapse.
/// True when the operator pins the sidebar (Settings
/// Appearance) and in widget tests measuring the expanded
/// layout.
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({
required this.selectedIndex,
required this.onSelect,
@ -818,6 +847,7 @@ class _Sidebar extends StatefulWidget {
required this.activeChannel,
this.pendingApprovals = 0,
this.forceExpanded = false,
this.onOpenSearch,
});
@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
void dispose() {
_ctrl.dispose();
@ -1026,11 +1066,33 @@ class _SidebarState extends State<_Sidebar>
widget.pendingApprovals > 0
? widget.pendingApprovals
: 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),
),
],
),
),
// 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(
t: t,
labelsInteractive: labelsInteractive,
@ -1535,6 +1597,11 @@ class _SidebarItem extends StatefulWidget {
/// Approvals item for pending count.
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({
super.key,
required this.page,
@ -1544,6 +1611,7 @@ class _SidebarItem extends StatefulWidget {
required this.iconColumnWidth,
required this.onTap,
this.badge,
this.shortcutHint,
});
@override
@ -1627,13 +1695,33 @@ class _SidebarItemState extends State<_SidebarItem> {
opacity: widget.t,
child: Padding(
padding: const EdgeInsets.only(right: ChainSpace.md),
child: Text(
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(
color: color,
),
child: Row(
children: [
Expanded(
child: Text(
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,
),
),
),
],
),
),
),
@ -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
// is visible inline the tooltip becomes redundant noise.
if (widget.t > 0.5) return inner;
return Tooltip(message: label, preferBelow: false, child: inner);
// waitDuration zero: a first-time user hunting through
// 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,
child: Padding(
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(
children: [
_ThemeToggle(),
_LanguageToggle(),
Flexible(child: ClipRect(child: _ThemeToggle())),
Flexible(child: ClipRect(child: _LanguageToggle())),
const Expanded(child: Center(child: _SidebarClock())),
],
),

View file

@ -444,10 +444,13 @@ class _PillarState extends State<_Pillar> {
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
Text(
l.welcomePillarTechToggle,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
Flexible(
child: Text(
l.welcomePillarTechToggle,
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_auth_token.dart';
import '../data/registry_token.dart';
import '../data/sidebar_prefs.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../pages/welcome.dart' show showFaiDoc;
@ -604,6 +605,19 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
theme,
),
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));
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));
expect(
tester.takeException(),

View file

@ -41,6 +41,12 @@ void main() {
testWidgets(
'sidebar destination Y positions are stable across rail expansion',
(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).
await tester.pumpWidget(
const StudioApp(