Fix(nav): collapse the link row by measured width, not by isMobile

Tablets report a desktop UA, so between 768px and ~1366px the flat
link row rendered and wrapped inside its links; no single breakpoint
fits six locales. use-nav-overflow.js measures the row against the
space beside the brand and actions and collapses to the hamburger
when it doesn't fit. The row stays rendered (invisible, inert) so it
remains measurable. DocsSearch switches to its icon below xl by CSS so
the action group's width never depends on the collapse state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Fable 5.1 committed 2026-10-20 17:54:46 +08:00
1 parent 35e691da6e
commit d28fe239ae
5 files changed
+153 -23

No files matched your search

+2
View File
@@ -177,6 +177,8 @@ Every "business state → color" mapping goes through `use-status-tone.js`
`resolveNavTarget()` (`utils/nav-target.js`: scroll on Home, go home then
scroll elsewhere, a tool → its page), carried out by `use-nav-target.js`;
no section is highlighted off Home.
- The link row collapses to the hamburger + Sheet when `use-nav-overflow.js`
measures it doesn't fit (no breakpoint); `isMobile` only drives phone behaviour.
- **Page breadcrumb** — every page but Home opens its body with
`<PageBreadcrumb :items />` above the `<h1>` (← Home / … / current page):
real links, Home via `resolveBackTarget()`, section crumbs via the Nav's
+37 -16
View File
@@ -6,11 +6,13 @@
:class="{ '-translate-y-full': isNavHidden,
'bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur': !isPwa || (isPwa && !isMobile),
'bg-page-bg': isPwa && isMobile }">
<nav id="navbar-top" class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
<nav id="navbar-top" ref="navEl"
class="relative mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
<!-- Left: Hamburger (only mobile) + Brand -->
<div class="flex items-center gap-2">
<Button v-if="isMobile" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
<!-- Left: Hamburger (collapsed nav only) + Brand. Expanded, every group
keeps its natural width so use-nav-overflow measures true sizes. -->
<div ref="brandEl" class="flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
<Button v-if="isNavCollapsed" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
aria-label="Toggle navigation menu" @click="store.toggleSheet('navMenu')">
<Menu />
</Button>
@@ -25,8 +27,12 @@
</a>
</div>
<!-- Middle: Desktop nav links + GitHub star badge (left aligned, next to brand) -->
<div v-if="!isMobile" class="flex items-center gap-0.5">
<!-- Middle: nav links + GitHub star badge (left aligned, next to brand).
Always rendered so its width stays measurable; collapsed, it is
invisible, out of flow and out of the a11y tree / tab order. -->
<div ref="linksEl" class="flex w-max shrink-0 items-center gap-0.5"
:class="{ 'invisible absolute left-0 top-0 pointer-events-none': isNavCollapsed }"
:aria-hidden="isNavCollapsed ? 'true' : undefined" :inert="isNavCollapsed || undefined">
<template v-for="item in navItems" :key="item">
<!-- Advanced Tools: hover reveals the sub-tools, click goes to the
section like the other links (disable-click-trigger frees the
@@ -36,7 +42,7 @@
class="flex-none">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger :class="['h-auto bg-transparent', navLinkClass(item)]"
<NavigationMenuTrigger :class="['h-auto bg-transparent whitespace-nowrap', navLinkClass(item)]"
@click="goToSection(item)">
{{ t(`nav.${item}`) }}
</NavigationMenuTrigger>
@@ -58,7 +64,7 @@
</NavigationMenuList>
</NavigationMenu>
<!-- All other sections -->
<a v-else href="#" :class="navLinkClass(item)" @click.prevent="goToSection(item)">
<a v-else href="#" :class="[navLinkClass(item), 'whitespace-nowrap']" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }}
</a>
</template>
@@ -76,11 +82,11 @@
</div>
<!-- Right: Action area (ml-auto push to the right) -->
<div class="ml-auto flex items-center gap-2">
<div ref="actionsEl" class="ml-auto flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
<!-- Earth Online entry (code name: pulse) -->
<Pulse />
<!-- Docs assistant entry point (ask box on desktop, icon on mobile) -->
<!-- Docs assistant entry point (ask box from xl up, icon below) -->
<DocsSearch />
<!-- Account menu: preferences cog (Firebase-less) or the user dropdown -->
@@ -88,9 +94,10 @@
</div>
</nav>
<!-- Mobile navigation sheet. Flex column so the link list scrolls instead
of clipping on short screens when Advanced Tools is expanded. -->
<Sheet v-if="isMobile" :open="isNavMenuOpen" @update:open="onNavMenuChange">
<!-- Navigation sheet of the collapsed nav (a phone, or a viewport where the
links don't fit). Flex column so the link list scrolls instead of
clipping on short screens when Advanced Tools is expanded. -->
<Sheet v-if="isNavCollapsed" :open="isNavMenuOpen" @update:open="onNavMenuChange">
<SheetContent side="left" class="w-80 p-0 flex flex-col gap-0" :title="t('nav.Navigation')">
<div class="flex shrink-0 items-center justify-between border-b px-4 py-3">
<h5 class="m-0 text-base font-semibold">{{ t('nav.Navigation') }}</h5>
@@ -119,7 +126,7 @@
</div>
</CollapsibleContent>
</Collapsible>
<!-- All other sections, as on desktop. -->
<!-- All other sections, as in the expanded nav. -->
<a v-else href="#" :class="navLinkClass(item, { block: true })" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }}
</a>
@@ -176,6 +183,7 @@ import { formatStarCount } from '@/utils/format-star-count.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { useNavTarget } from '@/composables/use-nav-target.js';
import { useNavOverflow } from '@/composables/use-nav-overflow.js';
const { t } = useI18n();
const store = useMainStore();
@@ -185,6 +193,19 @@ const { navigateTo } = useNavTarget();
const isDarkMode = computed(() => store.isDarkMode);
const isMobile = computed(() => store.isMobile);
// The link row collapses into the hamburger + Sheet on a phone, or wherever
// it doesn't fit beside the brand and actions. isMobile alone drives phone
// behaviour (scroll-hide, PWA background).
const NAV_GAP_PX = 8; // the nav's gap-2
const navEl = ref(null);
const brandEl = ref(null);
const linksEl = ref(null);
const actionsEl = ref(null);
const { overflow } = useNavOverflow({
nav: navEl, brand: brandEl, links: linksEl, actions: actionsEl, gap: NAV_GAP_PX,
});
const isNavCollapsed = computed(() => isMobile.value || overflow.value);
// store.currentSection keeps Home's last section, so it's ignored elsewhere.
const isHome = computed(() => route.name === 'home');
const currentSection = computed(() => (isHome.value ? store.currentSection : null));
@@ -203,7 +224,7 @@ const navItems = SECTION_IDS;
const configs = computed(() => store.configs);
const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value));
// Mobile: Advanced Tools sub-list expanded by default for discoverability.
// Sheet: Advanced Tools sub-list expanded by default for discoverability.
const mobileToolsOpen = ref(true);
// GitHub star count for the repo badge. Fetched from our own edge-cached
@@ -252,7 +273,7 @@ const handleLogoClick = (e) => {
trackEvent('Nav', 'NavClick', 'Logo');
};
// Section and tool entries close the mobile nav Sheet first and navigate once
// Section and tool entries close the nav Sheet first and navigate once
// its slide-out is over: WebKit records the back/forward snapshot of a page
// as it navigates away, and a swipe back would show the sheet still open.
const SHEET_CLOSE_MS = 300; // ui/sheet data-[state=closed]:duration-300
+7 -7
View File
@@ -1,10 +1,11 @@
<template>
<!-- Nav entry point for the docs assistant. Desktop gets an ask box, mobile
the same entry as a single icon. Both are inert while the panel itself
is open (it owns the conversation then).
<!-- Nav entry point for the docs assistant: an ask box from xl up, the same
entry as a single icon below it (both rendered, CSS picks one, so the
nav's action group never changes width with its own collapse). Both are
inert while the panel itself is open (it owns the conversation then).
The embed lives in widgets/DocsAssistant.vue; the loading and
configuration in composables/use-docs-assistant.js. -->
<form v-if="enabled && !isMobile" class="relative" @submit.prevent="submitDocsSearch">
<form v-if="enabled" class="relative max-xl:hidden" @submit.prevent="submitDocsSearch">
<Sparkle class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground z-10" />
<!-- Focused, the native placeholder prompts for a question; idle, the
rotating overlay below advertises what can be asked. -->
@@ -27,8 +28,8 @@
</div>
</form>
<JnTooltip v-else-if="enabled" :text="t('nav.AskIPilot')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" :aria-label="t('nav.AskIPilot')"
<JnTooltip v-if="enabled" :text="t('nav.AskIPilot')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer xl:hidden" :aria-label="t('nav.AskIPilot')"
:disabled="isDocsOpen" @click="openAssistant">
<Sparkle />
</Button>
@@ -50,7 +51,6 @@ const ROTATE_MS = 4000;
const { t } = useI18n();
const store = useMainStore();
const isMobile = computed(() => store.isMobile);
// Two gates for visibility: a docs site to answer from (build time) and the
// canonical deployment (same as the original-site-only advanced tools).
+62
View File
@@ -0,0 +1,62 @@
// Whether the site nav's link row fits beside the brand and the action
// group. Collapse follows the measured content, not a breakpoint: link
// labels differ widely in width across locales.
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
const isSize = (n) => typeof n === 'number' && Number.isFinite(n);
// The nav lays out brand · links · actions with `gap` between each pair, so
// the links get the nav width minus the other two groups and two gaps.
// An unmeasured nav (a missing size, or a zero-width nav) counts as fitting,
// so desktop never collapses on bad input.
export const navLinksFit = ({ navWidth, brandWidth, actionsWidth, linksWidth, gap }) => {
const sizes = [navWidth, brandWidth, actionsWidth, linksWidth, gap];
if (!sizes.every(isSize) || navWidth <= 0) return true;
return linksWidth <= navWidth - brandWidth - actionsWidth - 2 * gap;
};
const widthOf = (el) => (el ? el.getBoundingClientRect().width : NaN);
// Template refs in, `overflow` out. The links element must stay rendered
// (and keep its natural width) while collapsed so it can still be measured.
export const useNavOverflow = ({ nav, brand, links, actions, gap }) => {
const overflow = ref(false);
const { locale } = useI18n();
let observer = null;
const measure = () => {
overflow.value = !navLinksFit({
navWidth: widthOf(nav.value),
brandWidth: widthOf(brand.value),
actionsWidth: widthOf(actions.value),
linksWidth: widthOf(links.value),
gap,
});
};
// Synchronous first pass, so the first paint already has the right layout.
onMounted(() => {
if (typeof ResizeObserver === 'undefined') return;
measure();
observer = new ResizeObserver(measure);
for (const el of [nav.value, brand.value, links.value, actions.value]) {
if (el) observer.observe(el);
}
});
// Translated labels change the links' width; measure once they rendered.
watch(locale, async () => {
if (!observer) return;
await nextTick();
measure();
});
onBeforeUnmount(() => {
observer?.disconnect();
observer = null;
});
return { overflow };
};
+45
View File
@@ -0,0 +1,45 @@
// Specs for navLinksFit in composables/use-nav-overflow.js — the width check
// that decides whether the site nav's link row fits or collapses into the
// hamburger. The ResizeObserver half of the composable is out of scope.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { navLinksFit } from '../frontend/composables/use-nav-overflow.js';
const sizes = (over = {}) => ({
navWidth: 1200, brandWidth: 130, actionsWidth: 320, linksWidth: 700, gap: 8, ...over,
});
describe('navLinksFit', () => {
it('fits with room to spare', () => {
assert.equal(navLinksFit(sizes()), true);
});
it('treats an exact fit as a fit', () => {
// 1200 - 130 - 320 - 2 * 8 = 734
assert.equal(navLinksFit(sizes({ linksWidth: 734 })), true);
});
it('overflows one pixel past the room left', () => {
assert.equal(navLinksFit(sizes({ linksWidth: 735 })), false);
});
it('counts the gap twice (brand · links · actions)', () => {
// 1000 - 100 - 200 = 700 before gaps; one gap would leave 690, two leave 680.
const base = { navWidth: 1000, brandWidth: 100, actionsWidth: 200, gap: 10 };
assert.equal(navLinksFit({ ...base, linksWidth: 680 }), true);
assert.equal(navLinksFit({ ...base, linksWidth: 681 }), false);
assert.equal(navLinksFit({ ...base, linksWidth: 690 }), false);
});
it('fits when a measurement is missing', () => {
for (const key of ['navWidth', 'brandWidth', 'actionsWidth', 'linksWidth', 'gap']) {
assert.equal(navLinksFit(sizes({ linksWidth: 5000, [key]: undefined })), true, key);
assert.equal(navLinksFit(sizes({ linksWidth: 5000, [key]: NaN })), true, key);
}
assert.equal(navLinksFit({}), true);
});
it('fits while the nav has no width yet', () => {
assert.equal(navLinksFit(sizes({ navWidth: 0, linksWidth: 5000 })), true);
});
});