fix(dashboard): align mid-tablet shell chrome with JS viewport mode

Tablet-class devices at ≤768 CSS px were mode=tablet (left sidebar on, no
MobileNavBar) while pure max-width:768 rules hid the sidebar and elevated the
footer for a phantom tab bar. Publish data-viewport-mode and key shell CSS off
it so tablet keeps the left sidebar and a true bottom footer.
This commit is contained in:
gsxdsm
2026-08-02 17:27:31 -07:00
parent 1e7f510ee2
commit f21c07929a
9 changed files with 280 additions and 6 deletions

View File

@@ -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");
});

View File

@@ -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/,
);
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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 `<html data-viewport-mode="…">` 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<ViewportMode>(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<ViewportMode>(() => {
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;