feat(ui): make the core stack flow usable on mobile (#1327)

* feat(ui): make the core stack flow usable on mobile

Below the md breakpoint the app collapses to a single full-width column:
the stack list is full-screen, tapping a stack opens a full-screen detail
with a Health / Logs / Compose segmented control (Logs first) and a back
button, and a bottom tab bar switches Stacks, Fleet, Schedules, and
Settings. Compose is read-only on a phone with a prompt to edit on desktop.

Desktop (md and up) is unchanged: the mobile shell is gated behind a
useIsMobile hook plus max-md/md variants, and the stack-detail blocks are
shared with the desktop two-pane view so it renders identically.

Also generalizes the unsaved-changes guard so leaving a dirty editor (back,
tab bar, hamburger) prompts before discarding; adds 44px touch targets on
list rows, filter chips, and actions; makes log and shell modals full-screen
on mobile; and offsets toasts and the deploy pill above the bottom tab bar.

* fix(ui): keep mobile nav in sync when opening views from outside the bottom bar

On a phone the sidebar activity actions, the node switcher's Manage Nodes, the
profile Settings entry, and the dashboard configuration links set the active
view without flipping the mobile surface to content, so the user stayed on the
stack list and never saw the destination. Route these through the mobile-aware
navigation and settings helpers (a no-op on desktop).
This commit is contained in:
Anso
2026-06-07 01:03:13 -04:00
committed by GitHub
parent 57fe430db8
commit e8f271f5f6
26 changed files with 1696 additions and 659 deletions
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useIsMobile } from './use-is-mobile';
function installMatchMedia(initialMatches: boolean) {
let listener: ((e: MediaQueryListEvent) => void) | null = null;
const mql = {
matches: initialMatches,
media: '',
onchange: null,
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => { listener = cb; },
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
};
window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia;
return {
emit(matches: boolean) {
mql.matches = matches;
listener?.({ matches } as MediaQueryListEvent);
},
};
}
describe('useIsMobile', () => {
const original = window.matchMedia;
afterEach(() => { window.matchMedia = original; });
it('returns false at desktop widths', () => {
installMatchMedia(false);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it('returns true below the breakpoint', () => {
installMatchMedia(true);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it('updates when the media query crosses the breakpoint', () => {
const mm = installMatchMedia(false);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
act(() => mm.emit(true));
expect(result.current).toBe(true);
act(() => mm.emit(false));
expect(result.current).toBe(false);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
// Matches Tailwind's `max-md` variant exactly (md starts at 768px), so a JS
// branch keyed on this hook and a `max-md:` class always agree on which side
// of the breakpoint we are. Below this width Sencho renders its mobile shell;
// at or above it the desktop sidebar + workspace layout is untouched.
const MOBILE_QUERY = '(max-width: 767.98px)';
/**
* True when the viewport is narrower than the `md` breakpoint.
*
* This is a single-instance SPA (no SSR), so the initial state reads
* `matchMedia` synchronously to avoid a desktop→mobile flash on first paint.
* The `window`/`matchMedia` guards keep it safe under jsdom and any non-DOM
* render path.
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(MOBILE_QUERY).matches;
});
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mq = window.matchMedia(MOBILE_QUERY);
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
// Sync once in case the width changed between the initial render and effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMobile(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
return isMobile;
}