FN-8594: improve project overview mobile reflow
Make the project overview usable at small viewport widths while hiding its non-functional mobile task navigation. - Reflow overview filters, stats, cards, and skeletons for 480px and 380px breakpoints. - Hide the mobile navigation bar and clear its reserved height outside an active project task view. - Add regression coverage and document responsive dashboard behavior. Files changed: docs/dashboard-guide.md | 5 +- packages/dashboard/app/App.tsx | 8 +- .../mobile-feature-access-regression.test.tsx | 22 ++++- .../project-overview-small-screen.test.ts | 91 ++++++++++++++++++++ packages/dashboard/app/components/MobileNavBar.tsx | 19 ++++- packages/dashboard/app/components/ProjectCard.css | 58 +++++++++++++ .../dashboard/app/components/ProjectOverview.css | 99 ++++++++++++++++++++++ 7 files changed, 294 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-8594 Fusion-Task-Lineage: 0999f6e4-ffe9-4ed6-a471-8e5ba3413029 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -141,7 +141,10 @@ While the sidebar is active on desktop/tablet project screens, Board and List wo
|
||||
|
||||
The active nav-item highlight and the resize-handle hover/focus accent track the active color theme's `--accent` token across all themes, so shadcn, forest, ocean, and other themes no longer show a fixed blue selected state. The Header retains the Fusion brand and project selector, keeps non-navigation controls, and hides duplicate desktop view-toggle entries while the sidebar is active.
|
||||
|
||||
On mobile viewports (`<=768px`), the sidebar is not rendered even when the default-on setting is enabled. The existing bottom `MobileNavBar` remains the navigation surface, with mobile-only More-sheet entries for compact tools such as Git Manager, Terminal, Files, and **Import from GitHub**.
|
||||
On mobile viewports (`<=768px`), the sidebar is not rendered even when the default-on setting is enabled. The existing bottom `MobileNavBar` remains the navigation surface on project task screens, with mobile-only More-sheet entries for compact tools such as Git Manager, Terminal, Files, and **Import from GitHub**.
|
||||
|
||||
<!-- FNXC:DashboardResponsiveDocs 2026-07-25-22:57: Project overview has no task destinations, so the mobile navigation and its published height must be absent rather than leaving dead bottom space. Document the 320px responsive and token conventions alongside the operator-visible behavior. -->
|
||||
On the **All Projects** overview, Fusion suppresses the mobile bottom nav because task tabs are not active there. It also clears `--mobile-nav-height`, so the overview does not reserve bottom-bar space. The overview reflows through 320px: headers, filters, stats, and card actions stack or wrap, while long project names and paths truncate rather than overflowing. Dashboard component styles use literal pixel values for media-query breakpoints (including `480px` and `380px`); declaration values within those blocks stay token-based.
|
||||
|
||||
## Right Dock (experimental, default on)
|
||||
|
||||
|
||||
@@ -877,6 +877,7 @@ function AppInner() {
|
||||
/* FNXC:Navigation 2026-06-22-18:00: The right dock panel is no longer experimental or user-toggleable; tablet/desktop project screens always support it regardless of any stale persisted `rightDock` setting. */
|
||||
const rightDockEnabled = true;
|
||||
const executorFooterVisible = viewMode === "project" && !!currentProject;
|
||||
const mobileNavVisible = viewMode === "project" && !!currentProject;
|
||||
const rightDockActive = rightDockEnabled && !isMobile && executorFooterVisible;
|
||||
const sidebarActive = leftSidebarNavEnabled && !isMobile && executorFooterVisible;
|
||||
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
|
||||
@@ -1890,7 +1891,7 @@ function AppInner() {
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`project-content${executorFooterVisible && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
|
||||
className={`project-content${executorFooterVisible && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && mobileNavVisible && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
|
||||
>
|
||||
<MainContent {...mainContentProps} />
|
||||
</div>
|
||||
@@ -1929,8 +1930,9 @@ function AppInner() {
|
||||
)}
|
||||
<MobileNavBar
|
||||
view={taskView}
|
||||
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : () => {}}
|
||||
footerVisible={viewMode === "project" && !!currentProject}
|
||||
onChangeView={mobileNavVisible ? handleTaskViewChange : () => {}}
|
||||
footerVisible={mobileNavVisible}
|
||||
hidden={!mobileNavVisible}
|
||||
modalOpen={modalManager.anyModalOpen}
|
||||
keyboardOpen={mobileNavKeyboardOpen}
|
||||
mobileNavPrimaryItems={mobileNavPrimaryItems}
|
||||
|
||||
@@ -130,6 +130,7 @@ const createProjects = () => [
|
||||
describe("Mobile Feature Access Regression Guard", () => {
|
||||
beforeEach(() => {
|
||||
mockViewport("mobile");
|
||||
document.documentElement.style.removeProperty("--mobile-nav-height");
|
||||
});
|
||||
|
||||
it("list view is accessible via mobile nav bar", () => {
|
||||
@@ -261,11 +262,15 @@ describe("Mobile Feature Access Regression Guard", () => {
|
||||
expect(screen.queryByTestId("mobile-more-item-chat")).toBeNull();
|
||||
});
|
||||
|
||||
it("mobile nav bar renders only on mobile viewport and hides for modal or desktop", () => {
|
||||
const mobileRender = render(<MobileNavBar {...createDefaultMobileNavProps()} />);
|
||||
it("mobile nav bar renders only on mobile viewport and hides for modal, desktop, or project overview", () => {
|
||||
const mobileRender = render(<MobileNavBar {...createDefaultMobileNavProps()} hidden={false} />);
|
||||
expect(mobileRender.container.querySelector(".mobile-nav-bar")).not.toBeNull();
|
||||
mobileRender.unmount();
|
||||
|
||||
const hiddenRender = render(<MobileNavBar {...createDefaultMobileNavProps()} hidden />);
|
||||
expect(hiddenRender.container.querySelector(".mobile-nav-bar")).toBeNull();
|
||||
hiddenRender.unmount();
|
||||
|
||||
mockViewport("desktop");
|
||||
const desktopRender = render(<MobileNavBar {...createDefaultMobileNavProps()} />);
|
||||
expect(desktopRender.container.querySelector(".mobile-nav-bar")).toBeNull();
|
||||
@@ -276,6 +281,19 @@ describe("Mobile Feature Access Regression Guard", () => {
|
||||
expect(modalRender.container.querySelector(".mobile-nav-bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the published mobile-nav height when the overview hides the bar", () => {
|
||||
const hiddenRender = render(<MobileNavBar {...createDefaultMobileNavProps()} hidden />);
|
||||
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("");
|
||||
hiddenRender.unmount();
|
||||
|
||||
const { rerender, unmount } = render(<MobileNavBar {...createDefaultMobileNavProps()} hidden={false} />);
|
||||
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toMatch(/^[1-9]\d*px$/);
|
||||
|
||||
rerender(<MobileNavBar {...createDefaultMobileNavProps()} hidden />);
|
||||
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("desktop and tablet header view navigation is suppressed when left sidebar is active", () => {
|
||||
for (const tier of ["desktop", "tablet"] as const) {
|
||||
mockViewport(tier);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const overviewCss = readFileSync(resolve(__dirname, "../components/ProjectOverview.css"), "utf8");
|
||||
const cardCss = readFileSync(resolve(__dirname, "../components/ProjectCard.css"), "utf8");
|
||||
const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf8");
|
||||
|
||||
function extractMediaBlock(css: string, breakpoint: number): string {
|
||||
const match = new RegExp(`@media\\s*\\(max-width:\\s*${breakpoint}px\\)\\s*\\{`).exec(css);
|
||||
expect(match, `missing max-width: ${breakpoint}px media block`).not.toBeNull();
|
||||
|
||||
const start = (match?.index ?? 0) + (match?.[0].length ?? 0);
|
||||
let depth = 1;
|
||||
let end = start;
|
||||
while (depth > 0 && end < css.length) {
|
||||
if (css[end] === "{") depth += 1;
|
||||
if (css[end] === "}") depth -= 1;
|
||||
end += 1;
|
||||
}
|
||||
|
||||
expect(depth, `unterminated max-width: ${breakpoint}px media block`).toBe(0);
|
||||
return css.slice(start, end - 1);
|
||||
}
|
||||
|
||||
function extractRule(css: string, selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return css.match(new RegExp(`${escaped}\\s*\\{([\\s\\S]*?)\\}`))?.[1] ?? "";
|
||||
}
|
||||
|
||||
describe("project overview small-screen reflow", () => {
|
||||
it("keeps literal dashboard breakpoints and a single-column grid through 320px", () => {
|
||||
for (const css of [overviewCss, cardCss]) {
|
||||
expect(css).toContain("@media (max-width: 480px)");
|
||||
expect(css).toContain("@media (max-width: 380px)");
|
||||
}
|
||||
|
||||
const overview480 = extractMediaBlock(overviewCss, 480);
|
||||
const overview380 = extractMediaBlock(overviewCss, 380);
|
||||
expect(extractRule(overview480, ".project-grid")).toContain("grid-template-columns: 1fr");
|
||||
expect(overview480).toContain(".project-overview__header");
|
||||
expect(overview480).toContain(".project-overview__filters");
|
||||
expect(overview480).toContain(".project-filter-tabs");
|
||||
expect(overview380).toContain(".project-overview__stats-row");
|
||||
});
|
||||
|
||||
it("removes desktop card minimums and preserves truncation for populated long-name data", () => {
|
||||
const card480 = extractMediaBlock(cardCss, 480);
|
||||
const card380 = extractMediaBlock(cardCss, 380);
|
||||
|
||||
expect(extractRule(card480, ".project-card,\n .project-card-header,\n .project-card-title-section,\n .project-card-availability__row,\n .project-card-skeleton__header,\n .project-skeleton__text-group")).toContain("min-width: 0");
|
||||
expect(card480).toMatch(/\.project-card-name,[\s\S]*?\.project-card-availability__path\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis;/);
|
||||
expect(card380).toContain(".project-card-footer");
|
||||
expect(card380).toContain(".project-card-actions");
|
||||
expect(card480).not.toMatch(/min-width:\s*(?:2[89]\d|[3-9]\d\d)px/);
|
||||
});
|
||||
|
||||
it("keeps empty, filtered-empty, skeleton, health-empty, and availability surfaces shrinkable", () => {
|
||||
const overview480 = extractMediaBlock(overviewCss, 480);
|
||||
const card480 = extractMediaBlock(cardCss, 480);
|
||||
|
||||
expect(overview480).toContain(".project-empty-state");
|
||||
expect(overview480).toContain(".project-overview__no-results");
|
||||
expect(overview480).toContain(".project-card--skeleton");
|
||||
expect(overview480).toContain(".project-overview__stat-skeleton");
|
||||
expect(card480).toContain(".project-card-metric-empty");
|
||||
expect(card480).toContain(".project-card-availability__path");
|
||||
});
|
||||
|
||||
it("uses tokens in new declaration blocks while allowing literal media conditions", () => {
|
||||
for (const css of [overviewCss, cardCss]) {
|
||||
for (const breakpoint of [480, 380]) {
|
||||
const block = extractMediaBlock(css, breakpoint);
|
||||
expect(block).not.toMatch(/#[0-9a-f]{3,8}\b|\brgba?\(/i);
|
||||
expect(block).not.toMatch(/\b\d+px\b/);
|
||||
}
|
||||
}
|
||||
|
||||
const overview480 = extractMediaBlock(overviewCss, 480);
|
||||
const card480 = extractMediaBlock(cardCss, 480);
|
||||
expect(overview480).toContain("var(--space-sm)");
|
||||
expect(card480).toContain("var(--space-md)");
|
||||
});
|
||||
|
||||
it("reserves project mobile-nav padding only when the shared nav predicate is visible", () => {
|
||||
expect(appSource).toContain('const mobileNavVisible = viewMode === "project" && !!currentProject;');
|
||||
expect(appSource).toContain('isMobile && mobileNavVisible && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""');
|
||||
expect(appSource).toContain("hidden={!mobileNavVisible}");
|
||||
expect(appSource).toContain("footerVisible={mobileNavVisible}");
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,11 @@ export interface MobileNavBarProps {
|
||||
footerVisible: boolean;
|
||||
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
||||
modalOpen?: boolean;
|
||||
/*
|
||||
FNXC:Navigation 2026-07-25-22:57:
|
||||
The project overview has no task tabs to drive, so callers hide the mobile bar rather than rendering a non-functional navigation shell.
|
||||
*/
|
||||
hidden?: boolean;
|
||||
/** Whether the on-screen mobile keyboard is open */
|
||||
keyboardOpen?: boolean;
|
||||
// Navigation handlers
|
||||
@@ -145,6 +150,7 @@ export function MobileNavBar({
|
||||
onChangeView,
|
||||
footerVisible,
|
||||
modalOpen = false,
|
||||
hidden = false,
|
||||
keyboardOpen = false,
|
||||
onOpenSettings,
|
||||
onOpenActivityLog,
|
||||
@@ -346,6 +352,15 @@ export function MobileNavBar({
|
||||
}, [dismissMore, isMoreOpen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
/*
|
||||
FNXC:Navigation 2026-07-25-22:57:
|
||||
A hidden overview bar must remove its published height as well as its DOM shell; otherwise project content retains dead bottom space.
|
||||
*/
|
||||
if (hidden) {
|
||||
document.documentElement.style.removeProperty("--mobile-nav-height");
|
||||
return;
|
||||
}
|
||||
|
||||
const navEl = navRef.current;
|
||||
if (!navEl || typeof document === "undefined") {
|
||||
return;
|
||||
@@ -377,9 +392,9 @@ export function MobileNavBar({
|
||||
observer?.disconnect();
|
||||
document.documentElement.style.removeProperty("--mobile-nav-height");
|
||||
};
|
||||
}, []);
|
||||
}, [hidden]);
|
||||
|
||||
if (mode !== "mobile" || modalOpen) {
|
||||
if (mode !== "mobile" || modalOpen || hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -285,3 +285,61 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DashboardResponsive 2026-07-25-22:57:
|
||||
Project cards must not retain their desktop minimum at 320px. Literal pixel media conditions are the permitted dashboard breakpoint convention; declarations remain token-based.
|
||||
*/
|
||||
@media (max-width: 480px) {
|
||||
.project-card,
|
||||
.project-card-header,
|
||||
.project-card-title-section,
|
||||
.project-card-availability__row,
|
||||
.project-card-skeleton__header,
|
||||
.project-skeleton__text-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-card-name,
|
||||
.project-card-path,
|
||||
.project-card-availability__path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-card-health,
|
||||
.project-card-skeleton__health {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.project-card-metric,
|
||||
.project-card-metric-empty {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DashboardResponsive 2026-07-25-22:57:
|
||||
At the 320px floor, card actions and skeleton footer content stack instead of creating a horizontal overflow. Literal pixel media conditions are intentional; declarations use tokens.
|
||||
*/
|
||||
@media (max-width: 380px) {
|
||||
.project-card-header,
|
||||
.project-card-footer,
|
||||
.project-card-skeleton__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-card-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-card-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,3 +663,102 @@ Keep Dashboard on the canonical header model instead of changing every other vie
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DashboardResponsive 2026-07-25-22:57:
|
||||
The overview must stay usable down to 320px. Literal pixel media conditions are the permitted dashboard breakpoint convention; declaration values remain token-based.
|
||||
*/
|
||||
@media (max-width: 480px) {
|
||||
.project-overview__body {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-overview__header,
|
||||
.project-overview__filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-overview__stats,
|
||||
.project-overview__stats-row {
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-stat,
|
||||
.project-overview__stat-skeleton {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-filter-tabs,
|
||||
.project-overview__filters-skeleton {
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-filter-tab {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.project-sort,
|
||||
.project-node-filter {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-sort-select,
|
||||
.project-node-filter-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-empty-state,
|
||||
.project-overview__no-results {
|
||||
min-width: 0;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.project-card--skeleton,
|
||||
.project-skeleton__text-group {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DashboardResponsive 2026-07-25-22:57:
|
||||
At the 320px floor, stack compact stat and skeleton rows so fixed-width placeholders cannot force horizontal scrolling. Literal pixel media conditions are intentional; declarations use tokens.
|
||||
*/
|
||||
@media (max-width: 380px) {
|
||||
.project-overview__stats,
|
||||
.project-overview__stats-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-stat,
|
||||
.project-overview__stat-skeleton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-overview__filters-skeleton {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.project-skeleton--tab {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.project-empty-state,
|
||||
.project-overview__no-results {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user