Fix room layout proportions, responsive sizing, and visual composition

Addresses layout issues where the room felt cut off and controls were
disproportionate, especially on mobile.

Key changes:

RoomActionButton — responsive sizing:
  Mobile: size-14 circle, size-7 icons
  Desktop: size-20 circle, size-9 icons
  Tighter gap-1 and text-[10px] labels on mobile

ItemCarousel — responsive behavior:
  Mobile: focused item only + compact arrows, no prev/next previews
  Desktop: focused item + translucent side previews
  Smaller focused item (text-4xl mobile, text-5xl desktop)

BlobbiRoomHero — reduced visual footprint:
  Blobbi visual: tighter responsive scale (size-48 -> size-60 -> size-80)
  Stats crown: reduced margin (mb-6 sm:mb-10 vs mb-14)
  Stat indicators: size-14 sm:size-[4.5rem] (was size-[4.5rem] sm:size-20)
  Glow blur reduced on mobile (-m-16 vs -m-24)
  overflow-hidden to prevent hero from clipping

HomeRoom — unified bottom bar:
  Removed -mt-10 negative margin hack that caused visual clipping
  Photo, Carousel, and Companion now sit in one flex row with
  items-center alignment, forming a single cohesive bottom composition

All rooms — standardized bottom bar pattern:
  Consistent px-3 sm:px-6 pb-4 sm:pb-6 pt-1 spacing
  flex items-center justify-between gap-1 sm:gap-3 layout
  Spacer divs match button widths (w-14 sm:w-20) for balance

