From 4dbe7a28f6f0750e5f1e0b0cc58e57cb40cd070c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 30 May 2026 14:53:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(studio):=20sidebar=20=E2=80=94=20fix=20icon?= =?UTF-8?q?-shift=20+=20use=20collapsed=20top=20space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX bugs Stefan flagged on the v0.51.0 collapsible rail: 1. Icons re-positioned when the rail expanded. Operators already pointing at an icon had to chase it as the rail grew. Two causes: the _SidebarItem switched between `Center(icon)` (collapsed) and `Row(icon, gap, label)` (expanded), and the AnimatedContainer's horizontal padding switched between FaiSpace.sm (collapsed) and FaiSpace.md (expanded). Both flipped the icon's effective X-position. Now: same Row, same AnimatedContainer padding, in both states. The icon sits at the same X-pixel always; only the label slot toggles open beside it. 2. Top of the collapsed rail was wasted (only the brand-mark was visible; version + connection + channel disappeared completely). Use the same vertical real estate for glance-able compact equivalents: * tiny "v0.51" version text in mono below the brand * _CollapsedConnectionDot — 10 px circle, green/red/amber tonality matching FaiDeltaMark's mode states. Tooltip surfaces the full endpoint string. * _CollapsedChannelChip — 18 px circle with the channel's first letter (P / B / D / L) in the same accent tone the full pill uses, so "production" stays unmistakably red even at-a-glance. When the rail expands, the full pills replace the compact indicators as before. Tests still green (the existing widget test booted without hover-expand and that path stays correct either way). Version: 0.51.0 → 0.51.1 Signed-off-by: flemming-it --- lib/main.dart | 166 ++++++++++++++++++++++++++++++++++++++++++-------- pubspec.yaml | 2 +- 2 files changed, 142 insertions(+), 26 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 6bb1d85..9457765 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.51.0'; +const String kStudioVersion = '0.51.1'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -499,9 +499,7 @@ class _SidebarState extends State<_Sidebar> { ), ), const SizedBox(height: FaiSpace.md), - // Connection pill — only when expanded; the - // status-dot is encoded in the delta-mark's mode - // so collapsed users still see at-a-glance state. + // Connection pill — full version when expanded. Padding( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg), child: _ConnectionPill( @@ -529,6 +527,36 @@ class _SidebarState extends State<_Sidebar> { child: _ChannelPill(channel: widget.activeChannel!), ), ], + ] else ...[ + // Collapsed top: keep the brand mark and the + // most glance-able status indicators (version, + // connection dot, channel letter) so the rail + // still reads as F∆I Studio at-a-glance without + // hovering to expand. + const SizedBox(height: FaiSpace.sm), + Text( + 'v$kStudioVersion'.replaceFirst(RegExp(r'-.*'), ''), + style: FaiTheme.mono( + size: 9, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: FaiSpace.md), + Tooltip( + message: widget.connected == true + ? 'Connected · ${widget.endpointLabel}' + : widget.connected == false + ? 'Disconnected · ${widget.endpointLabel}' + : 'Connecting · ${widget.endpointLabel}', + child: _CollapsedConnectionDot(connected: widget.connected), + ), + if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.sm), + Tooltip( + message: widget.activeChannel!, + child: _CollapsedChannelChip(channel: widget.activeChannel!), + ), + ], ], const SizedBox(height: FaiSpace.xxl), // Destinations. @@ -671,6 +699,91 @@ class _ChannelPill extends StatelessWidget { } } +/// Mini-version of [_ConnectionPill] used when the sidebar is +/// collapsed. A 10px dot in the same green/red/amber tonality +/// the FaiDeltaMark uses for live / idle / unknown. +class _CollapsedConnectionDot extends StatelessWidget { + final bool? connected; + const _CollapsedConnectionDot({required this.connected}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = connected == true + ? FaiColors.success + : connected == false + ? theme.colorScheme.error + : FaiColors.warning; + return Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.35), + blurRadius: 4, + spreadRadius: 0.5, + ), + ], + ), + ); + } +} + +/// Mini-version of [_ChannelPill] used when the sidebar is +/// collapsed. A 16px circular chip with the first letter of +/// the channel (P / B / D / L) in the same accent tone the +/// full pill uses, so production stays unmistakeable at a +/// glance. +class _CollapsedChannelChip extends StatelessWidget { + final String channel; + const _CollapsedChannelChip({required this.channel}); + + Color _accent(ColorScheme cs) { + switch (channel) { + case 'production': + return cs.error; + case 'beta': + return FaiColors.warning; + case 'dev': + return cs.primary; + case 'local': + default: + return cs.onSurfaceVariant; + } + } + + String get _letter { + if (channel.isEmpty) return '·'; + return channel.substring(0, 1).toUpperCase(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final accent = _accent(theme.colorScheme); + return Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: accent.withValues(alpha: 0.55)), + ), + alignment: Alignment.center, + child: Text( + _letter, + style: FaiTheme.mono( + size: 10, + weight: FontWeight.w700, + color: accent, + ), + ), + ); + } +} + class _ConnectionPill extends StatelessWidget { final bool? connected; final String label; @@ -808,25 +921,28 @@ class _SidebarItemState extends State<_SidebarItem> { size: 18, color: color, ); - final Widget content = widget.expanded - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - icon, - const SizedBox(width: FaiSpace.md), - // Flexible so the label can ellipsize during the - // collapse animation instead of overflowing. - Flexible( - child: Text( - label, - overflow: TextOverflow.fade, - softWrap: false, - style: theme.textTheme.labelMedium?.copyWith(color: color), - ), - ), - ], - ) - : Center(child: icon); + // Same Row + same outer padding in both states so the icon + // stays at the exact same X-pixel before and after expand + // (operators don't have to re-aim at a destination they + // already pointed at). The label slot is conditional, the + // icon's position is not. + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + if (widget.expanded) ...[ + const SizedBox(width: FaiSpace.md), + Flexible( + child: Text( + label, + overflow: TextOverflow.fade, + softWrap: false, + style: theme.textTheme.labelMedium?.copyWith(color: color), + ), + ), + ], + ], + ); final inner = Padding( padding: const EdgeInsets.symmetric(vertical: 2), @@ -839,8 +955,8 @@ class _SidebarItemState extends State<_SidebarItem> { onTap: widget.onTap, child: AnimatedContainer( duration: FaiMotion.fast, - padding: EdgeInsets.symmetric( - horizontal: widget.expanded ? FaiSpace.md : FaiSpace.sm, + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, vertical: FaiSpace.md, ), decoration: BoxDecoration( diff --git a/pubspec.yaml b/pubspec.yaml index 248658e..ad82360 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.51.0 +version: 0.51.1 environment: sdk: ^3.11.0-200.1.beta