Improvements to the UI

This commit is contained in:
pricewowen
2026-05-24 08:49:37 -06:00
parent d35b4e57d9
commit ec1bfa3fbe
60 changed files with 669 additions and 391 deletions

View File

@@ -6,6 +6,7 @@ import { forwardRef } from "react";
import {
Home,
ListPlus,
Palette,
ShoppingBag,
Sparkles,
UserRound,
@@ -29,6 +30,7 @@ const gridColsClass = {
const navIcons = {
[routes.avatar]: Home,
[routes.customize]: Palette,
[routes.habits]: ListPlus,
[routes.dailyQuiz]: Sparkles,
[routes.shop]: ShoppingBag,

View File

@@ -1,5 +1,4 @@
import { AppPageHeader } from "@/components/layout/app-page-header";
import { AppTopBar } from "@/components/layout/app-top-bar";
import { cn } from "@/lib/utils";
type AppShellProps = {
@@ -27,8 +26,7 @@ export function AppShell({
centered = false,
}: AppShellProps) {
return (
<div className="relative flex min-h-dvh flex-col bg-background pt-topbar">
<AppTopBar />
<div className="relative flex flex-1 flex-col bg-background">
<main
className={cn(
"mx-auto flex w-full flex-1 flex-col px-4 py-6 lg:px-5",

View File

@@ -1,34 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { HabitBrand } from "@/components/layout/habit-brand";
import { routes } from "@/lib/routes";
import { cn } from "@/lib/utils";
type AppTopBarProps = {
className?: string;
};
export function AppTopBar({ className }: AppTopBarProps) {
const pathname = usePathname();
const isHome =
pathname === routes.home || pathname === routes.avatar;
return (
<header
className={cn(
"app-top-bar fixed inset-x-0 top-0 z-40 border-b-2 border-border bg-background/95 shadow-[var(--retro-shadow-sm)] backdrop-blur supports-[backdrop-filter]:bg-background/90",
className,
)}
style={{
height: "var(--app-topbar-height)",
paddingTop: "env(safe-area-inset-top, 0px)",
}}
>
<div className="mx-auto flex h-full max-w-6xl items-center px-4">
<HabitBrand aria-current={isHome ? "page" : undefined} />
</div>
</header>
);
}

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { forwardRef } from "react";
import { Home, LogIn, UserPlus } from "lucide-react";
import { routes } from "@/lib/routes";
@@ -13,12 +14,26 @@ const authNavItems = [
{ href: routes.signUp, label: "Sign up", icon: UserPlus },
] as const;
export function AuthBottomNav() {
export const AuthBottomNav = forwardRef<HTMLElement>(function AuthBottomNav(
_props,
ref,
) {
const pathname = usePathname();
return (
<nav className="fixed inset-x-0 bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto grid max-w-lg grid-cols-3 gap-1 px-1 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2">
<nav
ref={ref}
className="bottom-nav fixed inset-x-0 bottom-0 z-50 border-t-2 border-border bg-background shadow-[0_-4px_0_0_hsl(280_45%_28%/0.35)]"
style={{
bottom: "var(--app-viewport-bottom-offset, 0px)",
paddingBottom: "env(safe-area-inset-bottom, 0px)",
}}
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-secondary/40"
/>
<div className="mx-auto grid max-w-lg grid-cols-3 gap-1 px-2 pb-2 pt-2">
{authNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
@@ -28,18 +43,20 @@ export function AuthBottomNav() {
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center gap-1 rounded-lg px-1 py-2 font-pixel text-[8px] font-normal leading-tight transition-colors",
"bottom-nav-item flex flex-col items-center justify-center gap-1.5 overflow-hidden border-2 px-1 py-2 font-pixel text-[8px] leading-none transition-colors duration-75",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
? "border-secondary bg-primary text-primary-foreground shadow-[var(--retro-shadow-sm)]"
: "border-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
<Icon className="bottom-nav-item-icon h-5 w-5 shrink-0" strokeWidth={2.5} />
<span className="bottom-nav-item-label max-w-full truncate uppercase tracking-wide">
{item.label}
</span>
</Link>
);
})}
</div>
</nav>
);
}
});

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { AppNav } from "@/components/layout/app-nav";
import { AppDataPrefetcher } from "@/components/layout/app-data-prefetcher";
import { AuthBottomNav } from "@/components/layout/auth-bottom-nav";
import {
NavOffsetProvider,
useNavOffsetRef,
@@ -12,14 +13,20 @@ import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getOnboardingStatusClient } from "@/lib/onboarding-status";
import { createClient } from "@/lib/supabase/client";
type NavKind = "none" | "auth" | "app";
function MeasuredAppNav() {
const navRef = useNavOffsetRef();
return <AppNav ref={navRef} />;
}
function MeasuredAuthNav() {
const navRef = useNavOffsetRef();
return <AuthBottomNav ref={navRef} />;
}
export function GlobalBottomNav() {
const [showNav, setShowNav] = useState(false);
const [navKind, setNavKind] = useState<NavKind>("none");
useEffect(() => {
const supabase = createClient();
@@ -30,12 +37,12 @@ export function GlobalBottomNav() {
} = await supabase.auth.getUser();
if (!user) {
setShowNav(false);
setNavKind("auth");
return;
}
const status = await getOnboardingStatusClient();
setShowNav(status?.appComplete === true);
setNavKind(status?.appComplete === true ? "app" : "none");
}
void syncNavState();
@@ -55,9 +62,10 @@ export function GlobalBottomNav() {
}, []);
return (
<NavOffsetProvider enabled={showNav}>
<AppDataPrefetcher enabled={showNav} />
{showNav ? <MeasuredAppNav /> : null}
<NavOffsetProvider enabled={navKind !== "none"}>
<AppDataPrefetcher enabled={navKind === "app"} />
{navKind === "app" ? <MeasuredAppNav /> : null}
{navKind === "auth" ? <MeasuredAuthNav /> : null}
</NavOffsetProvider>
);
}

View File

@@ -3,20 +3,33 @@
import Link from "next/link";
import { useState } from "react";
import { HABIT_LOGO_SRC } from "@/lib/brand";
import { routes } from "@/lib/routes";
import { cn } from "@/lib/utils";
const HABIT_LOGO_SRC = "/habit-logo.png";
const logoSizeClass = {
sm: "h-6 w-auto max-h-6",
md: "h-8 w-auto max-h-8",
/** Compact — leaves vertical padding inside the top bar */
nav: "h-7 w-auto max-h-7 sm:h-8 sm:max-h-8",
lg: "h-12 w-auto max-h-12 sm:h-14",
hero: "h-16 w-auto max-h-16 sm:h-20 md:h-24",
} as const;
type HabitBrandProps = {
className?: string;
href?: string;
size?: keyof typeof logoSizeClass;
/** Show retro frame around the logo (top bar / site header). */
framed?: boolean;
"aria-current"?: "page" | undefined;
};
export function HabitBrand({
className,
href = routes.home,
size = "sm",
framed = true,
"aria-current": ariaCurrent,
}: HabitBrandProps) {
const [useText, setUseText] = useState(false);
@@ -24,10 +37,13 @@ export function HabitBrand({
return (
<Link
href={href}
aria-label="Go to home"
aria-label="HaBit home"
aria-current={ariaCurrent}
className={cn(
"group flex shrink-0 items-center justify-center border-2 border-border bg-card/80 px-3 py-1.5 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",
"group inline-flex shrink-0 items-center justify-center transition-transform hover:-translate-y-0.5",
framed &&
"border-2 border-border bg-card/80 px-3 py-1.5 shadow-[var(--retro-shadow-sm)] hover:border-primary/60",
!framed && "rounded-sm hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
@@ -35,14 +51,16 @@ export function HabitBrand({
// eslint-disable-next-line @next/next/no-img-element
<img
src={HABIT_LOGO_SRC}
alt=""
className="size-6 image-pixelated"
alt="HaBit"
className={cn("image-pixelated object-contain", logoSizeClass[size])}
onError={() => setUseText(true)}
/>
) : (
<span
className="font-pixel select-none text-[10px] uppercase tracking-widest text-primary"
aria-hidden="true"
className={cn(
"font-pixel select-none uppercase tracking-widest text-primary",
size === "hero" ? "text-base sm:text-lg" : "text-[10px]",
)}
>
HaBit
</span>

View File

@@ -0,0 +1,62 @@
"use client";
import { Coins } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getCoins } from "@/lib/avatar-progression-storage";
import { createClient } from "@/lib/supabase/client";
export function HeaderCoinBalance() {
const [coins, setCoins] = useState<number | null>(null);
const [isSignedIn, setIsSignedIn] = useState(false);
const refresh = useCallback(async () => {
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
setIsSignedIn(false);
setCoins(null);
return;
}
setIsSignedIn(true);
try {
setCoins(await getCoins());
} catch {
setCoins(0);
}
}, []);
useEffect(() => {
void refresh();
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
};
}, [refresh]);
if (!isSignedIn) {
return null;
}
return (
<div
className="inline-flex h-10 items-center justify-center gap-2 border-2 border-primary/35 bg-primary/10 px-4 font-pixel text-[10px] font-normal uppercase tracking-wide text-primary shadow-[var(--retro-shadow-sm)]"
aria-label={
coins === null ? "Loading point balance" : `${coins} points available`
}
>
<Coins aria-hidden="true" className="size-4 shrink-0" />
<span className="tabular-nums leading-none">
{coins === null ? "…" : coins}
</span>
<span className="leading-none text-primary/80">pts</span>
</div>
);
}

View File

@@ -1,45 +1 @@
import Link from "next/link";
import { routes } from "@/lib/routes";
import { cn } from "@/lib/utils";
type HomeLogoLinkProps = {
className?: string;
"aria-current"?: "page" | undefined;
};
export function HomeLogoLink({
className,
"aria-current": ariaCurrent,
}: HomeLogoLinkProps) {
return (
<Link
href={routes.avatar}
aria-label="Go to home"
aria-current={ariaCurrent}
className={cn(
"group flex shrink-0 items-center justify-center border-2 border-border bg-card/80 p-2 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",
className,
)}
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="size-8"
style={{ imageRendering: "pixelated" }}
>
<rect x="5" y="2" width="6" height="5" fill="#fdba74" stroke="#1e1b4b" strokeWidth="1" />
<rect x="4" y="3" width="1" height="1" fill="#1e1b4b" />
<rect x="11" y="3" width="1" height="1" fill="#1e1b4b" />
<rect x="6" y="5" width="1" height="1" fill="#1e1b4b" />
<rect x="9" y="5" width="1" height="1" fill="#1e1b4b" />
<rect x="7" y="6" width="2" height="1" fill="#f472b6" />
<rect x="4" y="7" width="8" height="5" fill="#6366f1" stroke="#1e1b4b" strokeWidth="1" />
<rect x="5" y="12" width="2" height="3" fill="#334155" />
<rect x="9" y="12" width="2" height="3" fill="#334155" />
<rect x="2" y="4" width="1" height="1" fill="#f472b6" className="opacity-0 group-hover:opacity-100" />
<rect x="13" y="4" width="1" height="1" fill="#f472b6" className="opacity-0 group-hover:opacity-100" />
</svg>
</Link>
);
}
export { HabitBrand as HomeLogoLink } from "@/components/layout/habit-brand";

View File

@@ -11,7 +11,7 @@ export async function LandingHeroActions() {
return (
<div className="flex flex-wrap gap-3">
<Button asChild size="lg">
<Link href={routes.avatar}>Go to your pet</Link>
<Link href={routes.avatar}>Go to your bit</Link>
</Button>
</div>
);

View File

@@ -1,38 +1,38 @@
import Link from "next/link";
import { Suspense } from "react";
import { AuthButton } from "@/components/auth-button";
import { EnvVarWarning } from "@/components/env-var-warning";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { HabitBrand } from "@/components/layout/habit-brand";
import { HeaderCoinBalance } from "@/components/layout/header-coin-balance";
import { routes } from "@/lib/routes";
import { hasEnvVars } from "@/lib/utils";
export function SiteHeader() {
return (
<header className="w-full border-b border-b-foreground/10">
<div className="mx-auto flex h-16 w-full max-w-5xl items-center justify-between px-5 text-sm">
<Link href={routes.home} className="font-pixel text-xs tracking-tight">
HaBit
</Link>
{!hasEnvVars ? (
<EnvVarWarning />
) : (
<Suspense>
<AuthButton />
</Suspense>
)}
<header
className="fixed inset-x-0 top-0 z-40 border-b border-border/50 bg-background"
style={{
height: "var(--app-topbar-height)",
paddingTop: "env(safe-area-inset-top, 0px)",
}}
>
<div className="mx-auto flex h-full w-full max-w-5xl items-center justify-between gap-4 px-5">
<HabitBrand href={routes.home} size="nav" framed={false} />
<div className="flex shrink-0 items-center gap-2 sm:gap-3">
{!hasEnvVars ? (
<EnvVarWarning />
) : (
<>
<Suspense fallback={null}>
<HeaderCoinBalance />
</Suspense>
<Suspense fallback={null}>
<AuthButton />
</Suspense>
</>
)}
</div>
</div>
</header>
);
}
export function SiteFooter() {
return (
<footer className="w-full border-t py-8 text-center text-xs text-muted-foreground">
<div className="mx-auto flex max-w-5xl flex-col items-center gap-4 px-5">
<p>Build habits. Care for your pet. Feel better every day.</p>
<ThemeSwitcher />
</div>
</footer>
);
}