Room UX polish: swipe navigation, lucide icons, indicator below name

- Add touch swipe support to BlobbiRoomShell (50px threshold)
- Larger navigation arrows (size-7/8) with strokeWidth 4
- Move room indicator (icon + label + dots) below Blobbi name in hero
- Remove room header overlay from shell top
- Replace all emoji button icons with lucide: Refrigerator, ShowerHead,
  TowelRack, Candy, Shovel
- Replace room config emoji icons with lucide: Home, Refrigerator, Cross,
  Moon, Shirt
- Upgrade lucide-react 0.462.0 -> 1.8.0
- Add room-arrow-nudge keyframe animations to index.css
This commit is contained in:
Chad Curtis
2026-04-10 17:03:52 -05:00
parent eda18d8b93
commit 7a10e4a406
7 changed files with 127 additions and 79 deletions
+30 -5
View File
@@ -109,7 +109,7 @@
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -5758,6 +5758,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5771,6 +5772,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5784,6 +5786,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5797,6 +5800,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5810,6 +5814,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5823,6 +5828,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5836,6 +5842,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5849,6 +5856,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5862,6 +5870,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5875,6 +5884,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5888,6 +5898,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5901,6 +5912,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5914,6 +5926,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5927,6 +5940,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5940,6 +5954,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5953,6 +5968,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5966,6 +5982,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5979,6 +5996,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5992,6 +6010,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6005,6 +6024,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6018,6 +6038,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6031,6 +6052,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6044,6 +6066,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6057,6 +6080,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6070,6 +6094,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -10087,12 +10112,12 @@
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.462.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
"integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
+1 -1
View File
@@ -116,7 +116,7 @@
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -18,6 +18,8 @@ import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BlobbiReactionState } from '@/blobbi/actions';
import type { BlobbiRoomId } from '../lib/room-config';
import { ROOM_META, DEFAULT_ROOM_ORDER, getRoomIndex } from '../lib/room-config';
import { cn } from '@/lib/utils';
// ─── Stat colour maps ─────────────────────────────────────────────────────────
@@ -72,6 +74,10 @@ export interface BlobbiRoomHeroProps {
handleSetAsCompanion: () => Promise<void>;
heroRef: React.RefObject<HTMLDivElement | null>;
heroWidth: number;
/** Current room (for indicator below name) */
roomId: BlobbiRoomId;
/** Room order for dot indicators */
roomOrder?: BlobbiRoomId[];
className?: string;
}
@@ -92,8 +98,12 @@ export function BlobbiRoomHero({
handleSetAsCompanion,
heroRef,
heroWidth,
roomId,
roomOrder = DEFAULT_ROOM_ORDER,
className,
}: BlobbiRoomHeroProps) {
const roomMeta = ROOM_META[roomId];
const roomIndex = getRoomIndex(roomId, roomOrder);
if (isActiveFloatingCompanion) {
return (
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
@@ -159,6 +169,25 @@ export function BlobbiRoomHero({
{companion.name}
</h2>
)}
{/* Room indicator */}
<div className="flex flex-col items-center mt-2">
<div className="flex items-center gap-1.5">
<roomMeta.icon className="size-3.5 sm:size-4 text-foreground/50" />
<span className="text-xs sm:text-sm font-semibold text-foreground/60">{roomMeta.label}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{roomOrder.map((id, i) => (
<div
key={id}
className={cn(
'rounded-full transition-all duration-300',
i === roomIndex ? 'w-4 h-1 bg-primary' : 'w-1 h-1 bg-muted-foreground/20',
)}
/>
))}
</div>
</div>
</div>
</div>
);
+41 -61
View File
@@ -6,9 +6,8 @@
* The parent decides what bottom bar to render based on the active room.
*/
import { useState, useCallback, useMemo, useEffect, type CSSProperties } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef as useReactRef, type CSSProperties } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { cn } from '@/lib/utils';
import { toast } from '@/hooks/useToast';
@@ -18,7 +17,6 @@ import {
DEFAULT_ROOM_ORDER,
getNextRoom,
getPreviousRoom,
getRoomIndex,
} from '../lib/room-config';
import {
generateInitialPoops,
@@ -59,6 +57,9 @@ interface BlobbiRoomShellProps {
// ─── Component ────────────────────────────────────────────────────────────────
/** Minimum horizontal swipe distance (px) to trigger room change */
const SWIPE_THRESHOLD = 50;
export function BlobbiRoomShell({
roomId,
onChangeRoom,
@@ -71,10 +72,6 @@ export function BlobbiRoomShell({
lastFeedTimestamp,
poopStateRef,
}: BlobbiRoomShellProps) {
const isMobile = useIsMobile();
const meta = ROOM_META[roomId];
const roomIndex = getRoomIndex(roomId, roomOrder);
const goLeft = useCallback(() => {
onChangeRoom(getPreviousRoom(roomId, roomOrder));
}, [roomId, roomOrder, onChangeRoom]);
@@ -83,15 +80,25 @@ export function BlobbiRoomShell({
onChangeRoom(getNextRoom(roomId, roomOrder));
}, [roomId, roomOrder, onChangeRoom]);
const dots = useMemo(() => roomOrder.map((id, i) => ({
id,
active: i === roomIndex,
label: ROOM_META[id].label,
})), [roomOrder, roomIndex]);
const leftDest = ROOM_META[getPreviousRoom(roomId, roomOrder)];
const rightDest = ROOM_META[getNextRoom(roomId, roomOrder)];
// ─── Touch swipe ───
const touchStartX = useReactRef<number | null>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
}, []);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
if (dx > 0) goLeft();
else goRight();
}, [goLeft, goRight]);
// ─── Poop system (ephemeral) ───
const [poops, setPoops] = useState<PoopInstance[]>([]);
const [shovelMode, setShovelMode] = useState(false);
@@ -117,11 +124,14 @@ export function BlobbiRoomShell({
poops, shovelMode, setShovelMode, onRemovePoop,
}), [poops, shovelMode, onRemovePoop]);
// Expose poop state to parent so bottom bars can access it
if (poopStateRef) poopStateRef.current = poopState;
return (
<div className="flex flex-col flex-1 min-h-0 relative">
<div
className="flex flex-col flex-1 min-h-0 relative"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Room content */}
<div className="flex-1 min-h-0 flex flex-col relative">
{hero}
@@ -137,71 +147,41 @@ export function BlobbiRoomShell({
/>
)}
{/* Room header */}
<div className="absolute inset-x-0 top-0 z-30 pointer-events-none">
<div className="flex flex-col items-center pt-2 pb-1">
<div className="flex items-center gap-1.5 pointer-events-auto">
<span className="text-sm">{meta.icon}</span>
<span className="text-xs sm:text-sm font-semibold text-foreground/70">{meta.label}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{dots.map(dot => (
<div
key={dot.id}
className={cn(
'rounded-full transition-all duration-300',
dot.active ? 'w-4 h-1 bg-primary' : 'w-1 h-1 bg-muted-foreground/20',
)}
title={dot.label}
/>
))}
</div>
</div>
</div>
{/* Navigation arrows */}
<button
onClick={goLeft}
className={cn(
'group absolute left-0 top-1/2 -translate-y-1/2 z-40',
'flex items-center gap-0',
'text-muted-foreground/40 hover:text-foreground/70',
'transition-all duration-200 active:scale-95',
'cursor-pointer select-none rounded-r-full pl-0.5 pr-1 py-1 hover:bg-accent/40',
'group absolute left-1 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center',
'size-10 sm:size-12 rounded-full',
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
'cursor-pointer select-none',
)}
aria-label={`Go to ${leftDest.label}`}
>
<ChevronLeft
className="size-5 shrink-0 transition-transform duration-300 group-hover:scale-110"
className="size-7 sm:size-8 shrink-0"
strokeWidth={4}
style={{ animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties}
/>
<span className={cn(
'text-[10px] font-medium leading-none whitespace-nowrap transition-all duration-200 overflow-hidden',
isMobile ? 'max-w-[60px] opacity-60' : 'max-w-0 opacity-0 group-hover:max-w-[80px] group-hover:opacity-70',
)}>
{leftDest.label}
</span>
</button>
<button
onClick={goRight}
className={cn(
'group absolute right-0 top-1/2 -translate-y-1/2 z-40',
'flex items-center gap-0',
'text-muted-foreground/40 hover:text-foreground/70',
'transition-all duration-200 active:scale-95',
'cursor-pointer select-none rounded-l-full pr-0.5 pl-1 py-1 hover:bg-accent/40',
'group absolute right-1 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center',
'size-10 sm:size-12 rounded-full',
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
'cursor-pointer select-none',
)}
aria-label={`Go to ${rightDest.label}`}
>
<span className={cn(
'text-[10px] font-medium leading-none whitespace-nowrap transition-all duration-200 overflow-hidden',
isMobile ? 'max-w-[60px] opacity-60' : 'max-w-0 opacity-0 group-hover:max-w-[80px] group-hover:opacity-70',
)}>
{rightDest.label}
</span>
<ChevronRight
className="size-5 shrink-0 transition-transform duration-300 group-hover:scale-110"
className="size-7 sm:size-8 shrink-0"
strokeWidth={4}
style={{ animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties}
/>
</button>
+8 -6
View File
@@ -5,6 +5,8 @@
* The current room is persisted as a tag on kind 11125.
*/
import { Home, Refrigerator, Cross, Moon, Shirt, type LucideIcon } from 'lucide-react';
// ─── Room IDs ─────────────────────────────────────────────────────────────────
export type BlobbiRoomId = 'home' | 'kitchen' | 'care' | 'rest' | 'closet';
@@ -15,7 +17,7 @@ export interface BlobbiRoomMeta {
id: BlobbiRoomId;
label: string;
description: string;
icon: string;
icon: LucideIcon;
}
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
@@ -23,31 +25,31 @@ export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
id: 'home',
label: 'Home',
description: 'Main living room',
icon: '🏠',
icon: Home,
},
kitchen: {
id: 'kitchen',
label: 'Kitchen',
description: 'Feed your Blobbi',
icon: '🍳',
icon: Refrigerator,
},
care: {
id: 'care',
label: 'Care Room',
description: 'Hygiene, care, and medicine',
icon: '🩹',
icon: Cross,
},
rest: {
id: 'rest',
label: 'Bedroom',
description: 'Rest and recharge',
icon: '🌙',
icon: Moon,
},
closet: {
id: 'closet',
label: 'Closet',
description: 'Wardrobe and accessories',
icon: '👗',
icon: Shirt,
},
};
+11
View File
@@ -699,3 +699,14 @@
100% { opacity: 1; }
}
/* Room navigation arrow nudge — subtle horizontal pulse */
@keyframes room-arrow-nudge-left {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(-3px); }
}
@keyframes room-arrow-nudge-right {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
+7 -6
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap } from 'lucide-react';
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, Shovel, TowelRack } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
@@ -1496,6 +1496,7 @@ function BlobbiDashboard({
handleSetAsCompanion={handleSetAsCompanion}
heroRef={heroRef}
heroWidth={heroWidth}
roomId={currentRoom}
/>
}
middleSlot={
@@ -1818,7 +1819,7 @@ function KitchenBar({
<div className="flex items-center justify-between gap-1 sm:gap-3">
{anyPoop && poopState ? (
<RoomActionButton
icon={<span className="text-2xl">🧹</span>}
icon={<Shovel className="size-7 sm:size-9" />}
label={poopState.shovelMode ? 'Done' : 'Shovel'}
color={poopState.shovelMode ? 'text-amber-600' : 'text-stone-500'}
glowHex={poopState.shovelMode ? '#d97706' : '#78716c'}
@@ -1837,7 +1838,7 @@ function KitchenBar({
/>
</div>
<RoomActionButton
icon={<span className="text-2xl">🧊</span>}
icon={<Refrigerator className="size-7 sm:size-9" />}
label="Fridge"
color="text-orange-500"
glowHex="#f97316"
@@ -1897,7 +1898,7 @@ function CareBar({
{isHygieneFocused ? (
towelItem ? (
<RoomActionButton
icon={<span className="text-2xl sm:text-3xl">{towelItem.icon}</span>}
icon={<TowelRack className="size-7 sm:size-9" />}
label="Towel"
color="text-cyan-500"
glowHex="#06b6d4"
@@ -1910,7 +1911,7 @@ function CareBar({
)
) : (
<RoomActionButton
icon={<span className="text-2xl">🍬</span>}
icon={<Candy className="size-7 sm:size-9" />}
label="Treat"
color="text-pink-400"
glowHex="#f472b6"
@@ -1932,7 +1933,7 @@ function CareBar({
</div>
{isHygieneFocused ? (
<RoomActionButton
icon={<span className="text-2xl">🚿</span>}
icon={<ShowerHead className="size-7 sm:size-9" />}
label="Shower"
color="text-blue-500"
glowHex="#3b82f6"