From ec1bfa3fbee45ac422e5211e207b6bc32f96c768 Mon Sep 17 00:00:00 2001
From: pricewowen <231476659+pricewowen@users.noreply.github.com>
Date: Sun, 24 May 2026 08:49:37 -0600
Subject: [PATCH] Improvements to the UI
---
README.md | 47 ++-----
app/(app)/avatar/page.tsx | 4 +-
app/(app)/customize/page.tsx | 4 +-
app/auth/error/page.tsx | 2 +-
app/auth/forgot-password/page.tsx | 2 +-
app/auth/layout.tsx | 9 +-
app/auth/login/page.tsx | 2 +-
app/auth/sign-up-success/page.tsx | 4 +-
app/auth/sign-up/page.tsx | 2 +-
app/auth/update-password/page.tsx | 2 +-
app/globals.css | 48 +++++--
app/layout.tsx | 8 +-
app/page.tsx | 22 +--
components/auth-button.tsx | 11 +-
components/avatar/avatar-customize-layout.tsx | 5 +-
components/avatar/avatar-page-content.tsx | 2 +-
components/avatar/avatar-status-card.tsx | 10 +-
components/avatar/customize-page-content.tsx | 4 +-
components/avatar/pixel-avatar.tsx | 2 +-
components/character/character-creator.tsx | 21 +--
components/character/character-layer-tabs.tsx | 4 +-
components/daily-quiz/daily-quiz-form.tsx | 4 +-
components/landing/landing-pet-preview.tsx | 10 +-
components/layout/app-nav.tsx | 2 +
components/layout/app-shell.tsx | 4 +-
components/layout/app-top-bar.tsx | 34 -----
components/layout/auth-bottom-nav.tsx | 35 +++--
components/layout/global-bottom-nav.tsx | 22 ++-
components/layout/habit-brand.tsx | 32 ++++-
components/layout/header-coin-balance.tsx | 62 +++++++++
components/layout/home-logo-link.tsx | 46 +------
components/layout/landing-hero-actions.tsx | 2 +-
components/layout/site-header.tsx | 50 +++----
.../onboarding/onboarding-customize-form.tsx | 6 +-
.../onboarding/onboarding-quiz-form.tsx | 4 +-
components/pet/animated-pet-sprite.tsx | 41 +++++-
components/pet/pet-emotion-bubble.tsx | 40 ++++++
components/pet/pet-emotion-caption.tsx | 35 +++++
components/pet/pet-habitat.tsx | 122 +++++++++++-----
components/pet/pet-style-link.tsx | 2 +-
components/pet/pixel-gauge.tsx | 22 ++-
components/pet/room-background-picker.tsx | 2 +-
.../profile/profile-preferences-form.tsx | 6 +-
.../reminders/in-app-reminder-banner.tsx | 2 +-
components/shop/shop-content.tsx | 71 +++++++---
components/shop/shop-item-preview-modal.tsx | 15 +-
lib/ai-prompt-security.ts | 2 +-
lib/ai-task-context.ts | 2 +-
lib/avatar-customization-storage.ts | 2 +-
lib/brand.ts | 1 +
lib/daily-reminders.ts | 4 +-
lib/landing-random-pet.ts | 130 ++++++++++--------
lib/openrouter.ts | 2 +-
lib/pet-status.ts | 26 ++++
lib/reminder-delivery.ts | 2 +-
lib/routes.ts | 3 +-
public/habit-logo.png | Bin 0 -> 19410 bytes
public/status/status-1.png | Bin 0 -> 368 bytes
public/status/status-2.png | Bin 0 -> 286 bytes
public/status/status-3.png | Bin 0 -> 362 bytes
60 files changed, 669 insertions(+), 391 deletions(-)
delete mode 100644 components/layout/app-top-bar.tsx
create mode 100644 components/layout/header-coin-balance.tsx
create mode 100644 components/pet/pet-emotion-bubble.tsx
create mode 100644 components/pet/pet-emotion-caption.tsx
create mode 100644 lib/brand.ts
create mode 100644 lib/pet-status.ts
create mode 100644 public/habit-logo.png
create mode 100644 public/status/status-1.png
create mode 100644 public/status/status-2.png
create mode 100644 public/status/status-3.png
diff --git a/README.md b/README.md
index 54836dd..4851a88 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# Habit Pet
+# HaBit
-Calgary hackathon app for building habits while caring for a customizable pixel pet.
+Hackathon app for building habits while growing a customizable digital self (your **bit**).
## Stack
@@ -13,9 +13,9 @@ Calgary hackathon app for building habits while caring for a customizable pixel
- **Auth** — email/password sign up and login
- **Onboarding** — focus-topic quiz, then required avatar customization (DB-gated)
-- **Avatar** — layered sprite pet with per-layer styles and hex colors, naming, equipped shop items
+- **Avatar** — layered sprite bit with per-layer styles and hex colors, naming, equipped shop items
- **Habits** — catalog + custom habits, streaks, coin rewards
-- **Daily quiz** — wellness sliders and journal that affect pet mood
+- **Daily quiz** — wellness sliders and journal that affect bit mood
- **Shop** — buy and equip items from `shop_items` / `user_items` (hats, glasses, backgrounds)
- **Profile** — focus topic, vibe, and reminder preferences
@@ -23,12 +23,12 @@ Calgary hackathon app for building habits while caring for a customizable pixel
1. Sign up / sign in
2. **Step 1:** onboarding quiz (focus topic only)
-3. **Step 2:** avatar customization (required confirm + pet name)
-4. Main app via bottom nav: Pet, Habits, Quiz, Shop, Profile
+3. **Step 2:** avatar customization (required confirm + bit name)
+4. Main app via bottom nav: Bit, Habits, Quiz, Shop, Profile
Onboarding progress is stored in the database (`profiles.onboarding_quiz_complete`, `profiles.onboarding_complete`, `avatar_state.avatar_customized`) and mirrored in auth metadata for fast client checks.
-On the **Pet** tab, users rename their pet and switch between **Overview** and **Customize** sub-tabs.
+On the **Bit** tab, users view their digital self and open **Style** to customize name, look, and room.
## Local setup
@@ -69,7 +69,7 @@ Stored in `avatar_state`:
| Column | Purpose |
|--------|---------|
-| `avatar_name` | Pet display name |
+| `avatar_name` | Bit display name |
| `skin_color` | Skin layer hex color |
| `pants_style` / `pants_color` | Pants variant id + hex |
| `shoe_style` / `shoe_color` | Shoes variant id + hex |
@@ -78,34 +78,3 @@ Stored in `avatar_state`:
| `head_style` / `head_color` | Head variant id + hex |
| `avatar_customized` | True after onboarding confirm |
| `equipped_items` | Shop item ids currently equipped |
-
-Style columns are validated against allowed variant ids (`none`, `pants-1`, …). Shop accessories overlay equipped variants at render time without overwriting saved customization.
-
-Sprite assets are added manually under `public/character/{layer}/` (e.g. `public/character/head/head-1.png`). Paths must match the variant ids in `lib/character/presets.ts` and `supabase/seed.sql`.
-
-## Shop
-
-- Sells character layer styles from `public/character/` (`head-1`, `pants-2`, etc.)
-- Shop item `id` = variant id, `type` = layer id (`head`, `pants`, `shoes`, `torso`, `eyes`)
-- Purchases unlock styles in `user_items`; owned styles appear in Customize and can be equipped from the shop
-- Catalog seeded in `supabase/seed.sql`
-
-## Supabase
-
-- Migrations: `supabase/migrations/`
-- Seed: `supabase/seed.sql`
-- Config: `supabase/config.toml`
-
-Production deploys on push to `main` via `.github/workflows/deploy.yml` (lint, build, `supabase db push --include-seed`, Vercel).
-
-## Routes
-
-| Route | Access |
-|-------|--------|
-| `/` | Public |
-| `/auth/*` | Public |
-| `/onboarding/quiz` | Authenticated, quiz incomplete |
-| `/onboarding/customize` | Authenticated, quiz complete, avatar not confirmed |
-| `/avatar` | Authenticated, onboarding complete |
-| `/habits`, `/daily/quiz`, `/shop`, `/profile` | Authenticated app |
-| `/character-creator` | Redirects to `/avatar?tab=customize` |
diff --git a/app/(app)/avatar/page.tsx b/app/(app)/avatar/page.tsx
index 57ca086..247d916 100644
--- a/app/(app)/avatar/page.tsx
+++ b/app/(app)/avatar/page.tsx
@@ -6,8 +6,8 @@ import { AppShell } from "@/components/layout/app-shell";
export default function AvatarPage() {
return (
diff --git a/app/(app)/customize/page.tsx b/app/(app)/customize/page.tsx
index 4a7efa8..f9ba92c 100644
--- a/app/(app)/customize/page.tsx
+++ b/app/(app)/customize/page.tsx
@@ -7,8 +7,8 @@ export default function CustomizePage() {
return (
Loading...
}>
diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx
index 18b8306..fd4ac3c 100644
--- a/app/auth/error/page.tsx
+++ b/app/auth/error/page.tsx
@@ -29,7 +29,7 @@ export default function Page({
searchParams: Promise<{ error: string }>;
}) {
return (
-
+
diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx
index 8dcfbb2..ce233a7 100644
--- a/app/auth/forgot-password/page.tsx
+++ b/app/auth/forgot-password/page.tsx
@@ -2,7 +2,7 @@ import { ForgotPasswordForm } from "@/components/forgot-password-form";
export default function Page() {
return (
-
+
diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx
index 0120eac..7e53911 100644
--- a/app/auth/layout.tsx
+++ b/app/auth/layout.tsx
@@ -1,14 +1,7 @@
-import { AuthBottomNav } from "@/components/layout/auth-bottom-nav";
-
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
- return (
- <>
- {children}
-
- >
- );
+ return
{children}
;
}
diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx
index 5efe679..f3dbc15 100644
--- a/app/auth/login/page.tsx
+++ b/app/auth/login/page.tsx
@@ -2,7 +2,7 @@ import { LoginForm } from "@/components/login-form";
export default function Page() {
return (
-
+
diff --git a/app/auth/sign-up-success/page.tsx b/app/auth/sign-up-success/page.tsx
index 06bac40..de1807e 100644
--- a/app/auth/sign-up-success/page.tsx
+++ b/app/auth/sign-up-success/page.tsx
@@ -8,7 +8,7 @@ import {
export default function Page() {
return (
-
+
@@ -22,7 +22,7 @@ export default function Page() {
- Your account is ready. You can sign in and start using Habit Pet.
+ Your account is ready. You can sign in and start using HaBit.
diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx
index d5dca78..c0eb93c 100644
--- a/app/auth/sign-up/page.tsx
+++ b/app/auth/sign-up/page.tsx
@@ -2,7 +2,7 @@ import { SignUpForm } from "@/components/sign-up-form";
export default function Page() {
return (
-
+
diff --git a/app/auth/update-password/page.tsx b/app/auth/update-password/page.tsx
index ff4d176..2b52abb 100644
--- a/app/auth/update-password/page.tsx
+++ b/app/auth/update-password/page.tsx
@@ -2,7 +2,7 @@ import { UpdatePasswordForm } from "@/components/update-password-form";
export default function Page() {
return (
-
+
diff --git a/app/globals.css b/app/globals.css
index d402579..5c15d0e 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -4,7 +4,7 @@
@layer base {
:root {
- --app-topbar-height: 3.5rem;
+ --app-topbar-height: 3.25rem;
--app-viewport-bottom-offset: 0px;
--app-nav-offset: 6rem;
--bottom-nav-height: 6rem;
@@ -117,7 +117,7 @@
border: 3px solid hsl(var(--lcd-border));
}
- /* Matches pet-tab LCD: max-w-lg main minus px-5 and shell p-4 */
+ /* Matches bit-tab LCD: max-w-lg main minus px-5 and shell p-4 */
.tamagotchi-lcd-pet-match {
@apply aspect-[4/3] w-full shrink-0;
max-width: calc(32rem - 2.5rem - 2rem);
@@ -127,6 +127,15 @@
.tamagotchi-lcd-landing {
@apply aspect-[4/3] w-full shrink-0;
max-height: clamp(120px, 36vw, 240px);
+ container-type: inline-size;
+ }
+
+ .tamagotchi-lcd-landing .lcd-readable-name-landing {
+ max-width: 58%;
+ padding: 0.125rem 0.375rem;
+ font-size: clamp(0.4rem, 4.5cqi, 0.5625rem);
+ line-height: 1.2;
+ box-shadow: 1px 1px 0 0 hsl(280 45% 28%);
}
.tamagotchi-lcd-landing > div.absolute svg {
@@ -149,16 +158,6 @@
aspect-ratio: auto;
}
- .customize-body {
- min-height: 26rem;
- }
-
- @media (min-width: 1024px) {
- .customize-body {
- min-height: 30rem;
- }
- }
-
.retro-btn {
@apply inline-flex items-center justify-center border-2 border-border bg-primary text-primary-foreground;
box-shadow: var(--retro-shadow-sm);
@@ -244,6 +243,31 @@
.form-section-locked label {
@apply text-muted-foreground;
}
+
+ .lcd-readable-label {
+ @apply border-2 border-border bg-card px-2.5 py-1.5 text-center font-pixel text-[8px] leading-snug text-card-foreground sm:text-[9px];
+ box-shadow: var(--retro-shadow-sm);
+ }
+
+ .lcd-readable-name {
+ @apply border-2 border-border bg-card px-2 py-1 font-pixel leading-snug text-card-foreground;
+ box-shadow: var(--retro-shadow-sm);
+ }
+}
+
+@keyframes emotion-pop {
+ 0% {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.animate-emotion-pop {
+ animation: emotion-pop 0.2s steps(4) both;
}
@keyframes pet-breathe {
diff --git a/app/layout.tsx b/app/layout.tsx
index 99e3326..8ccb9dd 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,6 +3,7 @@ import { Press_Start_2P, Share_Tech_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { GlobalBottomNav } from "@/components/layout/global-bottom-nav";
+import { SiteHeader } from "@/components/layout/site-header";
import { ToastProvider } from "@/components/ui/toast-provider";
import "./globals.css";
@@ -12,8 +13,8 @@ const defaultUrl = process.env.VERCEL_URL
export const metadata: Metadata = {
metadataBase: new URL(defaultUrl),
- title: "Habit Pet",
- description: "Build habits, care for your pixel pet, and track wellness every day.",
+ title: "HaBit",
+ description: "Build habits, grow your digital self, and track wellness every day.",
};
export const viewport: Viewport = {
@@ -52,7 +53,8 @@ export default function RootLayout({
disableTransitionOnChange
>
- {children}
+
+ {children}
diff --git a/app/page.tsx b/app/page.tsx
index 45e71b8..bc89ee8 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -2,7 +2,6 @@ import { Suspense } from "react";
import { LandingPetPreview } from "@/components/landing/landing-pet-preview";
import { LandingHeroActions } from "@/components/layout/landing-hero-actions";
-import { SiteFooter, SiteHeader } from "@/components/layout/site-header";
import {
Card,
CardContent,
@@ -13,22 +12,17 @@ import {
export default function Home() {
return (
-
-
-
+
-
- Calgary Hackathon MVP
-
- Build habits. Care for your pixel pet.
+ Build habits. Grow your digital self.
- HaBit turns daily check-ins, wellness sliders, and journaling
- into a simple game. Keep streaks alive, take your once-per-day
- quiz, and watch your pet change with you.
+ HaBit turns daily check-ins, wellness sliders, and journaling into
+ a simple game. Keep streaks alive, take your once-per-day quiz,
+ and watch your bit change with you.
@@ -59,8 +53,8 @@ export default function Home() {
description: "Sliders and a daily reflection box.",
},
{
- title: "Pet that responds",
- description: "Avatar mood changes from your input.",
+ title: "A bit that responds",
+ description: "Your digital self reflects your daily input.",
},
].map((feature) => (
@@ -72,8 +66,6 @@ export default function Home() {
))}
-
-
);
}
diff --git a/components/auth-button.tsx b/components/auth-button.tsx
index f55cc89..42efa25 100644
--- a/components/auth-button.tsx
+++ b/components/auth-button.tsx
@@ -11,18 +11,13 @@ export async function AuthButton() {
const user = data?.claims;
return user ? (
-
-
- Open app
-
-
-
+
) : (
-
+
Sign in
-
+
Sign up
diff --git a/components/avatar/avatar-customize-layout.tsx b/components/avatar/avatar-customize-layout.tsx
index fde45aa..c95b4df 100644
--- a/components/avatar/avatar-customize-layout.tsx
+++ b/components/avatar/avatar-customize-layout.tsx
@@ -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 (
-
-
-
+
Loading your pet...
+ Loading your bit...
);
}
diff --git a/components/avatar/avatar-status-card.tsx b/components/avatar/avatar-status-card.tsx
index fc42088..1ef894e 100644
--- a/components/avatar/avatar-status-card.tsx
+++ b/components/avatar/avatar-status-card.tsx
@@ -61,7 +61,7 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
- Loading your pet...
+ Loading your bit...
@@ -100,11 +100,11 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
{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."}
{!hasCheckedInToday ? (
diff --git a/components/avatar/customize-page-content.tsx b/components/avatar/customize-page-content.tsx
index 1bf9815..bef8142 100644
--- a/components/avatar/customize-page-content.tsx
+++ b/components/avatar/customize-page-content.tsx
@@ -26,6 +26,7 @@ export function CustomizePageContent() {
const [shopItems, setShopItems] = useState([]);
const [coins, setCoins] = useState(null);
const [loadError, setLoadError] = useState(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 (
+
{children}
);
@@ -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({
{showNameField ? (
- Pet name
+ Bit name
setName(event.target.value)}
/>
@@ -368,8 +371,8 @@ export function CharacterCreator({
-
-
+
+
@@ -386,7 +389,7 @@ export function CharacterCreator({
-
+
{showStyleSection ? (
Style
@@ -437,7 +440,7 @@ export function CharacterCreator({
) : null}
{onSave ? (
-
+
{CHARACTER_CUSTOMIZATION_TABS.map((tab) => (
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",
diff --git a/components/daily-quiz/daily-quiz-form.tsx b/components/daily-quiz/daily-quiz-form.tsx
index 315e13d..dc7360a 100644
--- a/components/daily-quiz/daily-quiz-form.tsx
+++ b/components/daily-quiz/daily-quiz-form.tsx
@@ -161,12 +161,12 @@ export function DailyQuizForm() {
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{" "}
- your pet
+ your bit
{" "}
whenever you want to see how they are doing.
diff --git a/components/landing/landing-pet-preview.tsx b/components/landing/landing-pet-preview.tsx
index bf9613b..034d286 100644
--- a/components/landing/landing-pet-preview.tsx
+++ b/components/landing/landing-pet-preview.tsx
@@ -20,11 +20,9 @@ function LandingPetPreviewSkeleton() {
aria-hidden="true"
className="tamagotchi-shell w-full max-w-sm overflow-hidden p-3 sm:p-4"
>
-
);
}
@@ -130,7 +128,7 @@ export function LandingPetPreview() {
-
+
-
-
-
-
- );
-}
diff --git a/components/layout/auth-bottom-nav.tsx b/components/layout/auth-bottom-nav.tsx
index 1969ef8..85a4630 100644
--- a/components/layout/auth-bottom-nav.tsx
+++ b/components/layout/auth-bottom-nav.tsx
@@ -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(function AuthBottomNav(
+ _props,
+ ref,
+) {
const pathname = usePathname();
return (
-
-
+
+
+
{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",
)}
>
-
- {item.label}
+
+
+ {item.label}
+
);
})}
);
-}
+});
diff --git a/components/layout/global-bottom-nav.tsx b/components/layout/global-bottom-nav.tsx
index 05a005d..e30de81 100644
--- a/components/layout/global-bottom-nav.tsx
+++ b/components/layout/global-bottom-nav.tsx
@@ -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
;
}
+function MeasuredAuthNav() {
+ const navRef = useNavOffsetRef();
+ return
;
+}
+
export function GlobalBottomNav() {
- const [showNav, setShowNav] = useState(false);
+ const [navKind, setNavKind] = useState
("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 (
-
-
- {showNav ? : null}
+
+
+ {navKind === "app" ? : null}
+ {navKind === "auth" ? : null}
);
}
diff --git a/components/layout/habit-brand.tsx b/components/layout/habit-brand.tsx
index 6288bbf..b4840f8 100644
--- a/components/layout/habit-brand.tsx
+++ b/components/layout/habit-brand.tsx
@@ -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 (
@@ -35,14 +51,16 @@ export function HabitBrand({
// eslint-disable-next-line @next/next/no-img-element
setUseText(true)}
/>
) : (
HaBit
diff --git a/components/layout/header-coin-balance.tsx b/components/layout/header-coin-balance.tsx
new file mode 100644
index 0000000..069687d
--- /dev/null
+++ b/components/layout/header-coin-balance.tsx
@@ -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(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 (
+
+
+
+ {coins === null ? "…" : coins}
+
+ pts
+
+ );
+}
diff --git a/components/layout/home-logo-link.tsx b/components/layout/home-logo-link.tsx
index 30720ea..40b6a78 100644
--- a/components/layout/home-logo-link.tsx
+++ b/components/layout/home-logo-link.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
+export { HabitBrand as HomeLogoLink } from "@/components/layout/habit-brand";
diff --git a/components/layout/landing-hero-actions.tsx b/components/layout/landing-hero-actions.tsx
index 61bf497..fb28438 100644
--- a/components/layout/landing-hero-actions.tsx
+++ b/components/layout/landing-hero-actions.tsx
@@ -11,7 +11,7 @@ export async function LandingHeroActions() {
return (
- Go to your pet
+ Go to your bit
);
diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx
index c16b979..35c282a 100644
--- a/components/layout/site-header.tsx
+++ b/components/layout/site-header.tsx
@@ -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 (
-
-
-
- HaBit
-
- {!hasEnvVars ? (
-
- ) : (
-
-
-
- )}
+
+
+
+
+ {!hasEnvVars ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
);
}
-
-export function SiteFooter() {
- return (
-
- );
-}
diff --git a/components/onboarding/onboarding-customize-form.tsx b/components/onboarding/onboarding-customize-form.tsx
index 1aa02a5..7adbae8 100644
--- a/components/onboarding/onboarding-customize-form.tsx
+++ b/components/onboarding/onboarding-customize-form.tsx
@@ -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 (
- Welcome to Habit Pet
+ Welcome to HaBit
- Choose a focus area, then design your pet in the next step.
+ Choose a focus area, then design your bit in the next step.
diff --git a/components/pet/animated-pet-sprite.tsx b/components/pet/animated-pet-sprite.tsx
index 95da511..c31c551 100644
--- a/components/pet/animated-pet-sprite.tsx
+++ b/components/pet/animated-pet-sprite.tsx
@@ -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({
x: PET_WANDER_START.x,
@@ -80,16 +90,45 @@ export function AnimatedPetSprite({
};
}, []);
+ const handleActivate = () => {
+ if (!interactive) {
+ return;
+ }
+
+ onInteract?.();
+ };
+
return (
{
+ event.stopPropagation();
+ handleActivate();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ event.stopPropagation();
+ handleActivate();
+ }
+ }}
>
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+ );
+}
diff --git a/components/pet/pet-emotion-caption.tsx b/components/pet/pet-emotion-caption.tsx
new file mode 100644
index 0000000..58cec6d
--- /dev/null
+++ b/components/pet/pet-emotion-caption.tsx
@@ -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 (
+
+
{getPetEmotionMessage(mood)}
+
+ );
+}
diff --git a/components/pet/pet-habitat.tsx b/components/pet/pet-habitat.tsx
index d3a3809..b8ed7c4 100644
--- a/components/pet/pet-habitat.tsx
+++ b/components/pet/pet-habitat.tsx
@@ -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
;
};
-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(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 (
- Waking up pet...
+ Waking up your bit...
);
@@ -83,24 +119,48 @@ export function PetHabitat({
return (
-
-
{customization.name}
-
- {MOOD_LABELS[condition.mood]}
-
-
+ {showNameAboveShell ? (
+
+ ) : null}
-
-
-
-
+
+
+
- {showStyleLink ? (
-
+ {showNameInLcdOverlay ? (
+
+ {customization.name}
+
) : null}
diff --git a/components/pet/pet-style-link.tsx b/components/pet/pet-style-link.tsx
index 060a33f..f799973 100644
--- a/components/pet/pet-style-link.tsx
+++ b/components/pet/pet-style-link.tsx
@@ -12,7 +12,7 @@ export function PetStyleLink({ className }: PetStyleLinkProps) {
return (
) : null}
-
+
{Array.from({ length: max }, (_, index) => (
- Pick the scene behind your pet. Premium rooms unlock in the shop.
+ Pick the scene behind your bit. Premium rooms unlock in the shop.
) : (
diff --git a/components/profile/profile-preferences-form.tsx b/components/profile/profile-preferences-form.tsx
index 656234e..a175632 100644
--- a/components/profile/profile-preferences-form.tsx
+++ b/components/profile/profile-preferences-form.tsx
@@ -266,7 +266,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
Focus topic preference
- 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.
@@ -288,7 +288,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
Avatar preferences
- Personalize your pet's vibe.
+ Personalize your bit's vibe.
{avatarVibeOptions.map((vibe) => (
@@ -369,7 +369,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
/>
- 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.
diff --git a/components/reminders/in-app-reminder-banner.tsx b/components/reminders/in-app-reminder-banner.tsx
index c01d4e9..37a0fd4 100644
--- a/components/reminders/in-app-reminder-banner.tsx
+++ b/components/reminders/in-app-reminder-banner.tsx
@@ -59,7 +59,7 @@ export function InAppReminderBanner() {
router.push(reminder.targetUrl);
}}
>
- Open Pet tab
+ Open Bit tab
+ {equipped ? "Equipped" : "Owned"}
+
+ );
+ }
+
+ return (
+
+
+ {price}
+ pts
+
+ );
+}
const SHOP_LAYER_ORDER: ShopLayerId[] = [
"head",
@@ -94,13 +132,15 @@ function ShopItemCard({
)}
-
-
-
{item.name}
-
- {item.price} pts
-
-
+
@@ -287,15 +327,6 @@ export function ShopContent() {
}}
/>
-
-
- Your balance
-
-
- {coins === null ? "..." : `${coins} points`}
-
-
-
{error ? {error}
: null}
{!error && coins !== null && items.length === 0 ? (
@@ -312,7 +343,7 @@ export function ShopContent() {
{roomItems.length > 0 ? (
-
+
Room backgrounds
@@ -335,7 +366,7 @@ export function ShopContent() {
{styleGroups.map((group) => (
-
+
{group.label}
diff --git a/components/shop/shop-item-preview-modal.tsx b/components/shop/shop-item-preview-modal.tsx
index 7ad1248..6e10015 100644
--- a/components/shop/shop-item-preview-modal.tsx
+++ b/components/shop/shop-item-preview-modal.tsx
@@ -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
{item.name}
@@ -119,13 +119,16 @@ export function ShopItemPreviewModal({
- Preview only — nothing is purchased or equipped until you confirm.
+ This is a preview only. Nothing is purchased or equipped until you confirm.
-
-
Price
-
{item.price} points
+
+
+
+ {item.price}
+
+ points
{owned ? (
diff --git a/lib/ai-prompt-security.ts b/lib/ai-prompt-security.ts
index 251990f..edee108 100644
--- a/lib/ai-prompt-security.ts
+++ b/lib/ai-prompt-security.ts
@@ -1,4 +1,4 @@
-export const AI_TASK_SYSTEM_PROMPT = `You are a supportive wellness coach for a habit-tracking pet app.
+export const AI_TASK_SYSTEM_PROMPT = `You are a supportive wellness coach for a habit-tracking app where users care for a digital self (their "bit").
Your only allowed action is to suggest practical daily habit task labels based on trusted app context and untrusted user journal text.
diff --git a/lib/ai-task-context.ts b/lib/ai-task-context.ts
index 7a9fbeb..b77b215 100644
--- a/lib/ai-task-context.ts
+++ b/lib/ai-task-context.ts
@@ -60,7 +60,7 @@ export function formatAiTaskContextForPrompt(context: AiTaskContext) {
`Daily reminder enabled: ${preferences.dailyReminderEnabled ? "yes" : "no"}`,
`Daily reminder time: ${preferences.dailyReminderTime}`,
`Primary focus: ${onboarding.focusTopic ?? "unknown"}`,
- `Starter pet vibe: ${onboarding.avatarVibe ?? "unknown"}`,
+ `Starter bit vibe: ${onboarding.avatarVibe ?? "unknown"}`,
].join("\n"),
);
diff --git a/lib/avatar-customization-storage.ts b/lib/avatar-customization-storage.ts
index 450a90a..0662620 100644
--- a/lib/avatar-customization-storage.ts
+++ b/lib/avatar-customization-storage.ts
@@ -51,7 +51,7 @@ type AvatarStateRow = {
room_background: string | null;
};
-export const DEFAULT_AVATAR_NAME = "Pixel Me";
+export const DEFAULT_AVATAR_NAME = "My Bit";
const DEFAULT_HEX = DEFAULT_GRAY_COLOR.hex;
const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
diff --git a/lib/brand.ts b/lib/brand.ts
new file mode 100644
index 0000000..9805614
--- /dev/null
+++ b/lib/brand.ts
@@ -0,0 +1 @@
+export const HABIT_LOGO_SRC = "/habit-logo.png";
diff --git a/lib/daily-reminders.ts b/lib/daily-reminders.ts
index 8462a86..761a2d4 100644
--- a/lib/daily-reminders.ts
+++ b/lib/daily-reminders.ts
@@ -129,7 +129,7 @@ export function buildDailyReminderNotification(status: DailyReminderStatus) {
if (!status.quizCompleted) {
return {
title: "Time for your daily check-in",
- body: "Complete today's wellness quiz to keep your pet happy.",
+ body: "Complete today's wellness quiz to keep your bit in good spirits.",
targetUrl,
};
}
@@ -174,7 +174,7 @@ export function showTestSystemDailyReminderNotification(
}
const { body, targetUrl } = buildDailyReminderNotification(status);
- const notification = new Notification("Test: Habit Pet reminder", {
+ const notification = new Notification("Test: HaBit reminder", {
body: `This is a browser test notification. ${body}`,
tag: `habit-pet-daily-reminder-test-${Date.now()}`,
});
diff --git a/lib/landing-random-pet.ts b/lib/landing-random-pet.ts
index 4be9491..76fff6f 100644
--- a/lib/landing-random-pet.ts
+++ b/lib/landing-random-pet.ts
@@ -1,43 +1,45 @@
-import {
- DEFAULT_AVATAR_NAME,
- type AvatarCustomization,
-} from "@/lib/avatar-customization-storage";
+import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import {
CHARACTER_LAYERS,
COLOR_PRESETS,
NONE_VARIANT_ID,
type CharacterLayerId,
} from "@/lib/character/presets";
-import { ROOM_BACKGROUNDS } from "@/lib/room-backgrounds";
+import { clampHsl } from "@/lib/character/color-utils";
+import {
+ ROOM_BACKGROUNDS,
+ type RoomBackgroundId,
+} from "@/lib/room-backgrounds";
const GUEST_PET_STORAGE_KEY = "habit-pet-guest-landing-pet";
-const SKIN_TONE_IDS = [
- "porcelain",
- "fair",
- "light",
- "medium",
- "tan",
- "brown",
- "dark",
- "deep",
- "onyx",
+export const GUEST_LANDING_BIT_NAMES = [
+ "Alex",
+ "Jordan",
+ "Sam",
+ "Taylor",
+ "Morgan",
] as const;
-const OUTFIT_COLOR_IDS = [
- "red",
- "orange",
- "yellow",
- "green",
- "blue",
- "indigo",
- "black",
- "white",
-] as const;
+const SKIN_COLOR_PRESETS = COLOR_PRESETS.filter((preset) =>
+ [
+ "porcelain",
+ "fair",
+ "light",
+ "medium",
+ "tan",
+ "brown",
+ "dark",
+ "deep",
+ "onyx",
+ ].includes(preset.id),
+);
-const EYE_COLOR_IDS = ["blue", "black", "green", "brown"] as const;
+const STYLE_COLOR_PRESETS = COLOR_PRESETS.filter(
+ (preset) => !SKIN_COLOR_PRESETS.some((skin) => skin.id === preset.id),
+);
-const HAIR_COLOR_IDS = ["brown", "black", "yellow", "red", "orange", "white"] as const;
+const ALL_ROOM_IDS = ROOM_BACKGROUNDS.map((room) => room.id);
function pickRandom
(items: readonly T[]): T {
return items[Math.floor(Math.random() * items.length)]!;
@@ -52,48 +54,61 @@ function presetHsl(id: string) {
return { ...preset.hsl };
}
-function pickRandomVariant(
- layerId: Exclude,
- allowNone = false,
-): string {
+function randomHsl(): { h: number; s: number; l: number } {
+ return clampHsl({
+ h: Math.floor(Math.random() * 360),
+ s: 35 + Math.floor(Math.random() * 50),
+ l: 28 + Math.floor(Math.random() * 45),
+ });
+}
+
+function pickRandomLayerColor(layerId: CharacterLayerId) {
+ if (layerId === "skin") {
+ return presetHsl(pickRandom(SKIN_COLOR_PRESETS).id);
+ }
+
+ if (Math.random() < 0.35) {
+ return randomHsl();
+ }
+
+ return presetHsl(pickRandom(STYLE_COLOR_PRESETS).id);
+}
+
+function pickRandomLayerVariant(layerId: CharacterLayerId): string {
const layer = CHARACTER_LAYERS.find((entry) => entry.id === layerId);
if (!layer) {
throw new Error(`Missing character layer: ${layerId}`);
}
- const options = layer.variants
- .map((variant) => variant.id)
- .filter((id) => allowNone || id !== NONE_VARIANT_ID);
+ if (!layer.allowVariants) {
+ return layer.variants[0]?.id ?? NONE_VARIANT_ID;
+ }
- return pickRandom(options);
+ return pickRandom(layer.variants.map((variant) => variant.id));
}
export function buildRandomLandingPetCustomization(): AvatarCustomization {
- const freeRooms = ROOM_BACKGROUNDS.filter((room) => room.free).map(
- (room) => room.id,
- );
-
return {
- name: DEFAULT_AVATAR_NAME,
+ name: pickRandom(GUEST_LANDING_BIT_NAMES),
colors: {
- skin: presetHsl(pickRandom(SKIN_TONE_IDS)),
- pants: presetHsl(pickRandom(OUTFIT_COLOR_IDS)),
- shoes: presetHsl(pickRandom(OUTFIT_COLOR_IDS)),
- torso: presetHsl(pickRandom(OUTFIT_COLOR_IDS)),
- eyes: presetHsl(pickRandom(EYE_COLOR_IDS)),
- head: presetHsl(pickRandom(HAIR_COLOR_IDS)),
+ skin: pickRandomLayerColor("skin"),
+ pants: pickRandomLayerColor("pants"),
+ shoes: pickRandomLayerColor("shoes"),
+ torso: pickRandomLayerColor("torso"),
+ eyes: pickRandomLayerColor("eyes"),
+ head: pickRandomLayerColor("head"),
},
variants: {
- skin: "skin-1",
- pants: pickRandomVariant("pants"),
- shoes: pickRandomVariant("shoes"),
- torso: pickRandomVariant("torso"),
- eyes: pickRandomVariant("eyes"),
- head: pickRandomVariant("head", Math.random() < 0.15),
+ skin: pickRandomLayerVariant("skin"),
+ pants: pickRandomLayerVariant("pants"),
+ shoes: pickRandomLayerVariant("shoes"),
+ torso: pickRandomLayerVariant("torso"),
+ eyes: pickRandomLayerVariant("eyes"),
+ head: pickRandomLayerVariant("head"),
},
customized: true,
equippedItems: [],
- roomBackground: pickRandom(freeRooms),
+ roomBackground: pickRandom(ALL_ROOM_IDS) as RoomBackgroundId,
};
}
@@ -105,7 +120,14 @@ export function getGuestLandingPetCustomization(): AvatarCustomization {
try {
const cached = sessionStorage.getItem(GUEST_PET_STORAGE_KEY);
if (cached) {
- return JSON.parse(cached) as AvatarCustomization;
+ const parsed = JSON.parse(cached) as AvatarCustomization;
+ if (
+ GUEST_LANDING_BIT_NAMES.includes(
+ parsed.name as (typeof GUEST_LANDING_BIT_NAMES)[number],
+ )
+ ) {
+ return parsed;
+ }
}
const next = buildRandomLandingPetCustomization();
diff --git a/lib/openrouter.ts b/lib/openrouter.ts
index b312155..c7e9cc0 100644
--- a/lib/openrouter.ts
+++ b/lib/openrouter.ts
@@ -46,7 +46,7 @@ export async function generateAiTaskLabels(
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": siteUrl,
- "X-Title": "Habit Pet",
+ "X-Title": "HaBit",
},
body: JSON.stringify({
model: DEFAULT_OPENROUTER_MODEL,
diff --git a/lib/pet-status.ts b/lib/pet-status.ts
new file mode 100644
index 0000000..0e19669
--- /dev/null
+++ b/lib/pet-status.ts
@@ -0,0 +1,26 @@
+import type { AvatarMood } from "@/lib/avatar-state";
+
+export const PET_EMOTION_DISPLAY_MS = 3200;
+
+export type PetStatusImageId = "status-1" | "status-2" | "status-3";
+
+export const PET_STATUS_BY_MOOD: Record = {
+ happy: "status-1",
+ neutral: "status-2",
+ tired: "status-3",
+};
+
+export function getPetStatusImagePath(mood: AvatarMood): string {
+ return `/status/${PET_STATUS_BY_MOOD[mood]}.png`;
+}
+
+export function getPetEmotionMessage(mood: AvatarMood): string {
+ switch (mood) {
+ case "happy":
+ return "Your bit seems cheerful and in good spirits.";
+ case "tired":
+ return "Your bit seems worn out and could use some care.";
+ default:
+ return "Your bit seems steady and doing okay.";
+ }
+}
diff --git a/lib/reminder-delivery.ts b/lib/reminder-delivery.ts
index 77d903e..a808d34 100644
--- a/lib/reminder-delivery.ts
+++ b/lib/reminder-delivery.ts
@@ -3,7 +3,7 @@ export const reminderDeliveryOptions = [
value: "in_app",
label: "In-app",
description:
- "Show reminders inside Habit Pet when you have the site open in a tab.",
+ "Show reminders inside HaBit when you have the site open in a tab.",
},
{
value: "system",
diff --git a/lib/routes.ts b/lib/routes.ts
index 3856cd7..01f5aa9 100644
--- a/lib/routes.ts
+++ b/lib/routes.ts
@@ -16,7 +16,8 @@ export const routes = {
export type AppRoute = (typeof routes)[keyof typeof routes];
export const appNavItems = [
- { href: routes.avatar, label: "Pet", shortLabel: "Pet" },
+ { href: routes.avatar, label: "Bit", shortLabel: "Bit" },
+ { href: routes.customize, label: "Customize", shortLabel: "Style" },
{ href: routes.habits, label: "Habits", shortLabel: "Habits" },
{ href: routes.dailyQuiz, label: "Daily Quiz", shortLabel: "Quiz" },
{ href: routes.shop, label: "Shop", shortLabel: "Shop" },
diff --git a/public/habit-logo.png b/public/habit-logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..6f8d42551f765a8772aa11ea7a3b22b40cb31e59
GIT binary patch
literal 19410
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
literal 0
HcmV?d00001
diff --git a/public/status/status-3.png b/public/status/status-3.png
new file mode 100644
index 0000000000000000000000000000000000000000..0f4a2ce64a9e1067f64b3d921d891fbede65b75c
GIT binary patch
literal 362
zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkE(|{ziWqcmo{H=Pif|Tq
zL>4nJ=qZCRW5rVYGN2%PiKnkC`zuxnUTKcb;!XR3LXst}5hc#~xw)x%B@E6*sfi`2
zDGKG8B^e6tp1uL$jeO!jMVCBX978Nl&z)?@*Q_AXI=`*?3U|pzA(fcJOExDOomn>d
z<((rp=Pl@*p74>WLOAA^aKI8R?hozX56(Y+n!(}PrALQe#Kj&<(art3BH&I}aFVe!
z!v>xP;ni-A{#F(d_q?~9FJo5V{oMV}QSZmP8}gNFDnevf=LqIUJ!oQP__E~gFL@n?
zp8FrSOw|dC**%fTo$*VG-VUJlo!@E__PKvM$`ac>+3b>!`DE)Y{80v*IrkN1_n=8KbLh*2~7a6
C^NKnE
literal 0
HcmV?d00001