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
+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,
});
});
}
});
}