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
+2
View File
@@ -39,6 +39,8 @@ html-report/
.coverage/
coverage/
.playwright-mcp/
# Visual-regression baselines are environment-specific; regenerate per env.
e2e/**/*-snapshots/
# Logs
*.log
+105
View File
@@ -0,0 +1,105 @@
// ---------------------------------------------------------------------------
// Zero-desktop-change gate for the mobile work.
//
// Snapshots every top-level view the mobile pass touches, at desktop widths.
// Capture the baseline on the pre-change state, then run again after the mobile
// changes: any pixel diff means a desktop base class was edited instead of a
// mobile-only `max-md:` override being added.
//
// # baseline (before the mobile changes):
// E2E_PASSWORD=admin123 npx playwright test desktop-visual-regression --update-snapshots
// # gate (after the changes):
// E2E_PASSWORD=admin123 npx playwright test desktop-visual-regression
//
// Snapshots are platform-specific and gitignored; regenerate the baseline in
// the same environment (or a pinned CI/Docker image) you run the gate in.
// ---------------------------------------------------------------------------
import { test, expect, type Page } from '@playwright/test';
import { loginAs, waitForStacksLoaded } from './helpers';
const WIDTHS = [1280, 1440, 1920] as const;
const HEIGHT = 1080;
interface View {
id: string;
label: string;
open: (page: Page) => Promise<void>;
}
// Navigate via the app's real chrome (no router): top-bar buttons by aria-label,
// Settings via the profile menu, stack detail via a sidebar row.
const VIEWS: View[] = [
{ id: 'home', label: 'home', open: async () => { /* dashboard is the landing view */ } },
{ id: 'fleet', label: 'fleet', open: (p) => p.getByRole('button', { name: 'Fleet', exact: true }).click() },
// Resources is intentionally omitted: its reclaim hero / table height depends
// on async Docker data that shifts run-to-run, so it is not deterministically
// snapshot-able in this live environment. Verify its desktop manually, or run
// this gate against a seeded/frozen-data instance in CI to include it.
{ id: 'app-store', label: 'app-store', open: (p) => p.getByRole('button', { name: 'App Store', exact: true }).click() },
{
id: 'settings', label: 'settings', open: async (p) => {
await p.getByRole('button', { name: /profile/i }).click();
await p.getByRole('button', { name: 'Settings', exact: true }).click();
},
},
{
id: 'stack-detail', label: 'stack-detail', open: async (p) => {
await p.locator('[data-stacks-loaded="true"] [data-testid="stack-row"]').first().click();
await p.getByText('image', { exact: false }).first().waitFor({ timeout: 10_000 }).catch(() => {});
},
},
];
// Paint over genuinely non-deterministic content so it does not cause false
// diffs. Mask only the elements that actually churn between runs, NOT the
// surrounding shell: the rest of the sidebar and the top bar stay unmasked so
// the gate proves they are unchanged. Masked elements keep their box, so a
// layout shift around them still moves unmasked pixels and is caught.
function maskDynamic(page: Page) {
return [
// The sidebar activity ticker ("x ago" text + live dot) is the only
// churning part of the otherwise-static sidebar.
page.locator('[data-testid="activity-ticker"]'),
// The top-bar notification count changes as alerts arrive.
page.locator('[aria-label^="Notifications"]'),
page.locator('svg'), // sparklines / gauges / charts
page.locator('.xterm'), // live terminal (stack detail)
page.locator('[data-testid="deploy-feedback-pill"]'),
];
}
for (const width of WIDTHS) {
test.describe(`desktop @ ${width}px must not change`, () => {
test.use({ viewport: { width, height: HEIGHT } });
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await loginAs(page);
await waitForStacksLoaded(page);
});
for (const view of VIEWS) {
test(view.label, async ({ page }) => {
await view.open(page);
// Let the fade-up entrance and async data (Docker images, fleet
// stats) settle before snapshotting so layout height is stable.
await page.waitForTimeout(1200);
// Viewport-only (not fullPage): a fixed-height frame removes
// scroll-height variance from below-fold live data, while the
// above-fold layout is where a base-class regression shows first.
await expect(page).toHaveScreenshot(`${view.label}-${width}.png`, {
animations: 'disabled',
mask: maskDynamic(page),
// The only remaining churn is the in-content live stats that
// can't be masked without hiding layout (dashboard gauge
// values, fleet node CPU/MEM, per-container stats). This small
// budget absorbs that text churn yet stays orders of magnitude
// below any real desktop layout shift, so a base-class
// regression still fails loudly. Run against a seeded /
// frozen-data instance in CI to drop this to 0.
maxDiffPixels: 1200,
});
});
}
});
}
+2 -2
View File
@@ -73,7 +73,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<Tabs defaultValue="overview">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<TabsList>
<TabsList className="max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="overview">
<TabsTrigger value="overview">Overview</TabsTrigger>
@@ -131,7 +131,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
)}
</TabsHighlight>
</TabsList>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 max-md:w-full max-md:flex-wrap">
<Button
variant="outline"
size="sm"
@@ -15,7 +15,10 @@ interface StackHealthTableProps {
const PAGE_SIZE = 8;
const WARN = 80;
const CRIT = 90;
const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_minmax(0,120px)_52px_52px_72px_110px_16px]';
// Shared by the header and data rows so their columns stay aligned. The
// `max-md:min-w` keeps both at the same width below md, where the card scrolls
// horizontally; desktop is unaffected by the `max-md:` prefix.
const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_minmax(0,120px)_52px_52px_72px_110px_16px] max-md:min-w-[600px]';
const formatMemory = (mb: number): string => {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
@@ -148,7 +151,7 @@ export function StackHealthTable({
}
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel max-md:overflow-x-auto">
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-baseline gap-3">
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">
@@ -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">
{showSidebar && (
<SettingsSidebar
dirtyFlags={dirtyFlags}
currentSection={safeSection}
onSectionChange={onSectionChange}
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}
@@ -153,6 +153,7 @@ export function SidebarActivityTicker({ summary, onAction }: SidebarActivityTick
onClick={() => onAction(config.action)}
disabled={!isClickable}
data-state={summary.kind}
data-testid="activity-ticker"
className={cn(
'w-full flex flex-col gap-0.5 px-4 py-2 border-t border-glass-border text-left',
'bg-sidebar/80',