diff --git a/lib/data/sidebar_prefs.dart b/lib/data/sidebar_prefs.dart new file mode 100644 index 0000000..035bf83 --- /dev/null +++ b/lib/data/sidebar_prefs.dart @@ -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 pinned = ValueNotifier(false); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + pinned.value = prefs.getBool(_kPinnedKey) ?? false; + } + + static Future setPinned(bool value) async { + pinned.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kPinnedKey, value); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 43b1427..2de43fe 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ec0593e..7d57dc1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f34b5cc..fc9fc81 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 136c1b6..f05a2af 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 86b1c66..761dbe2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/main.dart b/lib/main.dart index bf3f29a..f2e1244 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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 { final theme = Theme.of(context); return Shortcuts( shortcuts: { - // 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 { ): _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 { 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( + 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 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())), ], ), diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 0c2f6f8..dcd4f08 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -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, + ), ), ), ], diff --git a/lib/widgets/chain_settings_dialog.dart b/lib/widgets/chain_settings_dialog.dart index 35f78ef..24263d6 100644 --- a/lib/widgets/chain_settings_dialog.dart +++ b/lib/widgets/chain_settings_dialog.dart @@ -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 { 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( + 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), + ), + ), ]; } diff --git a/test/responsive_test.dart b/test/responsive_test.dart index 744febe..e151849 100644 --- a/test/responsive_test.dart +++ b/test/responsive_test.dart @@ -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(), diff --git a/test/sidebar_test.dart b/test/sidebar_test.dart index 4e8df1e..b6d3c7d 100644 --- a/test/sidebar_test.dart +++ b/test/sidebar_test.dart @@ -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(