mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -39,6 +39,8 @@ html-report/
|
||||
.coverage/
|
||||
coverage/
|
||||
.playwright-mcp/
|
||||
# Visual-regression baselines are environment-specific; regenerate per env.
|
||||
e2e/**/*-snapshots/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user