Room shell content area uses flex flex-col to ensure rooms
fill the available vertical space correctly.
This commit is contained in:
filemon
2026-04-06 01:15:11 -03:00
parent 0722d900a2
commit a2600d1caa
9 changed files with 140 additions and 172 deletions
+20 -33
View File
@@ -3,13 +3,7 @@
/**
* BlobbiCareRoom — The bathroom / hygiene room.
*
* Layout:
* - BlobbiRoomHero (Blobbi visual + stats)
* - Center: single-focus carousel with hygiene + medicine items
* - Bottom left: towel (RoomActionButton style)
* - Bottom right: shower (RoomActionButton style)
*
* Future: interactions could become drag-based.
* Bottom bar: Towel (left) | hygiene+medicine carousel (center) | Shower (right)
*/
import { useMemo } from 'react';
@@ -40,48 +34,41 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) {
[]);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
// Towel is shown as a dedicated button, not in the carousel
const towelItem = hygieneItems.find(i => i.id === 'hyg_towel');
// Carousel: hygiene (except towel) + medicine
const carouselEntries = useMemo<CarouselEntry[]>(() => {
const hygiene = getLiveShopItems()
.filter(i => i.type === 'hygiene' && i.id !== 'hyg_towel')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name }));
const medicine = getLiveShopItems()
.filter(i => i.type === 'medicine')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name }));
return [...hygiene, ...medicine];
}, []);
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Bottom Action Bar ── */}
{!isActiveFloatingCompanion && (
<div className="relative z-10 px-4 sm:px-8 pb-6 pt-2">
<div className="flex items-start justify-between">
{/* Bottom left — Towel */}
<div className="w-24 shrink-0">
{towelItem && (
<RoomActionButton
icon={<span className="text-3xl sm:text-4xl">{towelItem.icon}</span>}
label="Towel"
color="text-cyan-500"
glowHex="#06b6d4"
onClick={() => handleUseItemFromTab(towelItem.id)}
disabled={isDisabled}
loading={isUsingItem && usingItemId === towelItem.id}
/>
)}
</div>
<div className="relative z-10 px-3 sm:px-6 pb-4 sm:pb-6 pt-1">
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — Towel */}
{towelItem ? (
<RoomActionButton
icon={<span className="text-2xl sm:text-3xl">{towelItem.icon}</span>}
label="Towel"
color="text-cyan-500"
glowHex="#06b6d4"
onClick={() => handleUseItemFromTab(towelItem.id)}
disabled={isDisabled}
loading={isUsingItem && usingItemId === towelItem.id}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
{/* Center: single-focus carousel */}
{/* Center carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={carouselEntries}
@@ -91,9 +78,9 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) {
/>
</div>
{/* Bottom right — Shower */}
{/* Right — Shower */}
<RoomActionButton
icon={<ShowerHead className="size-9 sm:size-10" />}
icon={<ShowerHead className="size-7 sm:size-9" />}
label="Shower"
color="text-blue-500"
glowHex="#3b82f6"
@@ -119,24 +119,24 @@ export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) {
{/* ── Bottom Action Bar ── */}
{!isActiveFloatingCompanion && (
<div className="relative z-10 px-4 sm:px-8 pb-6 pt-2">
<div className="flex items-start justify-between">
{/* Bottom left — Blobbis selector */}
<div className="relative z-10 px-3 sm:px-6 pb-4 sm:pb-6 pt-1">
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — Blobbis selector */}
<RoomActionButton
icon={<Egg className="size-9 sm:size-10" />}
icon={<Egg className="size-7 sm:size-9" />}
label="Blobbis"
color="text-primary"
glowHex="var(--primary)"
onClick={() => setShowBlobbisPanel(true)}
badge={companions.length > 1 ? (
<span className="size-5 rounded-full bg-primary text-[10px] text-primary-foreground font-bold flex items-center justify-center">
<span className="size-4 sm:size-5 rounded-full bg-primary text-[9px] sm:text-[10px] text-primary-foreground font-bold flex items-center justify-center">
{companions.length}
</span>
) : undefined}
/>
{/* Center — Main hatch/evolve action */}
<div className="flex-1 flex flex-col items-center justify-center gap-1.5 pt-4">
<div className="flex-1 flex flex-col items-center justify-center gap-1.5">
{/* Active process: Hatch/Evolve CTA or progress */}
{hasActiveProcess && allCompleted && !isTasksLoading && (
<button
@@ -229,15 +229,15 @@ export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) {
)}
</div>
{/* Bottom right — Quests/Tasks */}
{/* Right — Quests/Tasks */}
<RoomActionButton
icon={<ListTodo className="size-9 sm:size-10" />}
icon={<ListTodo className="size-7 sm:size-9" />}
label="Quests"
color="text-amber-500"
glowHex="#f59e0b"
onClick={() => setShowQuestsPanel(true)}
badge={hasActiveProcess && totalCount - completedCount > 0 ? (
<span className="size-5 rounded-full bg-amber-500 text-[10px] text-white font-bold flex items-center justify-center">
<span className="size-4 sm:size-5 rounded-full bg-amber-500 text-[9px] sm:text-[10px] text-white font-bold flex items-center justify-center">
{totalCount - completedCount}
</span>
) : undefined}
+41 -45
View File
@@ -5,9 +5,8 @@
*
* Layout:
* - BlobbiRoomHero (stats crown, Blobbi visual, name)
* - Photo button (left) + Companion toggle (right) — same style as original
* - Center: single-focus carousel with toys + music + sing
* - Inline activity (music player, sing card) between hero and bottom bar
* - Unified bottom bar: Photo (left) | Carousel (center) | Companion (right)
* - Inline activity (music player, sing card) above the bottom bar
*
* Sleep/wake has been moved to BlobbiRestRoom.
*/
@@ -29,20 +28,16 @@ interface BlobbiHomeRoomProps {
export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
const {
isActiveFloatingCompanion,
// Photo
setShowPhotoModal,
// Companion
isCurrentCompanion,
canBeCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
// Items + actions
isUsingItem,
usingItemId,
handleUseItemFromTab,
handleDirectAction,
isDirectActionPending,
// Inline activity
inlineActivity,
handleConfirmSing,
handleCloseInlineActivity,
@@ -51,7 +46,6 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
handleSingRecordingStart,
handleSingRecordingStop,
handleChangeTrack,
// State
isPublishing,
actionInProgress,
} = ctx;
@@ -65,12 +59,12 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
const actions: CarouselEntry[] = [
{
id: '__action_music',
icon: <div className="size-12 rounded-full flex items-center justify-center bg-pink-500/15 text-pink-500"><Music className="size-6" /></div>,
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-pink-500/15 text-pink-500"><Music className="size-5 sm:size-6" /></div>,
label: 'Music',
},
{
id: '__action_sing',
icon: <div className="size-12 rounded-full flex items-center justify-center bg-purple-500/15 text-purple-500"><Mic className="size-6" /></div>,
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-purple-500/15 text-purple-500"><Mic className="size-5 sm:size-6" /></div>,
label: 'Sing',
},
];
@@ -95,33 +89,6 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
{/* ── Hero (Blobbi + stats) ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Action circles — Photo (left) + Companion (right) ── */}
{!isActiveFloatingCompanion && (
<div className="relative z-10 w-full px-4 sm:px-8 -mt-10 flex items-start justify-between">
{/* Photo */}
<RoomActionButton
icon={<Camera className="size-9 sm:size-10" />}
label="Photo"
color="text-pink-500"
glowHex="#ec4899"
onClick={() => setShowPhotoModal(true)}
/>
{/* Companion toggle */}
{canBeCompanion && (
<RoomActionButton
icon={<Footprints className="size-9 sm:size-10" />}
label={isCurrentCompanion ? 'With you' : 'Take along'}
color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'}
glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'}
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
loading={isUpdatingCompanion}
/>
)}
</div>
)}
{/* ── Inline Activity Area (music/sing) ── */}
{inlineActivity.type === 'music' && (
<div className="px-4 sm:px-6 pb-2">
@@ -148,15 +115,44 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
</div>
)}
{/* ── Bottom: Single-focus carousel ── */}
{/* ── Unified Bottom Bar: Photo | Carousel | Companion ── */}
{!isActiveFloatingCompanion && (
<div className="relative z-10 px-2 pb-4 pt-2">
<ItemCarousel
items={carouselItems}
onUse={handleCarouselUse}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
/>
<div className="relative z-10 px-3 sm:px-6 pb-4 sm:pb-6 pt-1">
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Photo */}
<RoomActionButton
icon={<Camera className="size-7 sm:size-9" />}
label="Photo"
color="text-pink-500"
glowHex="#ec4899"
onClick={() => setShowPhotoModal(true)}
/>
{/* Center carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={carouselItems}
onUse={handleCarouselUse}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
/>
</div>
{/* Companion toggle */}
{canBeCompanion ? (
<RoomActionButton
icon={<Footprints className="size-7 sm:size-9" />}
label={isCurrentCompanion ? 'With you' : 'Take along'}
color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'}
glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'}
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
loading={isUpdatingCompanion}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
</div>
</div>
)}
</div>
@@ -3,11 +3,7 @@
/**
* BlobbiKitchenRoom — The feeding room.
*
* Layout:
* - BlobbiRoomHero (Blobbi visual + stats)
* - Center: single-focus food carousel
* - Bottom right: fridge button (opens full food list modal)
* - Bottom left: empty for now
* Bottom bar: (empty left) | food carousel (center) | Fridge button (right)
*/
import { useMemo, useState } from 'react';
@@ -38,7 +34,6 @@ export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) {
const [showFridge, setShowFridge] = useState(false);
// Food carousel entries
const foodEntries = useMemo<CarouselEntry[]>(() =>
getLiveShopItems()
.filter(i => i.type === 'food')
@@ -47,7 +42,6 @@ export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) {
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
// Handler for using an item from the fridge modal
const handleFridgeUseItem = (itemId: string) => {
if (isUsingItem) return;
ctx.onUseItem(itemId, 'feed').finally(() => {
@@ -57,17 +51,15 @@ export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) {
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Bottom Action Bar ── */}
{!isActiveFloatingCompanion && (
<div className="relative z-10 px-4 sm:px-8 pb-6 pt-2">
<div className="flex items-start justify-between">
{/* Bottom left — empty */}
<div className="w-24 shrink-0" />
<div className="relative z-10 px-3 sm:px-6 pb-4 sm:pb-6 pt-1">
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — empty spacer matching button width */}
<div className="w-14 sm:w-20 shrink-0" />
{/* Center: single-focus food carousel */}
{/* Center: food carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={foodEntries}
@@ -77,9 +69,9 @@ export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) {
/>
</div>
{/* Bottom right — Fridge */}
{/* Right — Fridge */}
<RoomActionButton
icon={<Refrigerator className="size-9 sm:size-10" />}
icon={<Refrigerator className="size-7 sm:size-9" />}
label="Fridge"
color="text-orange-500"
glowHex="#f97316"
@@ -90,7 +82,6 @@ export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) {
</div>
)}
{/* ── Fridge Modal ── */}
{showFridge && (
<BlobbiActionInventoryModal
open={showFridge}
+13 -18
View File
@@ -3,12 +3,7 @@
/**
* BlobbiRestRoom — The bedroom / rest room.
*
* Dedicated to sleep / wake / resting.
* Layout:
* - BlobbiRoomHero (Blobbi visual + stats)
* - Bottom right: sleep / wake toggle (unified RoomActionButton style)
* - Bottom center: minimal for now
* - Bottom left: empty for now
* Bottom bar: (empty left) | (empty center) | Sleep/Wake (right)
*/
import { Moon, Sun, Loader2 } from 'lucide-react';
@@ -36,28 +31,26 @@ export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) {
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Bottom Action Bar ── */}
{!isActiveFloatingCompanion && (
<div className="relative z-10 px-4 sm:px-8 pb-6 pt-2">
<div className="flex items-start justify-between">
{/* Bottom left — empty for now */}
<div className="w-24 shrink-0" />
<div className="relative z-10 px-3 sm:px-6 pb-4 sm:pb-6 pt-1">
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — empty */}
<div className="w-14 sm:w-20 shrink-0" />
{/* Center — minimal for now */}
{/* Center — empty */}
<div className="flex-1" />
{/* Bottom right — Sleep / Wake */}
{!isEgg && (
{/* Right — Sleep / Wake */}
{!isEgg ? (
<RoomActionButton
icon={
actionInProgress === 'rest'
? <Loader2 className="size-9 sm:size-10 animate-spin" />
? <Loader2 className="size-7 sm:size-9 animate-spin" />
: isSleeping
? <Sun className="size-9 sm:size-10" />
: <Moon className="size-9 sm:size-10" />
? <Sun className="size-7 sm:size-9" />
: <Moon className="size-7 sm:size-9" />
}
label={isSleeping ? 'Wake up' : 'Sleep'}
color={isSleeping ? 'text-amber-500' : 'text-violet-500'}
@@ -65,6 +58,8 @@ export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) {
onClick={onRest}
disabled={isDisabled}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
</div>
</div>
+21 -22
View File
@@ -9,8 +9,10 @@
* - Blobbi name (hidden for eggs)
* - Bob/sway animation when not sleeping
*
* This component extracts the hero section that was previously inlined
* in BlobbiDashboard so all rooms can share it without duplication.
* Sizing is tuned so the hero doesn't crowd the bottom action bar:
* - Stats indicators are smaller on mobile (size-14 vs size-20)
* - Blobbi visual uses a tighter responsive scale
* - Stats crown margin is reduced
*/
import { useMemo } from 'react';
@@ -24,7 +26,7 @@ import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import { cn } from '@/lib/utils';
import type { BlobbiRoomContext } from '../lib/room-types';
// ─── Stat colour maps (same as original) ──────────────────────────────────────
// ─── Stat colour maps ─────────────────────────────────────────────────────────
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
hunger: 'orange',
@@ -70,11 +72,8 @@ const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; st
interface BlobbiRoomHeroProps {
ctx: BlobbiRoomContext;
/** Optional extra className on the outer container */
className?: string;
/** If true, hides the stats crown */
hideStats?: boolean;
/** If true, hides the name */
hideName?: boolean;
}
@@ -101,8 +100,8 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo
// When the companion is out floating, show "out exploring" instead
if (isActiveFloatingCompanion) {
return (
<div className={cn('flex flex-col items-center justify-center gap-6 text-center flex-1', className)}>
<Footprints className="size-16 text-muted-foreground/30" />
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
<Footprints className="size-12 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">
{companion.name} is out exploring right now.
</p>
@@ -110,16 +109,16 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
className={cn(
'flex items-center justify-center gap-2.5 px-8 py-3.5 rounded-full text-white font-semibold transition-all duration-300 ease-out',
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isUpdatingCompanion && 'opacity-50 pointer-events-none',
)}
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
>
{isUpdatingCompanion ? (
<Loader2 className="size-5 animate-spin" />
<Loader2 className="size-4 animate-spin" />
) : (
<Footprints className="size-5" />
<Footprints className="size-4" />
)}
<span>Bring {companion.name} home</span>
</button>
@@ -128,7 +127,7 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo
}
return (
<div ref={heroRef} className={cn('relative flex flex-col items-center justify-center px-4 sm:px-6 overflow-x-hidden flex-1', className)}>
<div ref={heroRef} className={cn('relative flex flex-col items-center justify-center px-4 sm:px-6 overflow-hidden flex-1 min-h-0', className)}>
<div className="relative flex flex-col items-center">
{/* Stats crown */}
{!hideStats && <StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />}
@@ -140,7 +139,7 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
} : undefined}
>
<div className="absolute inset-0 -m-24 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<div className="absolute inset-0 -m-16 sm:-m-24 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<BlobbiStageVisual
companion={companion}
size="lg"
@@ -150,8 +149,8 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
emotion={effectiveEmotion}
className={isEgg
? 'size-44 min-[400px]:size-52 sm:size-64 md:size-80 lg:size-96'
: 'size-64 min-[400px]:size-80 sm:size-96 md:size-[32rem] lg:size-[36rem]'
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-72 lg:size-80'
: 'size-48 min-[400px]:size-60 sm:size-80 md:size-[28rem] lg:size-[32rem]'
}
/>
</div>
@@ -159,7 +158,7 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo
{/* Blobbi Name — hidden for eggs */}
{!hideName && !isEgg && (
<h2
className="text-2xl sm:text-3xl font-bold text-center -mt-2"
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
style={{ color: companion.visualTraits.baseColor }}
>
{companion.name}
@@ -195,7 +194,7 @@ function StatsCrown({
const count = allStats.length;
const isSmall = heroWidth < 400;
const arcSpread = isSmall
? (count <= 2 ? 90 : count <= 3 ? 130 : 160)
? (count <= 2 ? 80 : count <= 3 ? 120 : 150)
: (count <= 2 ? 80 : count <= 3 ? 120 : 160);
const arcHalf = arcSpread / 2;
const angles = count === 1
@@ -203,11 +202,11 @@ function StatsCrown({
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
return (
<div className="relative flex items-end justify-center w-full mb-14" style={{ height: 48 }}>
<div className="relative flex items-end justify-center w-full mb-6 sm:mb-10" style={{ height: 40 }}>
{allStats.map((s, i) => {
const angleDeg = angles[i];
const angleRad = (angleDeg * Math.PI) / 180;
const radius = Math.min(210, Math.max(140, (heroWidth - 340) / (640 - 340) * (210 - 140) + 140));
const radius = Math.min(180, Math.max(100, (heroWidth - 340) / (640 - 340) * (180 - 100) + 100));
const x = Math.sin(angleRad) * radius;
const y = Math.cos(angleRad) * radius - radius;
@@ -251,7 +250,7 @@ function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorP
return (
<div className={cn(
'relative size-[4.5rem] sm:size-20 rounded-full flex items-center justify-center',
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
)}>
@@ -265,10 +264,10 @@ function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorP
/>
</svg>
<div className="relative">
{IconComponent && <IconComponent className={cn('size-6 sm:size-7', STAT_COLORS[color])} strokeWidth={2.5} />}
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
<AlertTriangle
className={cn('absolute -top-1.5 -right-2 size-3.5', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
strokeWidth={3}
/>
)}
@@ -147,7 +147,7 @@ export function BlobbiRoomShell({
The `direction` in nav state tells which way to animate.
For now, we simply render the active room instantly.
*/}
<div className="flex-1 min-h-0 relative">
<div className="flex-1 min-h-0 flex flex-col relative">
<RoomComponent ctx={ctx} />
</div>
+17 -17
View File
@@ -3,11 +3,10 @@
/**
* ItemCarousel — Single-focus carousel for room items.
*
* Shows one main item at centre with translucent prev/next previews.
* Left/right arrows cycle through items in a loop.
* Mobile: focused item only + compact arrows (no prev/next previews)
* Desktop: focused item + translucent prev/next previews + arrows
*
* Each "item" is an opaque entry with id, icon, label, and an onUse callback.
* The carousel doesn't know about shop items or actions — the parent maps them.
*/
import { useState, useCallback } from 'react';
@@ -58,7 +57,7 @@ export function ItemCarousel({
if (count === 0) {
return (
<div className={cn('flex items-center justify-center py-4', className)}>
<div className={cn('flex items-center justify-center py-3', className)}>
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
</div>
);
@@ -68,15 +67,16 @@ export function ItemCarousel({
const prevItem = items[(index - 1 + count) % count];
const nextItem = items[(index + 1) % count];
const isThisActive = activeItemId === current.id;
const showPreviews = count >= 3;
return (
<div className={cn('flex items-center justify-center gap-1', className)}>
<div className={cn('flex items-center justify-center', className)}>
{/* Left arrow */}
<button
onClick={prev}
disabled={disabled}
className={cn(
'size-8 rounded-full flex items-center justify-center shrink-0',
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
@@ -86,9 +86,9 @@ export function ItemCarousel({
<ChevronLeft className="size-4" />
</button>
{/* Preview (prev) — only when 3+ items */}
{count >= 3 && (
<div className="flex flex-col items-center opacity-30 scale-75 shrink-0 w-12 pointer-events-none select-none">
{/* Preview (prev) — desktop only, 3+ items */}
{showPreviews && (
<div className="hidden sm:flex flex-col items-center opacity-25 scale-[0.65] shrink-0 w-10 pointer-events-none select-none">
<span className="text-2xl leading-none">{prevItem.icon}</span>
</div>
)}
@@ -98,24 +98,24 @@ export function ItemCarousel({
onClick={() => onUse(current.id)}
disabled={disabled}
className={cn(
'relative flex flex-col items-center gap-1 py-2 px-4 rounded-2xl transition-all duration-200 shrink-0',
'relative flex flex-col items-center gap-0.5 py-1 px-3 sm:px-4 rounded-2xl transition-all duration-200 shrink-0',
'hover:-translate-y-0.5 active:scale-95',
isThisActive && 'bg-accent/40',
disabled && !isThisActive && 'opacity-50 pointer-events-none',
)}
>
<span className="text-5xl sm:text-6xl leading-none transition-transform duration-200 hover:scale-110">
<span className="text-4xl sm:text-5xl leading-none transition-transform duration-200 hover:scale-110">
{current.icon}
</span>
<span className="text-xs font-medium text-foreground/70">{current.label}</span>
<span className="text-[10px] sm:text-xs font-medium text-foreground/70">{current.label}</span>
{isThisActive && (
<Loader2 className="size-4 animate-spin text-primary absolute -bottom-0.5" />
<Loader2 className="size-3.5 animate-spin text-primary absolute -bottom-1" />
)}
</button>
{/* Preview (next) — only when 3+ items */}
{count >= 3 && (
<div className="flex flex-col items-center opacity-30 scale-75 shrink-0 w-12 pointer-events-none select-none">
{/* Preview (next) — desktop only, 3+ items */}
{showPreviews && (
<div className="hidden sm:flex flex-col items-center opacity-25 scale-[0.65] shrink-0 w-10 pointer-events-none select-none">
<span className="text-2xl leading-none">{nextItem.icon}</span>
</div>
)}
@@ -125,7 +125,7 @@ export function ItemCarousel({
onClick={next}
disabled={disabled}
className={cn(
'size-8 rounded-full flex items-center justify-center shrink-0',
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
@@ -3,12 +3,12 @@
/**
* RoomActionButton — Unified circular action button for all rooms.
*
* Matches the visual language of the original Photo and Companion buttons:
* - Large rounded-full circle with soft radial glow background
* - Icon centred inside
* - Label beneath
* - Hover lift + scale, active scale-down
* - Consistent size across all rooms
* Responsive sizing:
* - Mobile: size-14 circle, size-7 icons
* - Desktop (sm+): size-20 circle, size-9 icons
*
* Matches the soft radial glow of the original Photo / Companion buttons
* but at a smaller scale so the bottom bar feels proportional on mobile.
*/
import { Loader2 } from 'lucide-react';
@@ -48,7 +48,7 @@ export function RoomActionButton({
onClick={onClick}
disabled={disabled}
className={cn(
'flex flex-col items-center gap-1.5 transition-all duration-300 ease-out',
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
'hover:-translate-y-1 hover:scale-110 active:scale-95',
disabled && 'opacity-50 pointer-events-none',
className,
@@ -56,13 +56,13 @@ export function RoomActionButton({
>
<div className="relative">
<div
className={cn('size-20 sm:size-24 rounded-full flex items-center justify-center', color)}
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
style={{
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
}}
>
{loading ? (
<Loader2 className="size-9 sm:size-10 animate-spin" />
<Loader2 className="size-7 sm:size-9 animate-spin" />
) : (
icon
)}
@@ -73,7 +73,7 @@ export function RoomActionButton({
</div>
)}
</div>
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
</button>
);
}