FN-6800: center mobile nav icons in fixed slots
Normalize mobile bottom navigation icon slots so unread indicators no longer skew perceived spacing. - Wrap every mobile nav icon in a consistent token-sized slot. - Keep chat unread dots inside the icon slot instead of protruding past it. - Extend MobileNavBar tests to assert icon-slot structure and spacing CSS. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6800-mobile-nav-spacing.md | 5 ++++ packages/dashboard/app/components/MobileNavBar.css | 22 ++++++++++++----- packages/dashboard/app/components/MobileNavBar.tsx | 28 ++++++++++++++++------ .../app/components/__tests__/MobileNavBar.test.tsx | 24 ++++++++++++++++++- 4 files changed, 65 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-6800 Fusion-Task-Lineage: 66ac21cf-6761-4b2c-b2ae-723998ae2632
This commit is contained in:
5
.changeset/fn-6800-mobile-nav-spacing.md
Normal file
5
.changeset/fn-6800-mobile-nav-spacing.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix mobile bottom navigation icon alignment so unread indicators use a centered token-sized icon slot without visually skewing tab spacing.
|
||||||
@@ -78,8 +78,13 @@
|
|||||||
FNXC:MobileNav 2026-06-20-02:04:
|
FNXC:MobileNav 2026-06-20-02:04:
|
||||||
Every mobile nav tab must be an equal-width column with a centered icon so inter-icon spacing stays uniform across tab counts, long labels, active state, badges, and status dots.
|
Every mobile nav tab must be an equal-width column with a centered icon so inter-icon spacing stays uniform across tab counts, long labels, active state, badges, and status dots.
|
||||||
Use a zero flex basis plus min-width:0 so intrinsic label width cannot bias flex distribution; badges and status dots stay absolutely positioned and out of flow.
|
Use a zero flex basis plus min-width:0 so intrinsic label width cannot bias flex distribution; badges and status dots stay absolutely positioned and out of flow.
|
||||||
|
|
||||||
|
FNXC:MobileNav 2026-06-20-16:11:
|
||||||
|
Real-browser geometry showed the SVG centers were uniform after FN-6766, but status dots protruded past Chat/Mailbox icon boxes and made those icon clusters optically right-heavy.
|
||||||
|
Wrap every tab icon in the same token-sized icon slot and keep unread/pending dots inside that slot so icon centering and perceived inter-icon spacing stay identical across tab count, label length, badge, status-dot, and active variants.
|
||||||
*/
|
*/
|
||||||
.mobile-nav-tab {
|
.mobile-nav-tab {
|
||||||
|
--mobile-nav-icon-size: calc(var(--space-lg) + var(--space-sm) - (var(--space-xs) / 2));
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -87,7 +92,7 @@ Use a zero flex basis plus min-width:0 so intrinsic label width cannot bias flex
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: calc(var(--space-xs) / 2);
|
gap: calc(var(--space-xs) / 2);
|
||||||
min-height: 36px;
|
min-height: calc(var(--space-xl) + var(--space-md));
|
||||||
padding: calc(var(--space-sm) - (var(--space-xs) / 2)) 0;
|
padding: calc(var(--space-sm) - (var(--space-xs) / 2)) 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -109,19 +114,24 @@ Use a zero flex basis plus min-width:0 so intrinsic label width cannot bias flex
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-tab svg {
|
.mobile-nav-tab svg {
|
||||||
width: 22px;
|
width: var(--mobile-nav-icon-size);
|
||||||
height: 22px;
|
height: var(--mobile-nav-icon-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-tab-icon-wrapper {
|
.mobile-nav-tab-icon-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
|
flex: 0 0 var(--mobile-nav-icon-size);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--mobile-nav-icon-size);
|
||||||
|
height: var(--mobile-nav-icon-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-chat-unread-dot {
|
.mobile-nav-chat-unread-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(var(--space-xs) * -1);
|
top: 0;
|
||||||
right: calc(var(--space-xs) * -1);
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-tab-label {
|
.mobile-nav-tab-label {
|
||||||
|
|||||||
@@ -328,7 +328,9 @@ export function MobileNavBar({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutGrid />
|
<span className="mobile-nav-tab-icon-wrapper">
|
||||||
|
<LayoutGrid />
|
||||||
|
</span>
|
||||||
<span className="mobile-nav-tab-label">{t("nav.tasks", "Tasks")}</span>
|
<span className="mobile-nav-tab-label">{t("nav.tasks", "Tasks")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -340,7 +342,9 @@ export function MobileNavBar({
|
|||||||
aria-selected={view === "agents"}
|
aria-selected={view === "agents"}
|
||||||
onClick={() => onChangeView("agents")}
|
onClick={() => onChangeView("agents")}
|
||||||
>
|
>
|
||||||
<Bot />
|
<span className="mobile-nav-tab-icon-wrapper">
|
||||||
|
<Bot />
|
||||||
|
</span>
|
||||||
<span className="mobile-nav-tab-label">{t("nav.agents", "Agents")}</span>
|
<span className="mobile-nav-tab-label">{t("nav.agents", "Agents")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -352,7 +356,9 @@ export function MobileNavBar({
|
|||||||
aria-selected={view === "missions"}
|
aria-selected={view === "missions"}
|
||||||
onClick={() => onChangeView("missions")}
|
onClick={() => onChangeView("missions")}
|
||||||
>
|
>
|
||||||
<Target />
|
<span className="mobile-nav-tab-icon-wrapper">
|
||||||
|
<Target />
|
||||||
|
</span>
|
||||||
<span className="mobile-nav-tab-label">{t("nav.missions", "Missions")}</span>
|
<span className="mobile-nav-tab-label">{t("nav.missions", "Missions")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -407,7 +413,9 @@ export function MobileNavBar({
|
|||||||
aria-selected={view === "command-center"}
|
aria-selected={view === "command-center"}
|
||||||
onClick={() => onChangeView("command-center")}
|
onClick={() => onChangeView("command-center")}
|
||||||
>
|
>
|
||||||
<Gauge />
|
<span className="mobile-nav-tab-icon-wrapper">
|
||||||
|
<Gauge />
|
||||||
|
</span>
|
||||||
<span className="mobile-nav-tab-label">{t("nav.commandCenter", "Command Center")}</span>
|
<span className="mobile-nav-tab-label">{t("nav.commandCenter", "Command Center")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -420,7 +428,9 @@ export function MobileNavBar({
|
|||||||
aria-selected={view === "skills"}
|
aria-selected={view === "skills"}
|
||||||
onClick={() => onChangeView("skills")}
|
onClick={() => onChangeView("skills")}
|
||||||
>
|
>
|
||||||
<Zap />
|
<span className="mobile-nav-tab-icon-wrapper">
|
||||||
|
<Zap />
|
||||||
|
</span>
|
||||||
<span className="mobile-nav-tab-label">{t("nav.skills", "Skills")}</span>
|
<span className="mobile-nav-tab-label">{t("nav.skills", "Skills")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -439,7 +449,9 @@ export function MobileNavBar({
|
|||||||
aria-selected={view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph")}
|
aria-selected={view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph")}
|
||||||
onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
|
onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
|
||||||
>
|
>
|
||||||
<PluginIcon />
|
<span className="mobile-nav-tab-icon-wrapper">
|
||||||
|
<PluginIcon />
|
||||||
|
</span>
|
||||||
<span className="mobile-nav-tab-label">{entry.view.label}</span>
|
<span className="mobile-nav-tab-label">{entry.view.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -453,7 +465,9 @@ export function MobileNavBar({
|
|||||||
aria-selected={false}
|
aria-selected={false}
|
||||||
onClick={() => setIsMoreOpen((prev) => !prev)}
|
onClick={() => setIsMoreOpen((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal />
|
<span className="mobile-nav-tab-icon-wrapper">
|
||||||
|
<MoreHorizontal />
|
||||||
|
</span>
|
||||||
<span className="mobile-nav-tab-label">{t("nav.more", "More")}</span>
|
<span className="mobile-nav-tab-label">{t("nav.more", "More")}</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -45,12 +45,26 @@ function expectUniformMobileNavColumns(container: HTMLElement, expectedTabCount:
|
|||||||
expect(tabs).toHaveLength(expectedTabCount);
|
expect(tabs).toHaveLength(expectedTabCount);
|
||||||
|
|
||||||
const tabRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab");
|
const tabRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab");
|
||||||
|
expect(tabRule).toContain("--mobile-nav-icon-size: calc(var(--space-lg) + var(--space-sm) - (var(--space-xs) / 2))");
|
||||||
expect(tabRule).toContain("flex: 1 1 0");
|
expect(tabRule).toContain("flex: 1 1 0");
|
||||||
expect(tabRule).toContain("min-width: 0");
|
expect(tabRule).toContain("min-width: 0");
|
||||||
expect(tabRule).toContain("align-items: center");
|
expect(tabRule).toContain("align-items: center");
|
||||||
expect(tabRule).toMatch(/padding:\s*[^;]+\s+0;/);
|
expect(tabRule).toMatch(/padding:\s*[^;]+\s+0;/);
|
||||||
expect(tabRule).not.toMatch(/margin-left|margin-right/);
|
expect(tabRule).not.toMatch(/margin-left|margin-right/);
|
||||||
|
|
||||||
|
const iconRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab svg");
|
||||||
|
expect(iconRule).toContain("width: var(--mobile-nav-icon-size)");
|
||||||
|
expect(iconRule).toContain("height: var(--mobile-nav-icon-size)");
|
||||||
|
|
||||||
|
const iconWrapperRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab-icon-wrapper");
|
||||||
|
expect(iconWrapperRule).toContain("position: relative");
|
||||||
|
expect(iconWrapperRule).toContain("display: flex");
|
||||||
|
expect(iconWrapperRule).toContain("flex: 0 0 var(--mobile-nav-icon-size)");
|
||||||
|
expect(iconWrapperRule).toContain("align-items: center");
|
||||||
|
expect(iconWrapperRule).toContain("justify-content: center");
|
||||||
|
expect(iconWrapperRule).toContain("width: var(--mobile-nav-icon-size)");
|
||||||
|
expect(iconWrapperRule).toContain("height: var(--mobile-nav-icon-size)");
|
||||||
|
|
||||||
const labelRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab-label");
|
const labelRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab-label");
|
||||||
expect(labelRule).toContain("width: 100%");
|
expect(labelRule).toContain("width: 100%");
|
||||||
expect(labelRule).toContain("min-width: 0");
|
expect(labelRule).toContain("min-width: 0");
|
||||||
@@ -59,6 +73,10 @@ function expectUniformMobileNavColumns(container: HTMLElement, expectedTabCount:
|
|||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
expect(tab.className).toContain("mobile-nav-tab");
|
expect(tab.className).toContain("mobile-nav-tab");
|
||||||
expect(tab.querySelector(".mobile-nav-tab-label")).toBeInTheDocument();
|
expect(tab.querySelector(".mobile-nav-tab-label")).toBeInTheDocument();
|
||||||
|
const iconSlots = tab.querySelectorAll(":scope > .mobile-nav-tab-icon-wrapper");
|
||||||
|
expect(iconSlots).toHaveLength(1);
|
||||||
|
expect(tab.querySelector(":scope > svg")).toBeNull();
|
||||||
|
expect(iconSlots[0].querySelector("svg")).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (container.querySelector(".mobile-nav-tab-badge")) {
|
if (container.querySelector(".mobile-nav-tab-badge")) {
|
||||||
@@ -66,7 +84,11 @@ function expectUniformMobileNavColumns(container: HTMLElement, expectedTabCount:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (container.querySelector(".mobile-nav-chat-unread-dot")) {
|
if (container.querySelector(".mobile-nav-chat-unread-dot")) {
|
||||||
expect(extractRuleBlock(mobileNavCss, ".mobile-nav-chat-unread-dot")).toContain("position: absolute");
|
const dotRule = extractRuleBlock(mobileNavCss, ".mobile-nav-chat-unread-dot");
|
||||||
|
expect(dotRule).toContain("position: absolute");
|
||||||
|
expect(dotRule).toContain("top: 0");
|
||||||
|
expect(dotRule).toContain("right: 0");
|
||||||
|
expect(dotRule).not.toContain("*-1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user