diff --git a/packages/dashboard/app/__tests__/footer-safe-layout.test.ts b/packages/dashboard/app/__tests__/footer-safe-layout.test.ts index c4a107d2cc..cf82186a97 100644 --- a/packages/dashboard/app/__tests__/footer-safe-layout.test.ts +++ b/packages/dashboard/app/__tests__/footer-safe-layout.test.ts @@ -56,11 +56,25 @@ describe("footer-safe project workspace layout", () => { // ── .project-content--with-footer (desktop) ──────────────────────── describe(".project-content--with-footer desktop", () => { - const footerBlock = loadAllAppCssBaseOnly().match( - /\.project-content--with-footer\s*\{[^}]*\}/, - )?.[0]; + /* + FNXC:ViewportChrome 2026-08-03-00:13: + Match the unscoped base rule only. Mode-scoped overrides + (`html[data-viewport-mode=…] .project-content--with-footer`) also contain the + class name and would otherwise win a first-match scan. Require both the 36px + token and padding-bottom so token-only mode overrides cannot satisfy this contract. + */ + const baseCss = loadAllAppCssBaseOnly(); + const footerBlock = [...baseCss.matchAll(/\.project-content--with-footer\s*\{[^}]*\}/g)] + .map((match) => match[0]) + .find( + (block) => + block.includes("--executor-footer-height: 36px") && + block.includes("padding-bottom: var(--executor-footer-height)") && + !block.includes("data-viewport-mode"), + ); it("defines --executor-footer-height token as 36px", () => { + expect(footerBlock).toBeTruthy(); expect(footerBlock).toContain("--executor-footer-height: 36px"); }); diff --git a/packages/dashboard/app/__tests__/viewport-chrome-mode-alignment.test.ts b/packages/dashboard/app/__tests__/viewport-chrome-mode-alignment.test.ts new file mode 100644 index 0000000000..9e21ffec25 --- /dev/null +++ b/packages/dashboard/app/__tests__/viewport-chrome-mode-alignment.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss } from "../test/cssFixture"; + +/** + * Viewport chrome alignment (tablet-class narrow widths). + * + * Surface enumeration: + * - Left sidebar visibility (LeftSidebarNav.css) + * - Mobile bottom tab bar visibility (MobileNavBar.css) + * - Executor footer bottom stacking above mobile nav (ExecutorStatusBar.css) + * - Footer height token on project-content (ProjectSelector.css + ExecutorStatusBar.css) + * - Right dock hide/show (RightDock.css) + * - data-viewport-mode publisher (useViewportMode.ts) + * + * Original symptom: mid-tablet / tablet-class ≤768 CSS px showed no left sidebar, + * no bottom tab bar, and a floating footer overlapping board content with empty + * space below (CSS elevated the footer for a mobile nav that JS never mounted). + * + * Assertion: shell chrome CSS keys display and footer stacking off + * `html[data-viewport-mode="…"]` so tablet/desktop keep left sidebar + footer at + * the true bottom even when the CSS width is still ≤768. + */ + +const css = loadAllAppCss(); + +describe("viewport chrome mode alignment", () => { + it("publishes mode-driven left sidebar show/hide that overrides the width-only hide", () => { + expect(css).toMatch( + /html:is\(\[data-viewport-mode="tablet"\],\s*\[data-viewport-mode="desktop"\]\)\s*\.left-sidebar-nav\s*\{[^}]*display:\s*flex/, + ); + expect(css).toMatch(/html\[data-viewport-mode="mobile"\]\s*\.left-sidebar-nav\s*\{[^}]*display:\s*none/); + // Width fallback remains for no-JS / first paint on true phones. + expect(css).toMatch(/@media\s*\(\s*max-width:\s*768px\s*\)\s*\{[\s\S]*?\.left-sidebar-nav\s*\{[^}]*display:\s*none/); + }); + + it("publishes mode-driven mobile nav show/hide so tablet never reserves a phantom tab bar", () => { + expect(css).toMatch(/html\[data-viewport-mode="mobile"\]\s*\.mobile-nav-bar\s*\{[^}]*display:\s*flex/); + expect(css).toMatch( + /html:is\(\[data-viewport-mode="tablet"\],\s*\[data-viewport-mode="desktop"\]\)\s*\.mobile-nav-bar\s*\{[^}]*display:\s*none/, + ); + }); + + it("pins the executor footer to the true bottom on tablet/desktop mode", () => { + expect(css).toMatch( + /html:is\(\[data-viewport-mode="tablet"\],\s*\[data-viewport-mode="desktop"\]\)\s*\.executor-status-bar\s*\{[^}]*bottom:\s*var\(--icb-bottom-offset,\s*0px\)/, + ); + expect(css).toMatch( + /html:is\(\[data-viewport-mode="tablet"\],\s*\[data-viewport-mode="desktop"\]\)\s*\.executor-status-bar\s*\{[^}]*height:\s*36px/, + ); + // Mobile mode still elevates above the tab bar. + expect(css).toMatch( + /html\[data-viewport-mode="mobile"\]\s*\.executor-status-bar\s*\{[\s\S]*?bottom:\s*calc\([^)]*var\(--mobile-nav-height\)/, + ); + }); + + it("keeps the desktop footer-height token for tablet/desktop mode after mobile overrides", () => { + // May share a declaration block with .dashboard-project-shell (comma selector). + expect(css).toMatch( + /html:is\(\[data-viewport-mode="tablet"\],\s*\[data-viewport-mode="desktop"\]\)\s*\.project-content--with-footer[\s\S]{0,180}?\{[^}]*--executor-footer-height:\s*36px/, + ); + expect(css).toMatch( + /html\[data-viewport-mode="mobile"\]\s*\.project-content--with-footer\s*\{[^}]*--executor-footer-height:\s*calc\(var\(--space-lg\)\s*\*\s*2\s*\+\s*var\(--space-xs\)\)/, + ); + }); + + it("aligns right-dock mobile hide with viewport mode", () => { + expect(css).toMatch(/html\[data-viewport-mode="mobile"\]\s*\.right-dock\s*\{[^}]*display:\s*none/); + expect(css).toMatch( + /html:is\(\[data-viewport-mode="tablet"\],\s*\[data-viewport-mode="desktop"\]\)\s*\.right-dock\s*\{[^}]*display:\s*flex/, + ); + }); +}); diff --git a/packages/dashboard/app/components/ExecutorStatusBar.css b/packages/dashboard/app/components/ExecutorStatusBar.css index 5be63cc167..d0dab310f8 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.css +++ b/packages/dashboard/app/components/ExecutorStatusBar.css @@ -417,6 +417,10 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch } /* Responsive: collapse on small screens */ +/* +FNXC:ViewportChrome 2026-08-03-00:13: +Elevating the fixed footer above `--mobile-nav-height` is only correct when the mobile tab bar is actually mounted (JS mode=mobile). Tablet-class devices in the ≤768 CSS px band stay mode=tablet with a left sidebar and no bottom tab bar; the width-only rule left a dead band under the footer and made it paint over board content. Keep width media as a no-JS fallback for true phones, then reassert desktop bottom/height when mode is tablet or desktop. +*/ @media (max-width: 768px) { /* FNXC:MobileFooter 2026-06-23-22:10: @@ -465,6 +469,45 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch } +html[data-viewport-mode="mobile"] .project-content--with-footer { + --executor-footer-height: calc(var(--space-lg) * 2 + var(--space-xs)); +} + +html[data-viewport-mode="mobile"] .executor-status-bar { + padding: var(--space-xs) var(--space-md); + gap: 4px; + font-size: 11px; + height: calc(var(--space-lg) * 2 + var(--space-xs)); + overflow: hidden; + bottom: calc(var(--icb-bottom-offset, 0px) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap)); +} + +html[data-viewport-mode="mobile"] .executor-status-bar.executor-status-bar--keyboard-open { + bottom: 0; +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .project-content--with-footer, +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .dashboard-project-shell { + --executor-footer-height: 36px; +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .executor-status-bar { + bottom: var(--icb-bottom-offset, 0px); + height: 36px; + padding: var(--space-xs) var(--space-lg); + gap: var(--space-sm); + font-size: 12px; + overflow: visible; +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .executor-status-bar__segment--time { + display: flex; +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .executor-status-bar.executor-status-bar--keyboard-open { + bottom: var(--icb-bottom-offset, 0px); +} + /* Light theme support */ [data-theme="light"] .executor-status-bar { background: var(--surface); diff --git a/packages/dashboard/app/components/LeftSidebarNav.css b/packages/dashboard/app/components/LeftSidebarNav.css index 756d8bf525..dae2fb7773 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.css +++ b/packages/dashboard/app/components/LeftSidebarNav.css @@ -290,8 +290,20 @@ The footer has no top divider; it remains a functional footer cluster but should justify-content: center; } +/* +FNXC:ViewportChrome 2026-08-03-00:13: +Width-only hide was wrong for tablet-class devices in the 601–768 CSS px band: JS mounts the left sidebar (mode=tablet) while this media query hid it and MobileNavBar returned null — operators lost every primary nav surface. Prefer the published viewport mode; keep the width rule as a no-JS/first-paint fallback for true phones. +*/ @media (max-width: 768px) { .left-sidebar-nav { display: none; } } + +html[data-viewport-mode="mobile"] .left-sidebar-nav { + display: none; +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .left-sidebar-nav { + display: flex; +} diff --git a/packages/dashboard/app/components/MobileNavBar.css b/packages/dashboard/app/components/MobileNavBar.css index 12cd2c2a3c..807e780897 100644 --- a/packages/dashboard/app/components/MobileNavBar.css +++ b/packages/dashboard/app/components/MobileNavBar.css @@ -44,6 +44,10 @@ box-shadow: var(--shadow-md); } +/* +FNXC:ViewportChrome 2026-08-03-00:13: +Show the bottom tab bar only when JS mode is mobile. Width-only `@media (max-width: 768px)` left a gap on tablet-class narrow viewports (mode=tablet mounts no MobileNavBar, CSS still reserved its height under the elevated footer). Also reveal the bar for landscape phones that stay mode=mobile above 768 CSS px. Keep the width media query as a no-JS/first-paint fallback for true phones. +*/ @media (max-width: 768px) { .mobile-nav-bar { display: flex; @@ -82,6 +86,47 @@ } } +html[data-viewport-mode="mobile"] .mobile-nav-bar { + display: flex; +} + +html[data-viewport-mode="mobile"] .mobile-nav-bar--with-footer { + bottom: var(--icb-bottom-offset, 0px); +} + +html[data-viewport-mode="mobile"] .mobile-nav-bar.mobile-nav-bar--keyboard-open, +html[data-viewport-mode="mobile"] .mobile-nav-bar.mobile-nav-bar--with-footer.mobile-nav-bar--keyboard-open { + bottom: 0; + transform: translateY(100%); + pointer-events: none; +} + +html[data-viewport-mode="mobile"] .project-content--with-mobile-nav:not(.project-content--with-footer) { + padding-bottom: calc(var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap) + var(--icb-bottom-offset, 0px)); +} + +html[data-viewport-mode="mobile"] .project-content--with-footer.project-content--with-mobile-nav { + padding-bottom: calc( + var(--executor-footer-height) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap) + var(--icb-bottom-offset, 0px) + ); +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .mobile-nav-bar { + display: none; +} + +/* +FNXC:ViewportChrome 2026-08-03-00:13: +If a stale project-content--with-mobile-nav class remains while mode is tablet/desktop, do not reserve phantom tab-bar stack space — only the desktop footer token applies. +*/ +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .project-content--with-mobile-nav:not(.project-content--with-footer) { + padding-bottom: 0; +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .project-content--with-footer.project-content--with-mobile-nav { + padding-bottom: var(--executor-footer-height); +} + /* Individual tab button */ /* FNXC:MobileNav 2026-06-20-02:04: diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index 17d79b7467..f8e9343cb3 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -698,3 +698,24 @@ display: none; } } + +/* +FNXC:ViewportChrome 2026-08-03-00:13: +ProjectSelector.css loads after ExecutorStatusBar.css, so reassert the desktop footer token for tablet/desktop mode after the mobile width override above — otherwise mid-tablet still reserves the taller mobile footer while the bar sits at bottom:0. Also un-hide the project selector for tablet-class ≤768 CSS px (width media hid it with true phones). +*/ +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .project-content--with-footer, +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .dashboard-project-shell { + --executor-footer-height: 36px; +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .project-selector { + display: inline-flex; +} + +html[data-viewport-mode="mobile"] .project-content--with-footer { + --executor-footer-height: calc(var(--space-lg) * 2 + var(--space-xs)); +} + +html[data-viewport-mode="mobile"] .project-selector { + display: none; +} diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css index e9073aecc8..285bf29833 100644 --- a/packages/dashboard/app/components/RightDock.css +++ b/packages/dashboard/app/components/RightDock.css @@ -258,8 +258,20 @@ wide child cannot collapse the flex line. min-block-size: 0; } +/* +FNXC:ViewportChrome 2026-08-03-00:13: +Right dock hide follows JS viewport mode (authoritative mobile gate) rather than width alone, matching LeftSidebarNav/MobileNavBar so tablet-class narrow viewports keep the dock when mode=tablet. Width media remains a no-JS/first-paint fallback for true phones. +*/ @media (max-width: 768px) { .right-dock { display: none; } } + +html[data-viewport-mode="mobile"] .right-dock { + display: none; +} + +html:is([data-viewport-mode="tablet"], [data-viewport-mode="desktop"]) .right-dock { + display: flex; +} diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts index 395705116b..ba3dc5be50 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts @@ -1,6 +1,15 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, isTabletTouchViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode"; +import { + getViewportMode, + isFullScreenSheetViewport, + isMobileViewport, + isTabletTouchViewport, + MOBILE_MEDIA_QUERY, + publishViewportMode, + useViewportMode, + VIEWPORT_MODE_DATASET_KEY, +} from "../useViewportMode"; const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; @@ -110,11 +119,29 @@ function createViewportMediaMock(initial: { mobile: boolean; tablet: boolean }) describe("useViewportMode", () => { afterEach(() => { vi.unstubAllGlobals(); + delete document.documentElement.dataset[VIEWPORT_MODE_DATASET_KEY]; if (originalScreenDescriptor) { Object.defineProperty(window, "screen", originalScreenDescriptor); } }); + it("publishViewportMode mirrors mode onto documentElement dataset", () => { + publishViewportMode("tablet"); + expect(document.documentElement.dataset[VIEWPORT_MODE_DATASET_KEY]).toBe("tablet"); + publishViewportMode("mobile"); + expect(document.documentElement.dataset[VIEWPORT_MODE_DATASET_KEY]).toBe("mobile"); + }); + + it("useViewportMode publishes data-viewport-mode for CSS chrome alignment", () => { + stubScreen(1024, 768); + installViewportMedia({ width: false, height: false, tablet: true }); + + const { result, unmount } = renderHook(() => useViewportMode()); + expect(result.current).toBe("tablet"); + expect(document.documentElement.dataset[VIEWPORT_MODE_DATASET_KEY]).toBe("tablet"); + unmount(); + }); + it("treats short landscape phones as mobile", () => { stubScreen(844, 390); vi.stubGlobal( diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts index de8a4678b5..1c83065102 100644 --- a/packages/dashboard/app/hooks/useViewportMode.ts +++ b/packages/dashboard/app/hooks/useViewportMode.ts @@ -1,7 +1,19 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useLayoutEffect } from "react"; export type ViewportMode = "mobile" | "tablet" | "desktop"; +/* +FNXC:ViewportChrome 2026-08-03-00:13: +Shell chrome (left sidebar, mobile tab bar, footer stacking above the tab bar) must follow the JS viewport mode, not pure CSS width. Tablet-class devices at ≤768 CSS px are mode=tablet (sidebar on, no bottom tab bar) while `@media (max-width: 768px)` still hid the sidebar and elevated the footer for a phantom mobile nav — missing tab bar + mid-page overlapping footer. Publish the mode on `document.documentElement` so co-located CSS can override width-only rules and keep left sidebar + footer-at-bottom on tablet. +*/ +export const VIEWPORT_MODE_DATASET_KEY = "viewportMode"; + +/** Mirrors the resolved viewport mode onto `` for CSS chrome alignment. */ +export function publishViewportMode(mode: ViewportMode): void { + if (typeof document === "undefined") return; + document.documentElement.dataset[VIEWPORT_MODE_DATASET_KEY] = mode; +} + // `(max-height: 480px)` catches phones held in landscape, which can exceed // 768 CSS px wide but stay short. Without it, landscape phones fall out of // mobile mode and lose the bottom nav bar + get the desktop horizontally- @@ -153,7 +165,23 @@ export function isTabletTouchViewport(mode = getViewportMode()): boolean { } export function useViewportMode(): ViewportMode { - const [mode, setMode] = useState(getViewportMode); + /* + FNXC:ViewportChrome 2026-08-03-00:17: + Lazy initial state still runs publishViewportMode during the first render so the first paint already has data-viewport-mode — useLayoutEffect alone still leaves a pre-hydration/SSR-empty frame where width-only CSS hides the tablet sidebar. + */ + const [mode, setMode] = useState(() => { + const initial = getViewportMode(); + publishViewportMode(initial); + return initial; + }); + + /* + FNXC:ViewportChrome 2026-08-03-00:13: + Re-publish on every mode change before paint so resize/orientation updates do not flash phone chrome (hidden sidebar + elevated footer) on a tablet-class narrow viewport. + */ + useLayoutEffect(() => { + publishViewportMode(mode); + }, [mode]); useEffect(() => { if (typeof window === "undefined") return;