mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +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:
@@ -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">
|
||||
<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}
|
||||
|
||||
@@ -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