From 20723783961ae4605814fa6f4b5da84f68c3556c Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 7 Jun 2026 01:14:27 -0400 Subject: [PATCH] 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. --- .gitignore | 2 + e2e/desktop-visual-regression.spec.ts | 105 ++++++++++++++++++ frontend/src/components/FleetView.tsx | 4 +- .../components/dashboard/StackHealthTable.tsx | 7 +- .../src/components/settings/SettingsPage.tsx | 43 +++++-- .../components/settings/SettingsSidebar.tsx | 2 +- .../sidebar/SidebarActivityTicker.tsx | 1 + 7 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 e2e/desktop-visual-regression.spec.ts diff --git a/.gitignore b/.gitignore index 167c8ff4..eb5e019b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ html-report/ .coverage/ coverage/ .playwright-mcp/ +# Visual-regression baselines are environment-specific; regenerate per env. +e2e/**/*-snapshots/ # Logs *.log diff --git a/e2e/desktop-visual-regression.spec.ts b/e2e/desktop-visual-regression.spec.ts new file mode 100644 index 00000000..6bad62e2 --- /dev/null +++ b/e2e/desktop-visual-regression.spec.ts @@ -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; +} + +// 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, + }); + }); + } + }); +} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index d7525837..f8313d1c 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -73,7 +73,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
- + Overview @@ -131,7 +131,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} -
+
+ )}
+ )}
@@ -296,7 +325,7 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp glyph={group.glyph} onSelect={() => { setCommandOpen(false); - onSectionChange(item.id); + handleSectionChange(item.id); }} /> ))} diff --git a/frontend/src/components/settings/SettingsSidebar.tsx b/frontend/src/components/settings/SettingsSidebar.tsx index 2bf94c96..8284ee65 100644 --- a/frontend/src/components/settings/SettingsSidebar.tsx +++ b/frontend/src/components/settings/SettingsSidebar.tsx @@ -37,7 +37,7 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o } return ( -