diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 0388d61f1c..b89ee7defc 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -3755,14 +3755,20 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy FNXC:GitManager 2026-06-21-11:27: The mobile global `* { touch-action: pan-y; }` lock from FN-6365 blocks horizontal swipes unless the actual overflowing scroller opts back into pan-x. The Git Manager section toolbar must keep all fixed section tabs reachable on touch viewports, so mirror the FN-6450 tab-strip treatment and prevent tab compression (FN-6857). + + FNXC:GitManager 2026-06-21-18:00: + FN-6900 requires the Git Manager section tabs to remain visible and switchable on touch viewports. In the mobile column layout, the content pane is the flexible sibling, so the tab strip must be `flex: 0 0 auto` with a token-sized minimum height instead of shrinking to a zero-height row. */ .gm-sidebar { + flex: 0 0 auto; flex-direction: row; width: 100%; - min-width: unset; + min-width: 0; + min-height: calc(var(--space-2xl) + var(--space-md)); border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; + overflow-y: hidden; touch-action: pan-x pan-y; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; @@ -3777,8 +3783,9 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy padding: var(--space-xs) var(--space-sm); border-left: none; border-bottom: 2px solid transparent; - font-size: 10px; - min-width: 56px; + font-size: var(--font-size-xs); + min-width: calc(var(--space-2xl) + var(--space-xl)); + min-height: calc(var(--space-xl) + var(--space-sm)); text-align: center; justify-content: center; } @@ -4206,49 +4213,11 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy } @media (max-width: 768px) { - /* Git manager: align breakpoint behavior with global 768px mobile modal rules */ - .gm-layout { - flex-direction: column; - } - - .gm-sidebar { - flex-direction: row; - width: 100%; - min-width: unset; - border-right: none; - border-bottom: 1px solid var(--border); - overflow-x: auto; - touch-action: pan-x pan-y; - -webkit-overflow-scrolling: touch; - overscroll-behavior-x: contain; - padding: var(--space-xs) var(--space-sm); - gap: var(--space-xs); - } - - .gm-nav-item { - flex: 0 0 auto; - border-left: none; - border-bottom: 2px solid transparent; - text-align: center; - justify-content: center; - min-height: 36px; - } - - .gm-nav-item.active { - border-left-color: transparent; - border-bottom-color: var(--todo); - } - + /* Git manager: extend the canonical mobile rules above with safe-area and panel scroll behavior only. */ .gm-content { - min-height: 200px; - padding: var(--space-md); padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px)); } - .gm-status-grid { - grid-template-columns: 1fr; - } - .gm-panel { flex: none; min-height: auto; diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index d558a59305..abddeb8eb9 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -120,6 +120,41 @@ function expectLatestCallStartsWith(mockFn: { mock: { calls: unknown[][] } }, .. expect(mockFn.mock.calls.at(-1)?.slice(0, expectedArgs.length)).toEqual(expectedArgs); } +function getMediaBlocks(css: string, pattern: RegExp): string[] { + const matches = [...css.matchAll(pattern)]; + expect(matches.length).toBeGreaterThan(0); + + return matches.map((match) => { + const start = match.index!; + const open = css.indexOf("{", start); + let depth = 1; + let i = open + 1; + while (i < css.length && depth > 0) { + if (css[i] === "{") depth++; + else if (css[i] === "}") depth--; + i++; + } + return css.slice(start, i); + }); +} + +function getRuleBlocks(css: string, selector: string): string[] { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, "g"))] + .map((match) => match[1]); +} + +const gitManagerSectionLabels = [ + "Status", + "Changes", + "Commits", + "Branches", + "Worktrees", + "Stashes", + "Recovery", + "Remotes", +]; + const mockAddToast = vi.fn(); const mockTasks: Task[] = [ @@ -305,14 +340,59 @@ describe("GitManagerModal", () => { ); await waitFor(() => { - expect(screen.getByRole("tab", { name: /status/i })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: /changes/i })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: /commits/i })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: /branches/i })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: /worktrees/i })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: /stashes/i })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: /recovery/i })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: /remotes/i })).toBeInTheDocument(); + for (const label of gitManagerSectionLabels) { + expect(screen.getByRole("tab", { name: label })).toBeInTheDocument(); + } + }); + }); + + it.each([ + ["null status and no file changes", null, []], + ["populated status and populated file changes", { + branch: "main", + commit: "abc1234", + isDirty: true, + ahead: 1, + behind: 0, + }, [ + { file: "src/app.ts", status: "modified", staged: false }, + { file: "src/index.ts", status: "added", staged: true }, + ]], + ])("renders the mobile tablist and all static section tabs with %s", async (_name, statusResult, fileChangeResult) => { + mockUseViewportMode.mockReturnValue("mobile"); + (fetchGitStatus as any).mockResolvedValue(statusResult); + (fetchFileChanges as any).mockResolvedValue(fileChangeResult); + + render( + + ); + + const tablist = await screen.findByRole("tablist", { name: /git manager sections/i }); + const tabs = within(tablist).getAllByRole("tab"); + expect(tabs).toHaveLength(gitManagerSectionLabels.length); + for (const label of gitManagerSectionLabels) { + expect(within(tablist).getByRole("tab", { name: label })).toBeInTheDocument(); + } + }); + + it("switches sections from the tab strip on mobile", async () => { + mockUseViewportMode.mockReturnValue("mobile"); + + render( + + ); + + const tablist = await screen.findByRole("tablist", { name: /git manager sections/i }); + const statusTab = within(tablist).getByRole("tab", { name: "Status" }); + const branchesTab = within(tablist).getByRole("tab", { name: "Branches" }); + expect(statusTab).toHaveAttribute("aria-selected", "true"); + + await userEvent.click(branchesTab); + + await waitFor(() => { + expect(branchesTab).toHaveAttribute("aria-selected", "true"); + expect(statusTab).toHaveAttribute("aria-selected", "false"); + expect(screen.getByTestId("branches-panel")).toBeInTheDocument(); }); }); @@ -3321,5 +3401,27 @@ describe("GitManagerModal", () => { expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-file-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?flex-wrap:\s*wrap;/); expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-file-section\s*\{[\s\S]*?max-width:\s*100%;/); }); + + it("keeps the mobile Git Manager tab strip non-shrinking at 768px and 720px breakpoints", () => { + const css = loadAllAppCss(); + const mobile768 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*768px\)[^{]*\{/g).join("\n"); + const mobile720 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*720px\)[^{]*\{/g).join("\n"); + + const sidebarRules = getRuleBlocks(mobile768, ".gm-sidebar"); + expect(sidebarRules).toHaveLength(1); + expect(sidebarRules[0]).toContain("flex: 0 0 auto;"); + expect(sidebarRules[0]).toContain("min-height: calc(var(--space-2xl) + var(--space-md));"); + expect(sidebarRules[0]).toContain("overflow-x: auto;"); + expect(sidebarRules[0]).toContain("overflow-y: hidden;"); + expect(sidebarRules[0]).toContain("touch-action: pan-x pan-y;"); + + const navItemRules = getRuleBlocks(mobile768, ".gm-nav-item"); + expect(navItemRules).toHaveLength(1); + expect(navItemRules[0]).toContain("flex: 0 0 auto;"); + expect(navItemRules[0]).toContain("min-height: calc(var(--space-xl) + var(--space-sm));"); + + expect(mobile720).not.toContain(".gm-sidebar"); + expect(mobile720).not.toContain(".gm-nav-item"); + }); }); }); diff --git a/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx b/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx index 1480d2366b..ca06007942 100644 --- a/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx @@ -243,7 +243,10 @@ describe("core modals mobile css coverage", () => { const sidebarRules = getRuleBlocks(mobileBlock, ".gm-sidebar"); expect(sidebarRules.length).toBeGreaterThan(0); for (const sidebarRule of sidebarRules) { + expect(sidebarRule).toContain("flex: 0 0 auto;"); + expect(sidebarRule).toContain("min-height: calc(var(--space-2xl) + var(--space-md));"); expect(sidebarRule).toContain("overflow-x: auto;"); + expect(sidebarRule).toContain("overflow-y: hidden;"); expect(sidebarRule).toContain("touch-action: pan-x pan-y;"); expect(sidebarRule).toContain("-webkit-overflow-scrolling: touch;"); } @@ -255,12 +258,12 @@ describe("core modals mobile css coverage", () => { } }); - it("GitManagerModal: nav items keep 36px touch target on mobile", () => { + it("GitManagerModal: nav items keep a token-sized touch target on mobile", () => { const css = loadAllAppCss(); const mobileBlock = getMainMobileBlock(css); expect(mobileBlock).toContain(".gm-nav-item {"); - expect(mobileBlock).toContain("min-height: 36px;"); + expect(mobileBlock).toContain("min-height: calc(var(--space-xl) + var(--space-sm));"); }); it("GitManagerModal: panel allows content scrolling on mobile", () => {