Skip to content

feat(navbar): hybrid bottom-bar navigation for mobile#4

Open
trtlbby wants to merge 4 commits intoJourKnows:mainfrom
trtlbby:feature-nav-mobile
Open

feat(navbar): hybrid bottom-bar navigation for mobile#4
trtlbby wants to merge 4 commits intoJourKnows:mainfrom
trtlbby:feature-nav-mobile

Conversation

@trtlbby
Copy link
Copy Markdown

@trtlbby trtlbby commented Apr 4, 2026

Replaces the hamburger menu on mobile/tablet with a bottom navigation bar + overlay panel pattern. Desktop is unchanged.

Changes

  • Bottom bar (mobile only): 5 tabs — Home, Latest, Search, Sections, More
  • Overlay panels: Search input, 2-column category grid, and full menu opened from the bottom bar
  • Hydration fix: useBreakpoint() now starts with a stable desktop state during SSR, preventing React hydration mismatches and the "zoom-out flash" on page transitions. A lightweight CSS-only header shell renders before JS initializes.
  • Scroll lock fix: Locks both <html> and <body> overflow when a panel is open, fixing a conflict with html { overflow-y: scroll } in base CSS.
  • Spacing fix: Bottom bar clearance moved from an inline spacer <div> to a CSS media query padding-bottom on <body>.

Why

The hamburger hides key destinations behind an extra tap and sits in the hardest thumb-reach zone on tall phones. A persistent bottom bar surfaces the most-used actions immediately, matching the navigation pattern used by major news apps. This is on an experimental branch for review before merging.

