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:
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user