Restyle navigation with V-angled bars and Agora bolt feed button

Replace the smooth arc shapes shared by the mobile top bar, sub-header
tabs, and bottom nav with angled V polylines centralized in
ArcBackground. The top bar and sub-header now use flat rectangles, and
the bottom nav has a sharp V apex that cradles a centered Agora-bolt
Feed button. The bottom nav row layout changes from
[Home, Search, Notifications, Profile] to
[Search, Communities, _apex_, Notifications, World] with smaller outer
items, and the apex links to the configured home/feed page with
scroll-to-top + invalidation on re-tap.

Also drop the redundant 'Feed' page header on the home feed and the
border under the compact ComposeBox so it blends with the tabs strip
below it.
This commit is contained in:
Chad Curtis
2026-05-14 00:38:43 -05:00
committed by lemon
parent 37b315f06f
commit 207794e714
7 changed files with 257 additions and 123 deletions
+10 -6
View File
@@ -6,11 +6,15 @@ export const ARC_OVERHANG_PX = 20;
/** Larger overhang for the upward arc (bottom nav) so the harsher curve isn't clipped. */
export const ARC_UP_OVERHANG_PX = 28;
/** SVG path for a downward arc (used by top bar and sub-header bar). */
const ARC_DOWN_PATH = 'M0,0 L100,0 L100,44 Q50,64 0,44 Z';
/** SVG path for a downward angled bar (used by top bar and sub-header bar).
* Bottom edge slopes from each corner down to a center apex, forming an
* inverted-V that points toward the content below. */
const ARC_DOWN_PATH = 'M0,0 L100,0 L100,34 L50,46 L0,34 Z';
/** SVG path for an upward arc (used by bottom nav). */
const ARC_UP_PATH = 'M0,30 Q50,0 100,30 L100,64 L0,64 Z';
/** SVG path for an upward angled bar (used by bottom nav).
* Top edge slopes from each corner up to a center apex, forming a V that
* points away from the content. */
const ARC_UP_PATH = 'M0,40 L50,16 L100,40 L100,64 L0,64 Z';
/** SVG path for a plain rectangle with no arc. */
const RECT_PATH = 'M0,0 L100,0 L100,64 L0,64 Z';
@@ -54,8 +58,8 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
>
<path d={path} className="fill-background/85" />
{variant === 'down' && <path d="M0,44 Q50,64 100,44" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
{variant === 'up' && <path d="M0,30 Q50,0 100,30" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
{variant === 'down' && <path d="M0,34 L50,46 L100,34" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
{variant === 'up' && <path d="M0,40 L50,16 L100,40" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
</svg>
);
}
+1 -1
View File
@@ -251,7 +251,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
/>
)}
{!hideCompose && <ComposeBox compact />}
{!hideCompose && <ComposeBox compact hideBorder />}
{/* Tabs (logged in) */}
{user && (
+119 -88
View File
@@ -1,14 +1,13 @@
import { useCallback, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Bell, Home, Search, User } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Bell, Earth, Search, Users } from 'lucide-react';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { cn } from '@/lib/utils';
import { selectionChanged } from '@/lib/haptics';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutSnapshot } from '@/contexts/LayoutContext';
import { getSidebarItem } from '@/lib/sidebarItems';
@@ -20,20 +19,51 @@ const hiddenStyle: React.CSSProperties = {
transform: `translateY(calc(100% + ${ARC_UP_OVERHANG_PX}px))`,
};
interface NavItemProps {
icon: React.ComponentType<{ className?: string }>;
label: string;
active: boolean;
badge?: boolean;
onClick?: (e: React.MouseEvent) => void;
to?: string;
/** 'sm' shrinks the slot (smaller flex basis + smaller icon/label) for outer items. */
size?: 'sm' | 'md';
}
/** A side item in the bottom nav row. */
function NavItem({ icon: Icon, label, active, badge, onClick, to, size = 'md' }: NavItemProps) {
const isSm = size === 'sm';
const className = cn(
'flex flex-col items-center justify-center gap-0.5 py-2 transition-colors min-w-0',
isSm ? 'flex-[0.7]' : 'flex-1',
active ? 'text-primary' : 'text-muted-foreground',
);
const inner = (
<>
<span className="relative">
<Icon className={isSm ? 'size-4' : 'size-5'} />
{badge && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className={cn('font-medium truncate', isSm ? 'text-[9px]' : 'text-[10px]')}>{label}</span>
</>
);
if (to) return <Link to={to} onClick={onClick} className={className}>{inner}</Link>;
return <button onClick={onClick} className={className}>{inner}</button>;
}
export function MobileBottomNav() {
const location = useLocation();
const queryClient = useQueryClient();
const { user, metadata } = useCurrentUser();
const { user } = useCurrentUser();
const hasUnread = useHasUnreadNotifications();
const { scrollContainer, noArcs } = useLayoutSnapshot();
const { hidden } = useScrollDirection(scrollContainer);
const profileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const { config } = useAppContext();
const homeItem = getSidebarItem(config.homePage);
const HomeIcon = homeItem?.icon ?? Home;
const homeLabel = homeItem?.label ?? 'Home';
const homePath = homeItem?.path;
const homePath = homeItem?.path ?? '/';
const [searchOpen, setSearchOpen] = useState(false);
@@ -43,11 +73,24 @@ export function MobileBottomNav() {
setSearchOpen((v) => !v);
}, []);
const handleFeedClick = useCallback((e: React.MouseEvent) => {
selectionChanged();
setSearchOpen(false);
if (location.pathname === '/' || location.pathname === homePath) {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
void queryClient.invalidateQueries({ queryKey: ['feed'] });
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
}
}, [location.pathname, homePath, queryClient]);
// Hide the nav when search sheet is open so it doesn't compete for space
const isHidden = hidden || searchOpen;
const displayName = metadata?.name || metadata?.display_name;
const isOnProfile = user && location.pathname === profileUrl;
const isOnFeed = location.pathname === '/' || location.pathname === homePath;
const isOnCommunities = location.pathname === '/communities' || location.pathname.startsWith('/communities/');
const isOnWorld = location.pathname === '/world' || location.pathname.startsWith('/world/');
const isOnNotifications = location.pathname === '/notifications';
return (
<>
@@ -63,91 +106,79 @@ export function MobileBottomNav() {
{/* Arc + items wrapper */}
<div className="relative">
<ArcBackground variant={noArcs ? 'rect' : 'up'} />
<div className="h-11 flex items-center relative">
<div className="h-12 flex items-end pb-0 relative translate-y-2">
{/* Home */}
<Link
to="/"
onClick={() => {
selectionChanged();
setSearchOpen(false);
// When already on the home page, scroll to top and refresh the feed
if (location.pathname === '/' || location.pathname === homePath) {
window.scrollTo({ top: 0, behavior: 'smooth' });
void queryClient.invalidateQueries({ queryKey: ['feed'] });
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
}
}}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
(location.pathname === '/' || location.pathname === homePath) ? 'text-primary' : 'text-muted-foreground',
)}
>
<HomeIcon className="size-5" />
<span className="text-[10px] font-medium">{homeLabel}</span>
</Link>
{/* Search */}
<NavItem
icon={Search}
label="Search"
active={searchOpen}
onClick={handleSearchClick}
size="sm"
/>
{/* Search */}
<button
onClick={handleSearchClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
searchOpen ? 'text-primary' : 'text-muted-foreground',
)}
>
<Search className="size-5" />
<span className="text-[10px] font-medium">Search</span>
</button>
{/* Notifications */}
{user && (
<Link
to="/notifications"
{/* Communities */}
<NavItem
icon={Users}
label="Communities"
active={isOnCommunities}
to="/communities"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
)}
>
<span className="relative">
<Bell className="size-5" />
{hasUnread && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className="text-[10px] font-medium">Notifications</span>
</Link>
)}
/>
{/* Profile */}
{user ? (
<Link
to={profileUrl}
{/* Center spacer — reserved for the apex Feed button */}
<div className="flex-[0.4]" aria-hidden="true" />
{/* Notifications */}
{user ? (
<NavItem
icon={Bell}
label="Notifications"
active={isOnNotifications}
badge={hasUnread}
to="/notifications"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
/>
) : (
<div className="flex-1" aria-hidden="true" />
)}
{/* World */}
<NavItem
icon={Earth}
label="World"
active={isOnWorld}
to="/world"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
isOnProfile ? 'text-primary' : 'text-muted-foreground',
)}
>
<Avatar className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName?.[0]?.toUpperCase() || <User className="size-3" />}
</AvatarFallback>
</Avatar>
<span className="text-[10px] font-medium">Profile</span>
</Link>
) : (
<Link
to="/profile"
className="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors text-muted-foreground"
>
<User className="size-5" />
<span className="text-[10px] font-medium">Profile</span>
</Link>
)}
size="sm"
/>
</div>
{/* Apex Feed button — Agora bolt mark cradled in the V notch, with label below. */}
<Link
to={homePath}
onClick={handleFeedClick}
aria-label={homeItem?.label ?? 'Feed'}
className={cn(
'absolute left-1/2 -translate-x-1/2 z-10 -top-6',
'flex flex-col items-center gap-3',
'transition-transform hover:scale-105 active:scale-95',
)}
>
<AgoraBoltIcon
className={cn(
'size-16 drop-shadow-md',
isOnFeed && 'drop-shadow-[0_0_8px_hsl(var(--primary)/0.6)]',
)}
/>
<span className={cn(
'text-[10px] font-semibold leading-none',
isOnFeed ? 'text-primary' : 'text-foreground',
)}>
{homeItem?.label ?? 'Feed'}
</span>
</Link>
</div>
{/* Safe area fill — matches the arc's semi-transparent background */}
<div className="safe-area-bottom bg-background/85" />
+2 -2
View File
@@ -11,7 +11,7 @@ interface MobileTopBarProps {
hasSubHeader?: boolean;
}
export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps) {
export function MobileTopBar({ onAvatarClick, hasSubHeader: _hasSubHeader }: MobileTopBarProps) {
const location = useLocation();
const navHidden = useNavHidden();
@@ -34,7 +34,7 @@ export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps)
/>
{/* Relative wrapper so ArcBackground only covers the content area, not the safe-area padding above it. */}
<div className="relative">
<ArcBackground variant={hasSubHeader ? 'rect' : 'down'} />
<ArcBackground variant="rect" />
<div className="relative flex items-center px-3 h-10">
{/* Left: hamburger menu icon */}
<div className="flex items-center justify-center w-7 shrink-0">
+14 -17
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { ArcBackground } from '@/components/ArcBackground';
import { useNavHidden } from '@/contexts/LayoutContext';
import { SubHeaderBarContext } from '@/components/SubHeaderBarContext';
@@ -30,7 +30,7 @@ interface SubHeaderBarProps {
* Used by all tab bars (Feed, Search, Notifications, etc.) and the MobileTopBar
* fallback arc.
*/
export function SubHeaderBar({ children, className, innerClassName, noArc, pinned }: SubHeaderBarProps) {
export function SubHeaderBar({ children, className, innerClassName, noArc: _noArc, pinned }: SubHeaderBarProps) {
const [hover, setHover] = useState<HoverSlice | null>(null);
const [active, setActive] = useState<HoverSlice | null>(null);
const navHidden = useNavHidden();
@@ -125,38 +125,35 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
/>
)}
{/* Inner wrapper so ArcBackground covers only the tab area, not the safe-area padding above.
sidebar:pt-2 adds desktop top padding inside the arc rather than outside it. */}
<div className="relative sidebar:pt-2">
<ArcBackground variant={noArc ? 'rect' : 'down'} />
{/* Per-tab arc hover highlight: full-width arc, clipped to the hovered tab's x-slice */}
{hover && !noArc && (
{/* Inner wrapper holds the ArcBackground and tab content. */}
<div className="relative">
<ArcBackground variant="rect" />
{/* Per-tab hover highlight: a flat-bottomed slab clipped to the hovered tab's x-slice */}
{hover && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full pointer-events-none"
className="absolute top-0 left-0 w-full h-full pointer-events-none"
style={{
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
clipPath: `inset(0 calc(100% - ${hover.left + hover.width}px) 0 ${hover.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M0,0 L100,0 L100,44 Q50,64 0,44 Z" className="fill-secondary/40" />
<path d="M0,0 L100,0 L100,64 L0,64 Z" className="fill-secondary/40" />
</svg>
)}
{/* Active tab indicator: the arc's bottom edge as a stroke, clipped to the active tab's x-slice */}
{active && !noArc && (
{/* Active tab indicator: a flat underline along the bottom edge, clipped to the active tab's x-slice */}
{active && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full pointer-events-none"
className="absolute top-0 left-0 w-full h-full pointer-events-none"
style={{
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
clipPath: `inset(0 calc(100% - ${active.left + active.width}px) 0 ${active.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M100,44 Q50,64 0,44" fill="none" className="stroke-primary" strokeWidth="3" />
<path d="M0,62 L100,62" fill="none" className="stroke-primary" strokeWidth="3" vectorEffect="non-scaling-stroke" />
</svg>
)}
{/* Tab content sits above the SVG background */}
@@ -174,7 +171,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
)}
<div
ref={scrollRef}
className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}
className={cn('relative flex overflow-x-auto scrollbar-none py-1', innerClassName)}
>
{children}
</div>
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
/**
* Agora lightning-bolt icon — a stylized double-bolt mark in primary brand orange.
* The artwork uses fixed brand colors and gradients (it's a logo mark, not a
* monochrome icon), so it ignores `currentColor`. Pass `className` to size it
* (e.g. `size-6`).
*/
export const AgoraBoltIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 720 880"
fill="none"
className={className}
aria-hidden="true"
{...props}
>
<g filter="url(#agora_bolt_inner_shadow)">
<path
d="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z"
fill="url(#agora_bolt_grad_a)"
fillOpacity="0.9"
/>
<path
d="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z"
fill="#FF6600"
/>
<path
d="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z"
fill="url(#agora_bolt_grad_b)"
fillOpacity="0.9"
/>
<path
d="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z"
fill="#FF6600"
/>
</g>
<defs>
<filter
id="agora_bolt_inner_shadow"
x="0"
y="0"
width="720"
height="914"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="34" />
<feGaussianBlur stdDeviation="27" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"
/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="7" />
<feGaussianBlur stdDeviation="0.5" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"
/>
<feBlend mode="normal" in2="effect1_innerShadow" result="effect2_innerShadow" />
</filter>
<linearGradient
id="agora_bolt_grad_a"
x1="-19.0481"
y1="318.823"
x2="373.469"
y2="591.355"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
<linearGradient
id="agora_bolt_grad_b"
x1="346.531"
y1="288.645"
x2="739.048"
y2="561.177"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
</defs>
</svg>
),
);
AgoraBoltIcon.displayName = 'AgoraBoltIcon';
+1 -9
View File
@@ -1,7 +1,5 @@
import { useSeoMeta } from '@unhead/react';
import { Megaphone } from 'lucide-react';
import { Feed } from '@/components/Feed';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLayoutOptions } from '@/contexts/LayoutContext';
@@ -17,13 +15,7 @@ const Index = () => {
useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: !!user });
return (
<Feed
header={(
<PageHeader title="Feed" icon={<Megaphone className="size-5 text-primary" />} />
)}
/>
);
return <Feed />;
};
export default Index;