trtlbby added 4 commits April 4, 2026 07:37
- Add shared .jk-img-container class with branded logo placeholder
- Add shared hover zoom behavior via .jk-img-container img
- Update HeaderCard, MediumCard, SmallCard to use .jk-img-container
- Replace inline bg-[#d9d9d9] with consistent branded fallback
- Use .jk-img-container on hero section when no image
- Add body scroll lock when mobile menu is open
- Change menu animation from fade-up to slide-in-right
- Close menu on navigation link click
- Fix overlay to start below header bar
style(index): adjust layout for hero post title container
@trtlbby trtlbby requested a review from arsg0etia April 4, 2026 00:20
@trtlbby trtlbby added the enhancement New feature or request label Apr 5, 2026
@trtlbby trtlbby requested a review from Copilot April 15, 2026 04:12
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a hybrid mobile navigation experience by replacing the hamburger menu with a bottom tab bar that opens overlay panels, and adjusts SSR/hydration behavior to reduce breakpoint-driven hydration mismatches.

Changes:

  • Updated useBreakpoint() to SSR with a stable desktop width and expose an isHydrated flag.
  • Reworked AppNavbar to use a mobile bottom bar + overlay panels, plus scroll locking while panels are open.
  • Consolidated image placeholder/hover styling into a reusable .jk-img-container utility and applied it to multiple card/hero components; moved bottom-bar clearance to CSS body padding.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/utils/useBreakpoint.ts Adds an SSR-stable initial width and an isHydrated signal for client-only breakpoint behaviors.
src/styles/base.css Adds mobile body bottom padding and introduces .jk-img-container styling + new keyframes.
src/pages/index.astro Uses .jk-img-container for hero fallback styling and tweaks hero meta layout wrapping.
src/components/SmallCard.astro Switches image wrappers to .jk-img-container and relies on shared image styling.
src/components/MediumCard.astro Same .jk-img-container consolidation for medium card imagery.
src/components/HeaderCard.astro Same .jk-img-container consolidation for header card imagery.
src/components/AppNavbar.tsx Major redesign: bottom tabs + overlay panels, hydration gating, and scroll lock behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +463 to +466
action: () => {
setActivePanel(null);
window.location.href = "/posts";
},
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using window.location.href for the Latest tab forces a full page reload and removes standard link behaviors. Prefer an <a href="/posts"> for navigation tabs so browser/link semantics (new tab, copy link, prefetch) keep working.

Copilot uses AI. Check for mistakes.
@@ -1,20 +1,122 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { LOGO, SEARCH_ICON } from "../utils/constants";
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOGO and SEARCH_ICON are imported but no longer used in this file (logo/search now use hard-coded URLs / inline SVG). This will fail builds if noUnusedLocals/unused-import checks are enabled and also makes asset sources inconsistent. Remove the unused imports or switch the <img src=...> usages back to the constants/local assets.

Suggested change
import { LOGO, SEARCH_ICON } from "../utils/constants";

Copilot uses AI. Check for mistakes.
Comment on lines +168 to +171
<img
src="https://i.imgur.com/u77FS3O.jpeg"
alt="Site banner"
style={{
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These banner/logo images are now hard-coded to external URLs. This adds a third-party runtime dependency (availability/privacy/perf/CSP) and the same URL is duplicated elsewhere in this component. Prefer using local assets/constants (e.g. from src/utils/constants.ts) or a single config value to avoid repeated hard-coded URLs.

Copilot uses AI. Check for mistakes.
Comment on lines +454 to +457
action: () => {
setActivePanel(null);
window.location.href = "/";
},
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using window.location.href for the Home tab forces a full page reload and loses standard link behaviors (open in new tab, copy link address, etc.). Prefer rendering this tab as an <a href="/"> (or the project’s preferred navigation helper) and reserve <button> for panel toggles.

Copilot uses AI. Check for mistakes.
Comment on lines +339 to +341
className="fixed inset-0 z-[198] bg-black/50"
style={{ top: 50, bottom: 64 }}
onClick={() => setActivePanel(null)}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overlay positioning uses hard-coded top: 50 and bottom: 64 values. This will break if the header or bottom bar height changes (or if safe-area handling changes). Consider deriving these from shared constants/CSS variables or using layout techniques that avoid magic numbers (e.g. measuring the header/bar heights or rendering the overlay as a flex column between them).

Copilot uses AI. Check for mistakes.
Comment on lines +107 to +116
if (activePanel && compact) {
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
} else {
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
}
return () => {
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scroll-lock effect clears documentElement.style.overflow / body.style.overflow back to an empty string, which can overwrite pre-existing inline styles set by other code. To avoid clobbering other overflow management, capture the previous values before setting hidden and restore those exact values in the cleanup.

Suggested change
if (activePanel && compact) {
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
} else {
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
}
return () => {
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
const previousDocumentOverflow = document.documentElement.style.overflow;
const previousBodyOverflow = document.body.style.overflow;
if (activePanel && compact) {
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
}
return () => {
document.documentElement.style.overflow = previousDocumentOverflow;
document.body.style.overflow = previousBodyOverflow;

Copilot uses AI. Check for mistakes.
Comment on lines +159 to +161
<div
className="hidden lg:block"
style={{
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the pre-hydration render, the banner wrapper uses className="hidden lg:block", which hides the banner on tablet widths (sm..lg). After hydration the banner uses a different breakpoint (hidden sm:block), so tablets will see the banner “pop in” post-hydration. Align the breakpoints to avoid this layout shift.

Copilot uses AI. Check for mistakes.
{/* Site Banner */}
{/* Site Banner — desktop only */}
<div
className="hidden sm:block"
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hydrated render shows the banner at sm and up (className="hidden sm:block"), but the pre-hydration branch uses lg:block. If the banner is intended to be “desktop only”, consider using consistent lg:block here as well; otherwise update the pre-hydration branch to match so tablet widths don’t get a post-hydration jump.

Suggested change
className="hidden sm:block"
className="hidden lg:block"

Copilot uses AI. Check for mistakes.
Comment on lines +153 to +214
// Mobile loading overlay — shown before hydration on mobile-width screens
// Uses CSS media query so it never appears on desktop regardless of JS state
if (!isHydrated) {
return (
<>
{/* Desktop banner + header render normally during SSR */}
<div
className="hidden lg:block"
style={{
width: "100%",
aspectRatio: "1024 / 85",
overflow: "hidden",
position: "relative",
}}
>
<img
src="https://i.imgur.com/u77FS3O.jpeg"
alt="Site banner"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
<img
src="https://i0.wp.com/jourknowsph.com/wp-content/uploads/2026/02/untitled-1920-x-900-px-1920-x-750-px.png?fit=1920%2C750&ssl=1"
alt="Site logo"
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
height: "90%",
aspectRatio: "311 / 224",
objectFit: "contain",
}}
/>
</div>
{/* Mobile: slim loading header while JS initializes */}
<div
className="lg:hidden"
style={{
position: "sticky",
top: 0,
zIndex: 200,
height: 50,
background: "linear-gradient(135deg, #000050 0%, #000080 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src="https://i0.wp.com/jourknowsph.com/wp-content/uploads/2026/02/untitled-1920-x-900-px-1920-x-750-px.png?fit=1920%2C750&ssl=1"
alt="JourKnows"
style={{ height: 30, width: "auto", objectFit: "contain" }}
/>
</div>
</>
);
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if (!isHydrated) return ... branch means the real header/nav never renders during SSR/initial paint (it’s always replaced by this shell until hydration). That’s a regression for desktop and for users with JS disabled/slow. Consider SSR-rendering the desktop header markup (and optionally a mobile shell hidden via CSS) instead of gating the whole navbar on isHydrated.

Suggested change
// Mobile loading overlay — shown before hydration on mobile-width screens
// Uses CSS media query so it never appears on desktop regardless of JS state
if (!isHydrated) {
return (
<>
{/* Desktop banner + header render normally during SSR */}
<div
className="hidden lg:block"
style={{
width: "100%",
aspectRatio: "1024 / 85",
overflow: "hidden",
position: "relative",
}}
>
<img
src="https://i.imgur.com/u77FS3O.jpeg"
alt="Site banner"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
<img
src="https://i0.wp.com/jourknowsph.com/wp-content/uploads/2026/02/untitled-1920-x-900-px-1920-x-750-px.png?fit=1920%2C750&ssl=1"
alt="Site logo"
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
height: "90%",
aspectRatio: "311 / 224",
objectFit: "contain",
}}
/>
</div>
{/* Mobile: slim loading header while JS initializes */}
<div
className="lg:hidden"
style={{
position: "sticky",
top: 0,
zIndex: 200,
height: 50,
background: "linear-gradient(135deg, #000050 0%, #000080 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src="https://i0.wp.com/jourknowsph.com/wp-content/uploads/2026/02/untitled-1920-x-900-px-1920-x-750-px.png?fit=1920%2C750&ssl=1"
alt="JourKnows"
style={{ height: 30, width: "auto", objectFit: "contain" }}
/>
</div>
</>
);
}
// Do not short-circuit the entire component before hydration.
// The main navbar/header should SSR-render and remain available on the
// initial paint, including for desktop users and users with slow or
// disabled JavaScript. If a mobile-only loading shell is needed, render it
// inside the main returned JSX instead of returning early here.

Copilot uses AI. Check for mistakes.
Comment thread src/styles/base.css
Comment on lines +171 to +174
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@keyframes slideInRight is added here but isn’t referenced anywhere in the codebase (no usages found). If it’s not needed for this PR, consider removing it to keep the global stylesheet minimal; otherwise, wire it up where intended.

Suggested change
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants