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 ? ( -
- - -
+ ) : (
- -
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/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 ( -
-
-

Build habits. Care for your pet. Feel better every day.

- -
-
- ); -} 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 ? ( +
+

{customization.name}

+
+ ) : 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

+ ); + } + + return ( +
+
+ ); +} const SHOP_LAYER_ORDER: ShopLayerId[] = [ "head", @@ -94,13 +132,15 @@ function ShopItemCard({ )}
-
-
-

{item.name}

- - {item.price} pts - -
+
+

+ {item.name} +

+
@@ -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 +
+
{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