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

@@ -11,18 +11,13 @@ export async function AuthButton() {
const user = data?.claims;
return user ? (
<div className="flex items-center gap-3">
<Button asChild size="sm" variant="outline">
<Link href={routes.avatar}>Open app</Link>
</Button>
<LogoutButton />
</div>
<LogoutButton />
) : (
<div className="flex gap-2">
<Button asChild size="sm" variant={"outline"}>
<Button asChild size="sm" variant="outline">
<Link href={routes.login}>Sign in</Link>
</Button>
<Button asChild size="sm" variant={"default"}>
<Button asChild size="sm" variant="default">
<Link href={routes.signUp}>Sign up</Link>
</Button>
</div>

View File

@@ -1,5 +1,4 @@
import { AppPageHeader } from "@/components/layout/app-page-header";
import { AppTopBar } from "@/components/layout/app-top-bar";
type AvatarCustomizeLayoutProps = {
eyebrow?: string;
@@ -15,9 +14,7 @@ export function AvatarCustomizeLayout({
children,
}: AvatarCustomizeLayoutProps) {
return (
<div className="relative min-h-dvh bg-background pt-topbar">
<AppTopBar />
<div className="relative min-h-dvh flex-1 bg-background">
<main className="mx-auto max-w-lg px-4 py-6 lg:max-w-6xl lg:px-5">
<AppPageHeader
eyebrow={eyebrow}

View File

@@ -41,7 +41,7 @@ export function AvatarPageContent() {
if (!customization) {
return (
<p className="text-xs text-muted-foreground">Loading your pet...</p>
<p className="text-xs text-muted-foreground">Loading your bit...</p>
);
}

View File

@@ -61,7 +61,7 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
<Card>
<CardContent className="pt-6">
<p className="text-center text-sm text-muted-foreground">
Loading your pet...
Loading your bit...
</p>
</CardContent>
</Card>
@@ -100,11 +100,11 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
<p className="text-center text-sm text-muted-foreground">
{hasCheckedInToday
? completedTaskCount > 0
? `Your pet reacts to today's quiz and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.`
: "Your pet is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health."
? `Your bit reacts to today's quiz and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.`
: "Your bit is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health."
: completedTaskCount > 0
? "Complete today's quiz for a full wellness read, then keep completing habits to boost your pet."
: "Complete today's quiz to update your pet's mood, energy, and health."}
? "Complete today's quiz for a full wellness read, then keep completing habits to boost your bit."
: "Complete today's quiz to update your bit's mood, energy, and health."}
</p>
{!hasCheckedInToday ? (
<Button asChild className="w-full">

View File

@@ -26,6 +26,7 @@ export function CustomizePageContent() {
const [shopItems, setShopItems] = useState<ShopItemRecord[]>([]);
const [coins, setCoins] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [editorKey, setEditorKey] = useState(0);
const loadCustomization = useCallback(async () => {
try {
@@ -46,6 +47,7 @@ export function CustomizePageContent() {
setEquippedItems(inventory.equippedItems);
setShopItems(inventory.items);
setCoins(inventory.coins);
setEditorKey((current) => current + 1);
} catch (error) {
setLoadError(
error instanceof Error ? error.message : "Could not load your avatar.",
@@ -93,7 +95,7 @@ export function CustomizePageContent() {
return (
<CharacterCreator
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${customization.roomBackground}-${avatarName}-${ownedVariantIds.join(",")}`}
key={editorKey}
initialCustomization={{
...customization,
name: avatarName,

View File

@@ -29,7 +29,7 @@ export function PixelAvatar({
return (
<div
className={cn("flex flex-col items-center gap-3", className)}
aria-label={`Pixel pet avatar, mood ${mood}`}
aria-label={`Bit avatar, mood ${mood}`}
>
<div
className={cn(

View File

@@ -14,7 +14,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/toast-provider";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import {
DEFAULT_AVATAR_NAME,
type AvatarCustomization,
} from "@/lib/avatar-customization-storage";
import { clampHsl } from "@/lib/character/color-utils";
import {
COLOR_PRESETS,
@@ -47,7 +50,7 @@ import {
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</h3>
);
@@ -84,7 +87,7 @@ export function CharacterCreator({
}: CharacterCreatorProps) {
const { toast } = useToast();
const [internalName, setInternalName] = useState(
initialCustomization?.name ?? "Pixel Me",
initialCustomization?.name ?? DEFAULT_AVATAR_NAME,
);
const name = controlledName ?? internalName;
@@ -355,12 +358,12 @@ export function CharacterCreator({
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
{showNameField ? (
<div className="mb-4 grid max-w-md gap-2 border-b-2 border-border/60 pb-4">
<Label htmlFor="avatar-name">Pet name</Label>
<Label htmlFor="avatar-name">Bit name</Label>
<Input
id="avatar-name"
value={name}
maxLength={32}
placeholder="Name your pet"
placeholder="Name your bit"
onChange={(event) => setName(event.target.value)}
/>
</div>
@@ -368,8 +371,8 @@ export function CharacterCreator({
<CharacterLayerTabs activeTabId={activeTabId} onTabChange={handleTabChange} />
<div className="customize-body mt-4 grid items-stretch gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
<div className="flex min-h-full min-w-0 flex-col">
<div className="customize-body mt-4 grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
<div className="flex min-w-0 flex-col">
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
<ParallaxRoomBackground roomId={displayCustomization.roomBackground} />
<div className="relative z-10 flex h-full items-center justify-center">
@@ -386,7 +389,7 @@ export function CharacterCreator({
</div>
</div>
<div className="flex min-h-full min-w-0 flex-col gap-5 border-t-2 border-border/60 pt-4 lg:border-l-2 lg:border-t-0 lg:pl-5 lg:pt-0">
<div className="flex min-w-0 flex-col gap-5 border-t-2 border-border/60 pt-4 lg:border-l-2 lg:border-t-0 lg:pl-5 lg:pt-0">
{showStyleSection ? (
<section className="grid shrink-0 gap-3">
<SectionLabel>Style</SectionLabel>
@@ -437,7 +440,7 @@ export function CharacterCreator({
) : null}
{onSave ? (
<div className="mt-auto grid gap-2 pt-1">
<div className="grid gap-2 pt-2">
<Button
id="avatar-customization-save"
type="button"

View File

@@ -19,7 +19,7 @@ export function CharacterLayerTabs({
<div
role="tablist"
aria-label="Character customization"
className="grid grid-cols-7 gap-0.5 border-2 border-border bg-muted/30 p-0.5"
className="grid grid-cols-4 gap-1 border-2 border-border bg-muted/30 p-1 sm:grid-cols-7"
>
{CHARACTER_CUSTOMIZATION_TABS.map((tab) => (
<button
@@ -29,7 +29,7 @@ export function CharacterLayerTabs({
aria-selected={activeTabId === tab.id}
onClick={() => onTabChange(tab.id)}
className={cn(
"min-w-0 truncate border-2 px-0.5 py-1.5 text-[7px] font-medium uppercase leading-tight tracking-wide transition-colors sm:px-1 sm:text-[8px]",
"min-w-0 border-2 px-1 py-2 text-[10px] font-semibold uppercase leading-tight tracking-wide transition-colors sm:px-1.5 sm:text-xs",
activeTabId === tab.id
? "border-secondary bg-background text-foreground shadow-[var(--retro-shadow-sm)]"
: "border-transparent text-muted-foreground hover:border-border/60 hover:text-foreground",

View File

@@ -161,12 +161,12 @@ export function DailyQuizForm() {
</p>
<p className="text-sm text-muted-foreground">
Your answers are saved below. Come back tomorrow for your next
check-in. How you did today affects your pet. Spend time with{" "}
check-in. How you did today affects your bit. Spend time with{" "}
<Link
href={routes.avatar}
className="font-medium text-primary underline-offset-2 hover:underline"
>
your pet
your bit
</Link>{" "}
whenever you want to see how they are doing.
</p>

View File

@@ -20,11 +20,9 @@ function LandingPetPreviewSkeleton() {
aria-hidden="true"
className="tamagotchi-shell w-full max-w-sm overflow-hidden p-3 sm:p-4"
>
<div className="mb-3 space-y-2 border-b-2 border-border/60 pb-3">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-2 w-16 animate-pulse rounded bg-muted" />
<div className="tamagotchi-lcd tamagotchi-lcd-landing relative animate-pulse bg-muted/40">
<div className="absolute right-2 top-2 z-20 h-4 w-20 rounded bg-muted" />
</div>
<div className="tamagotchi-lcd tamagotchi-lcd-landing animate-pulse bg-muted/40" />
</div>
);
}
@@ -130,7 +128,7 @@ export function LandingPetPreview() {
<PetHabitat
customization={customization}
className="w-full max-w-sm"
showStyleLink={false}
nameInLcd
petScale={petScale}
lcdClassName="tamagotchi-lcd-landing"
lcdRef={lcdRef}
@@ -141,7 +139,7 @@ export function LandingPetPreview() {
return (
<Link
href={routes.avatar}
aria-label="Go to your pet"
aria-label="Go to your bit"
className={cn(
"block w-full max-w-sm rounded-sm transition-transform",
"hover:scale-[1.01] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",

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>
);
}

View File

@@ -30,7 +30,7 @@ export function OnboardingCustomizeForm() {
},
{ completeOnboarding: true },
);
toast("Avatar confirmed. Welcome to Habit Pet!", "success");
toast("Avatar confirmed. Welcome to HaBit!", "success");
router.push(routes.avatar);
router.refresh();
} catch (saveError) {
@@ -49,8 +49,8 @@ export function OnboardingCustomizeForm() {
return (
<AvatarCustomizeLayout
eyebrow="Step 2 of 2"
title="Create your Habit Pet"
description="Customize your pet's look, give them a name, and confirm before entering the app."
title="Create your bit"
description="Customize your digital self, give them a name, and confirm before entering the app."
>
<CharacterCreator
initialCustomization={defaultAvatarCustomization}

View File

@@ -54,10 +54,10 @@ export function OnboardingQuizForm() {
Step 1 of 2
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">
Welcome to Habit Pet
Welcome to HaBit
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Choose a focus area, then design your pet in the next step.
Choose a focus area, then design your bit in the next step.
</p>
</div>

View File

@@ -3,7 +3,9 @@
import { useEffect, useState } from "react";
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
import { PetEmotionBubble } from "@/components/pet/pet-emotion-bubble";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import type { AvatarMood } from "@/lib/avatar-state";
import {
PET_WANDER_BOUNDS,
PET_WANDER_START,
@@ -14,6 +16,10 @@ type AnimatedPetSpriteProps = {
customization: AvatarCustomization;
className?: string;
petScale?: number;
interactive?: boolean;
onInteract?: () => void;
emotionVisible?: boolean;
mood?: AvatarMood;
};
type WanderPosition = {
@@ -54,6 +60,10 @@ export function AnimatedPetSprite({
customization,
className,
petScale = 7,
interactive = false,
onInteract,
emotionVisible = false,
mood = "neutral",
}: AnimatedPetSpriteProps) {
const [position, setPosition] = useState<WanderPosition>({
x: PET_WANDER_START.x,
@@ -80,16 +90,45 @@ export function AnimatedPetSprite({
};
}, []);
const handleActivate = () => {
if (!interactive) {
return;
}
onInteract?.();
};
return (
<div
className={cn("pointer-events-none absolute z-10", className)}
className={cn(
"absolute z-10",
interactive
? "pointer-events-auto cursor-pointer"
: "pointer-events-none",
className,
)}
style={{
left: `${position.x}%`,
top: `${position.y}%`,
transform: "translate(-50%, -100%)",
transition: "left 1.8s steps(10), top 1.8s steps(10)",
}}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
aria-label={interactive ? "Check how your bit is feeling" : undefined}
onClick={(event) => {
event.stopPropagation();
handleActivate();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
handleActivate();
}
}}
>
<PetEmotionBubble mood={mood} visible={emotionVisible} />
<div
style={{
transform: position.facing === "left" ? "scaleX(-1)" : undefined,

View File

@@ -0,0 +1,40 @@
"use client";
import type { AvatarMood } from "@/lib/avatar-state";
import { getPetStatusImagePath } from "@/lib/pet-status";
import { cn } from "@/lib/utils";
type PetEmotionBubbleProps = {
mood: AvatarMood;
visible: boolean;
className?: string;
};
export function PetEmotionBubble({
mood,
visible,
className,
}: PetEmotionBubbleProps) {
if (!visible) {
return null;
}
return (
<div
className={cn(
"pointer-events-none absolute bottom-full left-1/2 z-20 mb-1 w-[clamp(3.5rem,18vw,5.5rem)] -translate-x-1/2",
className,
)}
aria-hidden
>
<div className="animate-emotion-pop">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPetStatusImagePath(mood)}
alt=""
className="h-auto w-full object-contain image-pixelated"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import type { AvatarMood } from "@/lib/avatar-state";
import { getPetEmotionMessage } from "@/lib/pet-status";
import { cn } from "@/lib/utils";
type PetEmotionCaptionProps = {
mood: AvatarMood;
visible: boolean;
className?: string;
};
export function PetEmotionCaption({
mood,
visible,
className,
}: PetEmotionCaptionProps) {
if (!visible) {
return null;
}
return (
<div
className={cn(
"pointer-events-none absolute inset-x-2 bottom-2 z-30 flex justify-center",
"animate-emotion-pop",
className,
)}
role="status"
aria-live="polite"
>
<p className="lcd-readable-label max-w-full">{getPetEmotionMessage(mood)}</p>
</div>
);
}

View File

@@ -1,17 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatedPetSprite } from "@/components/pet/animated-pet-sprite";
import { PetEmotionCaption } from "@/components/pet/pet-emotion-caption";
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
import { PetStyleLink } from "@/components/pet/pet-style-link";
import { PixelGauge } from "@/components/pet/pixel-gauge";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import {
defaultAvatarCondition,
type AvatarCondition,
} from "@/lib/avatar-state";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getAvatarConditionForToday } from "@/lib/daily-quiz-storage";
import { PET_EMOTION_DISPLAY_MS } from "@/lib/pet-status";
import {
normalizeRoomBackgroundId,
type RoomBackgroundId,
@@ -22,23 +24,18 @@ type PetHabitatProps = {
customization: AvatarCustomization;
className?: string;
fillViewport?: boolean;
showStyleLink?: boolean;
/** Bit name in the LCD top-right (landing preview). */
nameInLcd?: boolean;
petScale?: number;
lcdClassName?: string;
lcdRef?: React.Ref<HTMLDivElement>;
};
const MOOD_LABELS = {
happy: "HAPPY",
neutral: "OKAY",
tired: "SLEEPY",
} as const;
export function PetHabitat({
customization,
className,
fillViewport = false,
showStyleLink = true,
nameInLcd = false,
petScale,
lcdClassName: lcdClassNameOverride,
lcdRef,
@@ -47,16 +44,51 @@ export function PetHabitat({
defaultAvatarCondition,
);
const [isReady, setIsReady] = useState(false);
const [emotionVisible, setEmotionVisible] = useState(false);
const emotionTimeoutRef = useRef<number | null>(null);
const roomId = normalizeRoomBackgroundId(customization.roomBackground);
const interactive = fillViewport;
const clearEmotionTimeout = useCallback(() => {
if (emotionTimeoutRef.current !== null) {
window.clearTimeout(emotionTimeoutRef.current);
emotionTimeoutRef.current = null;
}
}, []);
const showEmotionBriefly = useCallback(() => {
clearEmotionTimeout();
setEmotionVisible(true);
emotionTimeoutRef.current = window.setTimeout(() => {
setEmotionVisible(false);
emotionTimeoutRef.current = null;
}, PET_EMOTION_DISPLAY_MS);
}, [clearEmotionTimeout]);
const loadStatus = useCallback(async () => {
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
setIsReady(true);
}, []);
useEffect(() => {
async function loadStatus() {
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
setIsReady(true);
}
void loadStatus();
}, []);
return () => {
clearEmotionTimeout();
};
}, [loadStatus, clearEmotionTimeout]);
useEffect(() => {
const handleDataUpdated = () => {
void loadStatus();
};
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
};
}, [loadStatus]);
const shellClassName = cn(
"tamagotchi-shell overflow-hidden p-3 sm:p-4",
@@ -71,11 +103,15 @@ export function PetHabitat({
: (lcdClassNameOverride ?? "tamagotchi-lcd-pet-match"),
);
const gaugeOverlaySize = fillViewport ? "responsive" : "md";
const showNameAboveShell = !fillViewport && !nameInLcd;
const showNameInLcdOverlay = fillViewport || nameInLcd;
if (!isReady) {
return (
<div className={shellClassName}>
<p className="text-center text-[10px] text-muted-foreground">
Waking up pet...
Waking up your bit...
</p>
</div>
);
@@ -83,24 +119,48 @@ export function PetHabitat({
return (
<div className={shellClassName}>
<div className="mb-3 shrink-0 border-b-2 border-border/60 pb-3">
<p className="text-sm text-foreground">{customization.name}</p>
<p className="mt-1 text-[9px] uppercase tracking-[0.25em] text-secondary">
{MOOD_LABELS[condition.mood]}
</p>
</div>
{showNameAboveShell ? (
<div className="mb-3 shrink-0 border-b-2 border-border/60 pb-3">
<p className="text-sm text-foreground">{customization.name}</p>
</div>
) : null}
<div ref={lcdRef} className={lcdClassName}>
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
<AnimatedPetSprite customization={customization} petScale={petScale} />
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1.5">
<PixelGauge type="heart" value={condition.health} variant="overlay" />
<PixelGauge type="energy" value={condition.energy} variant="overlay" />
<AnimatedPetSprite
customization={customization}
petScale={petScale}
interactive={interactive}
emotionVisible={emotionVisible}
mood={condition.mood}
onInteract={showEmotionBriefly}
/>
<PetEmotionCaption mood={condition.mood} visible={emotionVisible} />
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1 sm:gap-1.5">
<PixelGauge
type="heart"
value={condition.health}
variant="overlay"
overlaySize={gaugeOverlaySize}
/>
<PixelGauge
type="energy"
value={condition.energy}
variant="overlay"
overlaySize={gaugeOverlaySize}
/>
</div>
{showStyleLink ? (
<div className="absolute right-2 top-2 z-20">
<PetStyleLink />
</div>
{showNameInLcdOverlay ? (
<p
className={cn(
"pointer-events-none absolute right-2 top-2 z-20 max-w-[52%] truncate text-right lcd-readable-name",
fillViewport
? "text-sm sm:text-base md:text-lg"
: "lcd-readable-name-landing",
)}
>
{customization.name}
</p>
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
</div>

View File

@@ -12,7 +12,7 @@ export function PetStyleLink({ className }: PetStyleLinkProps) {
return (
<Link
href={routes.customize}
aria-label="Customize pet style"
aria-label="Customize bit style"
title="Style"
className={cn(
"flex shrink-0 items-center justify-center border-2 border-border bg-card/90 p-2 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",

View File

@@ -11,6 +11,8 @@ type PixelGaugeProps = {
max?: number;
className?: string;
variant?: "default" | "overlay";
/** Overlay icon size. Use "responsive" on the bit tab LCD. */
overlaySize?: "md" | "lg" | "responsive";
};
export function PixelGauge({
@@ -19,6 +21,7 @@ export function PixelGauge({
max = WELLNESS_SCALE_MAX,
className,
variant = "default",
overlaySize = "md",
}: PixelGaugeProps) {
const filled = Math.max(0, Math.min(max, Math.round(value)));
const Icon = type === "heart" ? Heart : Zap;
@@ -29,6 +32,12 @@ export function PixelGauge({
: "fill-amber-300 text-amber-300";
const emptyClass = "text-muted-foreground/35";
const isOverlay = variant === "overlay";
const overlayIconClass =
overlaySize === "responsive"
? "h-[clamp(1.125rem,3.6vw,3rem)] w-[clamp(1.125rem,3.6vw,3rem)]"
: overlaySize === "lg"
? "size-12"
: "size-6";
return (
<div
@@ -44,13 +53,22 @@ export function PixelGauge({
{label}
</span>
) : null}
<div className={cn("flex items-center", isOverlay ? "gap-1" : "gap-0.5")}>
<div
className={cn(
"flex items-center",
isOverlay
? overlaySize === "responsive"
? "gap-0.5 sm:gap-1"
: "gap-1"
: "gap-0.5",
)}
>
{Array.from({ length: max }, (_, index) => (
<Icon
key={`${type}-${index}`}
className={cn(
"shrink-0 stroke-[2.5]",
isOverlay ? "size-6" : "size-4",
isOverlay ? overlayIconClass : "size-4",
index < filled ? activeClass : emptyClass,
)}
aria-hidden

View File

@@ -32,7 +32,7 @@ export function RoomBackgroundPicker({
Room background
</h3>
<p className="mt-1 text-[9px] text-muted-foreground">
Pick the scene behind your pet. Premium rooms unlock in the shop.
Pick the scene behind your bit. Premium rooms unlock in the shop.
</p>
</div>
) : (

View File

@@ -266,7 +266,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<CardHeader>
<CardTitle className="text-base">Focus topic preference</CardTitle>
<CardDescription>
Choose the one area you want your pet and daily tasks to focus on.
Choose the one area you want your bit and daily tasks to focus on.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
@@ -288,7 +288,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<Card>
<CardHeader>
<CardTitle className="text-base">Avatar preferences</CardTitle>
<CardDescription>Personalize your pet&apos;s vibe.</CardDescription>
<CardDescription>Personalize your bit&apos;s vibe.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{avatarVibeOptions.map((vibe) => (
@@ -369,7 +369,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
/>
</div>
<p className="text-xs text-muted-foreground">
In-app reminders appear inside Habit Pet while a tab is open.
In-app reminders appear inside HaBit while a tab is open.
Browser (OS) reminders use the Web Notifications API and may appear
in your system notification center. Reminders fire once per day at
your chosen local time.

View File

@@ -59,7 +59,7 @@ export function InAppReminderBanner() {
router.push(reminder.targetUrl);
}}
>
Open Pet tab
Open Bit tab
</Button>
<Button
type="button"

View File

@@ -1,9 +1,9 @@
"use client";
import { Coins } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TintedSpriteIcon } from "@/components/character/tinted-sprite-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/components/ui/toast-provider";
@@ -24,9 +24,47 @@ import {
type ShopItemRecord,
type ShopLayerId,
} from "@/lib/shop-catalog";
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
import { cn } from "@/lib/utils";
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
function ShopItemPriceBadge({
price,
owned,
equipped,
}: {
price: number;
owned: boolean;
equipped?: boolean;
}) {
const boxClassName =
"inline-flex shrink-0 items-center justify-center gap-2.5 border-2 px-5 font-pixel text-[13px] font-normal uppercase tracking-wide leading-none shadow-[var(--retro-shadow-sm)] h-12";
if (owned) {
return (
<div
className={cn(
boxClassName,
"border-border/60 bg-muted/30 text-muted-foreground",
)}
>
{equipped ? "Equipped" : "Owned"}
</div>
);
}
return (
<div
className={cn(
boxClassName,
"border-primary/35 bg-primary/10 text-primary",
)}
>
<Coins aria-hidden="true" className="size-5 shrink-0" />
<span className="tabular-nums">{price}</span>
<span className="text-primary/80">pts</span>
</div>
);
}
const SHOP_LAYER_ORDER: ShopLayerId[] = [
"head",
@@ -94,13 +132,15 @@ function ShopItemCard({
)}
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-xs leading-snug">{item.name}</p>
<Badge className="shrink-0 px-1.5 py-0.5 text-[8px]">
{item.price} pts
</Badge>
</div>
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
<p className="min-w-0 pt-1 text-sm font-medium leading-snug">
{item.name}
</p>
<ShopItemPriceBadge
price={item.price}
owned={owned}
equipped={equipped}
/>
</div>
</div>
@@ -287,15 +327,6 @@ export function ShopContent() {
}}
/>
<div className="mb-5 flex items-center justify-between gap-3 border-2 border-border bg-muted/40 px-4 py-3 shadow-[var(--retro-shadow-sm)]">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
Your balance
</span>
<Badge variant="secondary" className="shrink-0">
{coins === null ? "..." : `${coins} points`}
</Badge>
</div>
{error ? <p className="mb-4 text-xs text-red-500">{error}</p> : null}
{!error && coins !== null && items.length === 0 ? (
@@ -312,7 +343,7 @@ export function ShopContent() {
<div className="grid gap-8">
{roomItems.length > 0 ? (
<section className="grid gap-3">
<h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Room backgrounds
</h3>
<div className={itemGridClassName}>
@@ -335,7 +366,7 @@ export function ShopContent() {
{styleGroups.map((group) => (
<section key={group.layerId} className="grid gap-3">
<h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</h3>
<div className={itemGridClassName}>

View File

@@ -1,6 +1,6 @@
"use client";
import { X } from "lucide-react";
import { Coins, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -87,7 +87,7 @@ export function ShopItemPreviewModal({
id="shop-preview-title"
className="text-[10px] uppercase tracking-wider text-muted-foreground"
>
Preview on your pet
Preview on your bit
</p>
<p className="mt-1 truncate text-sm">{item.name}</p>
</div>
@@ -119,13 +119,16 @@ export function ShopItemPreviewModal({
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
</div>
<p className="mt-3 text-center text-[10px] text-muted-foreground">
Preview only — nothing is purchased or equipped until you confirm.
This is a preview only. Nothing is purchased or equipped until you confirm.
</p>
<div className="mt-4 grid gap-2 border-t-2 border-border pt-4">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-muted-foreground">Price</span>
<span>{item.price} points</span>
<div className="flex items-center justify-end gap-1.5 border-2 border-primary/30 bg-primary/5 px-3 py-2.5">
<Coins aria-hidden="true" className="size-4 text-primary" />
<span className="text-xl font-bold tabular-nums text-primary">
{item.price}
</span>
<span className="text-sm text-muted-foreground">points</span>
</div>
{owned ? (