feat(ui): make Fleet, Settings, and the dashboard table usable on mobile (#1331)

* feat(ui): make Fleet, Settings, and the dashboard table usable on mobile

Tier 2 of the mobile pass, all gated below the md breakpoint so desktop
renders identically:
- Fleet: the tab strip scrolls horizontally and the action row (Check
  Updates / Refresh / Add Node) wraps instead of clipping.
- Settings: below md the nav rail is a full-screen list; choosing a section
  pushes it full-screen with a back affordance (master/detail), matching the
  stack flow. Desktop keeps the two-pane layout.
- Dashboard: the fixed stack-health table scrolls horizontally on a phone.

Adds a Playwright desktop visual-regression spec (1280/1440/1920) as a
zero-desktop-change gate; its environment-specific snapshots are gitignored.

* test(ui): harden the desktop visual-regression gate

Mask only the live sidebar ticker and notification count instead of the whole
sidebar / top-bar shell, so the gate now proves that shell unchanged too.
Lower the pixel budget to 1200 (the only residual churn is in-content live
stats); run against seeded / frozen data in CI for a zero-tolerance gate. Adds
a data-testid to the activity ticker so it can be masked precisely.
This commit is contained in:
Anso
2026-06-07 01:14:27 -04:00
committed by GitHub
parent e8f271f5f6
commit 2072378396
7 changed files with 152 additions and 12 deletions
@@ -1,5 +1,7 @@
import { useLayoutEffect, useRef, useState, useCallback, useMemo, useEffect, lazy, Suspense } from 'react';
import { ChevronLeft } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useIsMobile } from '@/hooks/use-is-mobile';
import {
CommandDialog,
CommandEmpty,
@@ -107,6 +109,19 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
const { isPaid } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
// Mobile master/detail: below md the nav rail and the section content cannot
// sit side by side, so the rail is a full-screen list and choosing a section
// pushes it full-screen with a back affordance. Desktop shows both as before.
const isMobile = useIsMobile();
const [mobileSectionOpen, setMobileSectionOpen] = useState(false);
const handleSectionChange = useCallback((section: SectionId) => {
onSectionChange(section);
if (isMobile) setMobileSectionOpen(true);
}, [onSectionChange, isMobile]);
// Desktop shows both panes; mobile shows exactly one (the rail or the section).
const showSidebar = !isMobile || !mobileSectionOpen;
const showSection = !isMobile || mobileSectionOpen;
const visibility: VisibilityContext = useMemo(
() => ({ isRemote, isAdmin, isPaid }),
[isRemote, isAdmin, isPaid],
@@ -245,14 +260,27 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
/>
<div className="flex flex-1 min-h-0 gap-4">
<SettingsSidebar
dirtyFlags={dirtyFlags}
currentSection={safeSection}
onSectionChange={onSectionChange}
onOpenPalette={() => setCommandOpen(true)}
/>
{showSidebar && (
<SettingsSidebar
dirtyFlags={dirtyFlags}
currentSection={safeSection}
onSectionChange={handleSectionChange}
onOpenPalette={() => setCommandOpen(true)}
/>
)}
{showSection && (
<div className="flex-1 min-h-0 min-w-0 rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors overflow-hidden flex flex-col">
{isMobile && mobileSectionOpen && (
<button
type="button"
onClick={() => setMobileSectionOpen(false)}
className="md:hidden flex shrink-0 items-center gap-1 border-b border-hairline px-4 py-3 font-mono text-xs text-brand"
>
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
Settings
</button>
)}
<ScrollArea
block
viewportRef={contentViewportRef}
@@ -281,6 +309,7 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
</div>
</ScrollArea>
</div>
)}
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
@@ -296,7 +325,7 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
glyph={group.glyph}
onSelect={() => {
setCommandOpen(false);
onSectionChange(item.id);
handleSectionChange(item.id);
}}
/>
))}
@@ -37,7 +37,7 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
}
return (
<aside className="w-[240px] rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors flex flex-col shrink-0 min-h-0 overflow-hidden">
<aside className="w-[240px] max-md:w-full rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors flex flex-col shrink-0 min-h-0 overflow-hidden">
<div className="px-3 pt-5 pb-2">
<button
onClick={onOpenPalette}