Compare commits

...

383 Commits

Author SHA1 Message Date
Chad Curtis 3ee880d1dd release: v2.2.0 2026-03-28 08:00:41 -05:00
Chad Curtis 586e103161 Merge branch 'fix/zap-primal-users' into 'main'
Fixing zap Primal users without error

See merge request soapbox-pub/ditto!110
2026-03-28 12:55:40 +00:00
Chad Curtis 5776bf2a51 Merge branch 'fix/remote-signer-ux-improvements' into 'main'
feat: remote signer UX improvements for Amber/NIP-46 users on Android

See merge request soapbox-pub/ditto!128
2026-03-28 12:51:15 +00:00
Chad Curtis ceb442ebf1 Merge remote-tracking branch 'origin/main' into fix/remote-signer-ux-improvements
# Conflicts:
#	src/hooks/useCurrentUser.ts
#	src/index.css
2026-03-28 07:47:26 -05:00
Chad Curtis e6a2bdc65f Clean up nsec paste guard toast wording for clarity 2026-03-28 07:37:59 -05:00
Chad Curtis c9205adbab Merge branch 'fix/nsec-paste-guard' into 'main'
feat: block nsec private key paste with warning

See merge request soapbox-pub/ditto!101
2026-03-28 12:37:28 +00:00
Chad Curtis 29d56daab3 fix: relay page arc header with inline NIP-11 info panel
- Use PageHeader + SubHeaderBar arc format matching other feed pages
- Add arc overhang spacer for consistent feed padding
- Move NIP-11 relay info into an inline expanding panel (maxHeight transition)
- Info toggle button in PageHeader top-right corner
- Accept string | undefined in useRelayInfo hook signature
- Register useLayoutOptions({ hasSubHeader: true }) for mobile nav
2026-03-28 07:28:32 -05:00
Chad Curtis 4d7ac5e619 Merge branch 'feat/relay-info' into 'main'
Adding NIP-11 relay information on network settings page

See merge request soapbox-pub/ditto!106
2026-03-28 12:11:44 +00:00
Chad Curtis fb3686fef4 Merge branch 'fix/embedded-note-link-preview' into 'main'
Show link preview cards in quoted posts

See merge request soapbox-pub/ditto!107
2026-03-28 12:05:15 +00:00
Chad Curtis ed14ef0cd9 Merge branch 'fix/notifications-136' into 'main'
Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts

Closes #136

See merge request soapbox-pub/ditto!102
2026-03-28 12:04:20 +00:00
Chad Curtis fd90f90cbb Restore client-side reply count seeding with correct nip85-event-stats cache key 2026-03-28 06:59:01 -05:00
Chad Curtis b6cee104b9 Filter Mentions tab by literal nostr: URI mentions in content 2026-03-28 06:49:19 -05:00
Chad Curtis 5c6df95734 Fix review issues: remove duplicate subscription, fix zapAmount regression, remove dead cache seeding 2026-03-28 06:40:58 -05:00
Chad Curtis 8941aca968 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/notifications-136
# Conflicts:
#	src/hooks/useHasUnreadNotifications.ts
#	src/hooks/useTrending.ts
#	src/pages/NotificationsPage.tsx
2026-03-28 06:32:45 -05:00
Chad Curtis e1348f782e refactor: deduplicate theme dark/light detection into colorUtils
Extract getBackgroundThemeMode() and getBackgroundHex() into colorUtils.ts,
replacing duplicated CSS variable reading and luminance calculations in
EmojiPicker, main.tsx status bar, and TweetEmbed. Also fixes TweetEmbed
incorrectly treating custom themes as always light.
2026-03-28 06:27:21 -05:00
Chad Curtis b652976784 Merge branch 'fix/emoji-picker-autofocus' into 'main'
Fix: Auto-focus search box when emoji picker opens

Closes #162

See merge request soapbox-pub/ditto!114
2026-03-28 11:08:30 +00:00
Chad Curtis 530c0681d0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/emoji-picker-autofocus
# Conflicts:
#	src/components/EmojiPicker.tsx
2026-03-28 06:01:21 -05:00
Chad Curtis e38f57f823 Merge branch 'fix/hashtag-query-eq' into 'main'
Fixing query case variants for tag feed parity with search

See merge request soapbox-pub/ditto!111
2026-03-28 10:57:46 +00:00
Chad Curtis f3b9eb9f73 Merge branch 'fix/disable-notifications' into 'main'
Fix notification preferences not filtering based on settings

See merge request soapbox-pub/ditto!93
2026-03-28 10:55:25 +00:00
Chad Curtis bd7be9590a Merge branch 'fix/feed-header-ordering' into 'main'
Fix: Consistent feed header ordering — title above tabs on all feed pages

Closes #153, #155, #156, #157, #158, and #159

See merge request soapbox-pub/ditto!112
2026-03-28 10:51:29 +00:00
Chad Curtis e1fa43c9f0 fix: add arc overhang spacer to BadgesPage to match other feeds 2026-03-28 05:48:07 -05:00
Chad Curtis ccb0d9ec71 fix: add background to PageSkeleton so it matches feed appearance 2026-03-28 05:46:41 -05:00
Chad Curtis eca4a5ba77 fix: PageHeader padding, opacity, and BooksPage search bar overlap
- Equal vertical padding (py-4) on PageHeader for balanced spacing
- Add bg-background/85 to PageHeader to match SubHeaderBar opacity
- Add top padding to BookSearchBar so it clears the arc overhang
2026-03-28 05:39:48 -05:00
Chad Curtis 6dd29c571f refactor: extract useInfiniteScroll hook and deduplicateEvents utility
Consolidate duplicated infinite-scroll boilerplate (auto-fetch page 2,
IntersectionObserver, scroll trigger) into a shared useInfiniteScroll
hook, and extract the flatten+dedup pattern into deduplicateEvents.

Also migrate BadgesPage and ThemesPage to use useFeedTab for
consistent tab persistence across all feed pages.
2026-03-28 05:31:49 -05:00
Chad Curtis 28f1e2b517 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering 2026-03-28 05:14:26 -05:00
Chad Curtis 21374b2cb4 Make vines desktop immersive like mobile, with floating tab bar and back button 2026-03-28 05:13:21 -05:00
Chad Curtis ada87468cc Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering
# Conflicts:
#	src/pages/BadgesPage.tsx
2026-03-28 05:00:19 -05:00
Chad Curtis 0a4b488d69 Merge branch 'fix/vines-feed-header' into 'main'
Fix: Add Vines feed section header with info link to divine.video

Closes #154

See merge request soapbox-pub/ditto!113
2026-03-28 09:57:38 +00:00
Chad Curtis aa257b34ec Fix bottom nav flashing during vine swipe transitions
Remove the activeVinePlaying reset on index change. The old card's
onPlayingChange is already undefined after re-render, and the new
card's autoplay fires onPlay directly, so the state stays consistent
through transitions without a brief false→true flash.
2026-03-28 04:43:52 -05:00
Chad Curtis c48e6c7123 Add safe area insets to vine card overlay UI
Offset the bottom info strip, action sidebar, and mute button by
env(safe-area-inset-bottom) so they clear the home indicator on
notch/island devices. Applied to both the live UI and loading
skeleton.
2026-03-28 04:43:14 -05:00
Chad Curtis 827bc4b836 Show bottom nav when vine is paused, hide during playback
Lift playing state from VineCard to VinesFeedPage via onPlayingChange
callback. hideBottomNav is now driven by whether the active vine is
playing, so users can navigate when paused. Reset playing state on
swipe so the nav briefly appears while the next vine loads. Remove
noArcs so the bottom nav renders with its normal arc appearance.
2026-03-28 04:41:40 -05:00
Chad Curtis c7d115f873 Make vines page fully immersive on mobile
Hide the mobile top bar and bottom nav entirely on the vines page,
replacing them with a floating TikTok-style tab bar that overlays
directly on the video. The menu button (hamburger) is embedded in
the floating bar so users can still access navigation.

- Add hideTopBar and hideBottomNav layout options
- Add DrawerContext so pages can open the mobile drawer directly
- Move floating tab bar outside the scroll container to fix
  IntersectionObserver index tracking (autoplay on next video)
- Simplify vine-slide-height CSS to use full 100dvh
2026-03-28 04:36:41 -05:00
Alex Gleason 45c585a27d fix: add Vines feed section header with info link to divine.video
Fixes #154
2026-03-28 04:20:30 -05:00
Alex Gleason dc3fe02767 Upgrade Radix UI packages to fix infinite render loop with React 19
@radix-ui/react-popper 1.2.4 had a useEffect with no dependency array
that called setState (onAnchorChange) on every render, causing an
infinite loop. Fixed in 1.2.8 by tracking the previous anchor value
in a ref and only calling setState when it changes.

Upgraded all Radix packages that depend on react-popper:
- react-tooltip 1.2.4 -> 1.2.8
- react-popover 1.1.11 -> 1.1.15
- react-dropdown-menu 2.1.12 -> 2.1.16
- react-context-menu 2.2.12 -> 2.2.16
- react-hover-card 1.1.11 -> 1.1.15
- react-select 2.2.2 -> 2.2.6
- react-menubar 1.1.12 -> 1.1.16
- react-navigation-menu 1.2.10 -> 1.2.14
2026-03-28 02:29:17 -05:00
Lemon a1ef06510e Remove stale DEFAULT_KINDS fallback, skip polling until JS configures kinds 2026-03-28 00:20:21 -07:00
Alex Gleason 56002c68ca Add eslint rule to prohibit import.meta.glob usage 2026-03-28 02:19:55 -05:00
Alex Gleason 30bd73f8f9 Replace import.meta.glob with inlined SVG constants for Shakespeare compatibility
import.meta.glob is Vite-only and crashes in Shakespeare's esbuild bundler.
Generated baby-svg-data.ts and adult-svg-data.ts with SVG content as template
literal constants, keeping the existing resolver API unchanged.
2026-03-28 02:18:26 -05:00
Lemon 9d8a30f678 Add badge awards push notification template and pref key mapping 2026-03-28 00:13:21 -07:00
Lemon 2b0b99d598 Fix letters push toggle not propagating to nostr-push server 2026-03-28 00:07:28 -07:00
Lemon 8551852c9d Add badges and letters kinds to shared notificationKinds helper 2026-03-28 00:00:20 -07:00
Lemon 7ade0eaeb1 Clean up notification follow-filter code: remove dead ref, fix filter typing 2026-03-27 23:58:31 -07:00
Lemon debdbf770b Respect 'only from people I follow' in web push notifications
Send authors: ['$contacts'] in the nostr-push subscription filter
when onlyFollowing is enabled. The nostr-push server resolves this
macro to the user's kind 3 follow list. Toggling the setting off
removes the authors filter so all notifications are delivered again.
2026-03-27 23:58:31 -07:00
Lemon 01976685e8 Respect 'only from people I follow' in unread notification dot
Apply the same authors filter as useNotifications so the nav dot
won't appear for notifications from non-followed accounts.
2026-03-27 23:58:31 -07:00
Lemon bac5d71480 Respect 'only from people I follow' in native Android notifications
Pass followed pubkeys through the Capacitor plugin to the native
polling service. When onlyFollowing is enabled, the relay query
includes an authors filter so only events from followed accounts
trigger native Android notifications.
2026-03-27 23:58:31 -07:00
Lemon 0ad655d1cf Fix notification preferences not filtering push notifications or unread dot
Disabled notification types (e.g. reactions) still triggered push
notifications and showed the unread dot indicator, even though the
notification tab correctly filtered them out.

Three root causes fixed:
- useHasUnreadNotifications now uses getEnabledNotificationKinds to
  only query for enabled types, preventing phantom unread dots
- NotificationSettings now syncs type preference changes to the
  nostr-push server via updateSubscription (is_active toggle)
- Native Android poller now receives enabled kinds from the JS layer
  and uses them in the relay filter instead of hardcoded kinds
2026-03-27 23:58:31 -07:00
Chad Curtis a526e301da Fix MobileTopBar arc lingering when hidden on pages without tabs
The hide transform was missing the 20px arc overhang, so the bottom
curve remained visible after the bar slid up. Match the nav-hidden-slide
approach used by SubHeaderBar.
2026-03-28 00:46:29 -05:00
Chad Curtis 9c16c6df40 Fix MobileTopBar arc position with safe-area inset on native
The ArcBackground was positioned over the entire header including the
safe-area padding, causing the arc curve to sit too high on native apps.
Wrap content in a relative container so the arc only covers the content
area, and add bg-background/85 on the header to fill the safe-area region.
2026-03-28 00:46:29 -05:00
Chad Curtis 3220e9482b Fix profile and compose letter tabs hiding with mobile top bar
Add 'pinned' prop to SubHeaderBar that transitions top to 0 instead
of sliding off-screen when the nav hides. Applied to ProfilePage tabs
and ComposeLetterSheet toolbar.
2026-03-28 00:46:29 -05:00
Alex Gleason 9a033d7f91 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-28 00:38:33 -05:00
Chad Curtis 2329458a84 Fix settings letters section, drawer background/z-index, and top nav scroll hide
- Remove letters section from settings page (accessible from letters page)
- Add background to letter editor drawer panel
- Fix drawer z-index so letter content doesn't bleed through
- Fix compose sheet SubHeaderBar top offset in overlay context
- Hide top bar and sub-header tabs together on scroll down
2026-03-28 00:36:07 -05:00
Alex Gleason c613a7aedd npm audit fix 2026-03-28 00:30:35 -05:00
Alex Gleason d4d502f418 Loosen engines constraint to node >=22 2026-03-28 00:30:14 -05:00
Alex Gleason 7f37f16c7b Upgrade React from 18.3 to 19.2
- Upgrade react, react-dom to ^19.2.4 and @types/react, @types/react-dom to v19
- Upgrade @nostrify/react to 0.4.0 (peer deps fix for React 19)
- Upgrade vaul to 1.1.2 and react-day-picker to 9.14.0 for React 19 compatibility
- Fix useRef() calls to pass explicit initial values (required in React 19)
- Update RefObject types to include null (React 19 type change)
- Rewrite Calendar component for react-day-picker v9 classNames API
- Add npm overrides for @emoji-mart/react (only remaining React 18 holdout)
2026-03-28 00:29:37 -05:00
Chad Curtis a67d007435 Merge branch 'fix/badge-profile-and-give-badge' into 'main'
Add top badges to profile and 'Give badge' to profile menu

Closes #189 and #185

See merge request soapbox-pub/ditto!121
2026-03-28 05:19:02 +00:00
Chad Curtis a5c6645d2d Fix GiveBadgeDialog bugs and DRY up ProfileMoreMenu handlers 2026-03-28 00:16:18 -05:00
Alex Gleason cbe50a0232 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-28 00:01:19 -05:00
Alex Gleason a48ac48202 Trim Sentry bundle by tree-shaking unused replay/feedback/canvas modules
Use named imports in the dynamic import of @sentry/react so the bundler
can drop re-exported modules we never reference (replay 207K, feedback 67K,
replay-canvas 25K). Also set defaultIntegrations: undefined to prevent
Sentry from pulling those modules at runtime.

Sentry chunk: 431K → 128K (-70%).
2026-03-28 00:00:21 -05:00
Mary Kate Fain 0a7aaca6e5 Move badges from sidebar to profile bio section
Display badge preview inline in the profile bio area (after the
about text) as a horizontal row of thumbnails, matching the style
used in the profile hover card. This works on both desktop and
mobile since it's part of the main profile content.
2026-03-27 23:47:01 -05:00
Mary Kate Fain 6e41ea3b42 Add badges to profile sidebar and 'Give badge' to profile menu
- Show accepted badges in the profile right sidebar (fixes #189)
- Add 'Give badge' option to the profile 3-dot overflow menu,
  allowing users to award their created badges directly from
  a user's profile (fixes #185)
2026-03-27 23:47:01 -05:00
Chad Curtis 75ada621d9 Merge branch 'fix/font-picker-click-through' into 'main'
Fix: Prevent font picker clicks from passing through to theme selector

Closes #171

See merge request soapbox-pub/ditto!115
2026-03-28 04:44:53 +00:00
Alex Gleason 12d578ff57 Improve bundle chunking: lazy-load emoji picker, markdown, and remove runtime tailwind config
- Hardcode md breakpoint in useIsMobile and toaster to eliminate runtime
  import of tailwind.config (was pulling in tailwindcss, postcss-selector-parser,
  and plugin code ~100KB)
- Lazy-load EmojiPicker in ComposeBox (emoji-mart + data ~500KB deferred)
- Dynamic import webxdcMeta.ts (smol-toml + fflate only loaded for .xdc uploads)
- Lazy-load ArticleContent, PullRequestCard, CustomNipCard in NoteCard and
  PostDetailPage (react-markdown + unified pipeline ~147KB deferred)
- Consolidate 60+ lucide-react icon micro-chunks into a single chunk via
  manualChunks, reducing HTTP request overhead
- ReplyComposeModal chunk: 808KB -> 296KB (-63%)
- JS file count: 226 -> 181 (-45 files)
2026-03-27 23:44:20 -05:00
Chad Curtis 3486b7f503 Merge remote-tracking branch 'origin/main' into fix/font-picker-click-through
# Conflicts:
#	src/components/FontPicker.tsx
2026-03-27 23:43:46 -05:00
Chad Curtis 0aed5e0f31 Merge branch 'fix/emoji-picker-contrast' into 'main'
Fix: Improve contrast on focused emoji category text in picker

Closes #174

See merge request soapbox-pub/ditto!116
2026-03-28 04:41:26 +00:00
Alex Gleason 34c40980e3 Merge branch 'remove-dev-buttons' into 'main'
Removing dev buttons

See merge request soapbox-pub/ditto!127
2026-03-28 03:09:08 +00:00
Alex Gleason b13eb6012c Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:59:00 -05:00
Alex Gleason ecc3284a94 Improve code splitting: reduce index chunk from 1021K to 494K
- Lazy-load BlobbiCompanionLayer (~450K blobbi code off critical path)
- Split BlobbiActionsProvider into lightweight file to avoid pulling
  heavy blobbi action system into the index chunk
- Fix HomePage eagerly importing all 18 page components; use lazy()
  so only the configured homepage's chunk is loaded
- Lazy-load ReplyComposeModal in AppRouter and FloatingComposeButton
  to defer emoji-mart (~620K) until compose is opened
- Fix barrel import in App.tsx pulling BlobbiDevEditor into index;
  use direct import from EmotionDevContext instead
2026-03-27 20:56:36 -05:00
filemon b10c8ff182 Hide Blobbi dev controls on deployed environments
Add isLocalhostDev() helper that checks both import.meta.env.DEV AND
hostname (localhost/127.0.0.1/0.0.0.0). This ensures dev buttons only
appear during local development, never on deployed apps.

Dev controls now hidden in production:
- Dev Hatch/Evolve instant transition buttons
- Dev State Editor button
- Dev Emotion Tester button
2026-03-27 22:40:17 -03:00
Alex Gleason d96e222a15 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:40:02 -05:00
filemon ec63533108 Merge branch 'main' into remove-dev-buttons 2026-03-27 22:32:47 -03:00
Alex Gleason 6f74366dd9 Restore code splitting with React.lazy for all non-critical page routes
Convert 44 page imports from static to React.lazy() with dynamic imports.
Only HomePage, Index, and NotFound remain eagerly loaded as critical-path
pages. The existing Suspense boundary in MainLayout (with PageSkeleton
fallback) already wraps the content area, so lazy pages show a skeleton
while loading without affecting the sidebar or navigation.
2026-03-27 20:30:32 -05:00
filemon 4b5825790a Fix daily missions UI not updating after reroll/claim
Replace useLocalStorage with direct localStorage reads that re-trigger
when the 'daily-missions-updated' event fires. The previous approach
cached state internally and didn't see same-tab localStorage writes.

Now when mutations write to localStorage and dispatch the event:
- Version counter bumps
- useMemo re-reads from localStorage
- UI updates immediately without page refresh
2026-03-27 22:29:20 -03:00
Alex Gleason c257e61fa7 Add bundle analyzer (rollup-plugin-visualizer) to the build 2026-03-27 20:26:21 -05:00
filemon f7391c0e0b Rebalance daily mission rewards to match shop economy
- Increase easy mission rewards to 25-30 coins
- Increase medium mission rewards to 45-50 coins
- Increase high-effort missions (photo, multiple feeds) to 55-70 coins
- Increase medicine missions to 60-70 coins (since medicine costs coins)
- Increase Daily Champion bonus from 50 to 80 coins

Target daily economy: 130-180 coins normal day, 210-250 with bonus
2026-03-27 22:24:48 -03:00
Alex Gleason dce3d5b411 Merge branch 'feat-blobbi' into 'main'
Blobbi: add core pet system, interactions, missions, etc

See merge request soapbox-pub/ditto!104
2026-03-28 01:23:11 +00:00
Alex Gleason a2490da3b4 Add badge kinds (30009, 30008) to the Ditto tab feed 2026-03-27 20:22:02 -05:00
Alex Gleason 0bd4877dd3 release: v2.1.1 2026-03-27 20:22:00 -05:00
filemon 1aacc0073f Fix egg-compatible missions and add medicine missions for reroll
Mission pool changes:
- Add 'medicine' action type for giving medicine to Blobbi
- Add medicine_1 (30 coins) and medicine_2 (50 coins) missions
- Mark clean, sing, play_music, medicine as available for ALL stages (egg+baby+adult)
- Keep interact, feed, sleep, take_photo as baby/adult only

Egg users now have 8 valid missions:
- clean_1, clean_2
- sing_1, sing_2
- play_music_1, play_music_2
- medicine_1, medicine_2

This ensures egg-only users always have alternatives when rerolling
(need at least 4 missions for 3 daily + 1 reroll target)
2026-03-27 22:14:40 -03:00
Alex Gleason 6195ae6901 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:10:58 -05:00
filemon 6f2d80b99e Fix mission reroll - expand pool and add state migration
- Expand DAILY_MISSION_POOL from 7 to 15 missions with more variety
  - Multiple difficulty tiers for feed, clean, interact, sing, play_music, take_photo
  - Ensures always having alternatives when rerolling
- Fix selectReplacementMission to properly exclude only active missions
- Add state migration for rerollsRemaining in both hooks
  - Old localStorage states without rerollsRemaining now get 3 rerolls
- Improve getRerollsRemaining to handle undefined/null values
- Better error message when pool is exhausted
2026-03-27 22:10:40 -03:00
filemon a8e7901eac Add mission reroll feature for daily missions
- Add rerollsRemaining to DailyMissionsState (max 3 per day, resets daily)
- Add rerollMission() function with stage-aware replacement selection
- Replacement avoids duplicates and the mission being replaced
- Add useRerollMission hook for mutation with toast feedback
- Add reroll button (RefreshCw icon) to incomplete missions
- Show remaining rerolls count at top of mission list
- Disable reroll for completed/claimed missions
- Bonus mission still works correctly after rerolling
2026-03-27 22:01:50 -03:00
filemon d4ae9d9611 Add stage-based daily mission filtering and bonus mission reward
- Add requiredStages property to mission definitions (all current missions require baby/adult)
- Update selectDailyMissions() to filter by user's available Blobbi stages
- Show 'Hatch Your Blobbi First' message when user only has eggs
- Add 50-coin 'Daily Champion' bonus mission after completing all regular missions
- Bonus mission appears locked until all regular missions are completed
- Update useClaimMissionReward hook to support claiming bonus rewards
- Pass availableStages through modal props for proper filtering
2026-03-27 21:56:41 -03:00
filemon d82a3cffe8 Allow empty name input during Blobbi egg adoption editing
Previously, clearing the name input would immediately restore it to 'Egg',
which made for a frustrating UX when trying to fully clear and retype.

Now:
- Name input can be fully cleared while editing
- Validation error shows when name is empty
- Adopt button is disabled when name is empty/whitespace
- Only validate on submit, not on every keystroke
2026-03-27 21:43:06 -03:00
filemon 05e189b938 Fix SVG ID prefix to use full Blobbi ID instead of first 8 chars
The previous fix used instanceId.slice(0, 8) for the prefix, but since
Blobbi IDs have the format 'blobbi-{pubkeyPrefix12}-{petId10}', the first
8 characters are always 'blobbi-' for all Blobbis owned by the same user.

This caused gradient ID collisions between different Blobbis.

Now using the full sanitized instanceId as the prefix:
  b_blobbi-abc123456789-xyz1234567

This ensures each Blobbi gets truly unique SVG IDs.
2026-03-27 21:39:01 -03:00
filemon d32d0b17d0 Fix gradient ID collisions causing wrong colors in Blobbi modal list
When multiple Blobbis are rendered on the same page (like in the selector modal),
they all shared the same SVG gradient IDs (e.g., cattiBody3D, blobbiBodyGradient).
The browser only uses the first definition of each ID, so all subsequent Blobbis
would use the first one's colors instead of their own.

Fixed by:
- Adding uniquifySvgIds() function to both adult and baby SVG customizers
- Generating unique prefixes from each Blobbi's ID (first 8 characters)
- Prefixing all SVG IDs and updating all references (url(), href, xlink:href)
2026-03-27 21:28:04 -03:00
Alex Gleason 3a8282255c Revert "Hide top nav on scroll and unify top padding across pages"
This reverts commit 488ce5750d.

Restores sticky (non-fixed) mobile top bar, removes scroll-based
hide/show behavior, reverts pt-mobile-bar back to -mt-mobile-bar
negative margin approach, and removes ARC_OVERHANG_PX spacers from
NotificationsPage and ProfilePage.
2026-03-27 19:26:14 -05:00
Mary Kate 79e97fae09 Merge branch 'fix/toast-swipe' into 'main'
Fix toast swipe direction on mobile

See merge request soapbox-pub/ditto!120
2026-03-28 00:18:25 +00:00
Mary Kate Fain 9b93881663 Merge remote-tracking branch 'origin/main' into fix/toast-swipe
# Conflicts:
#	src/components/ui/toast.tsx
#	src/components/ui/toaster.tsx
2026-03-27 19:14:08 -05:00
Mary Kate 21df47eccb Merge branch 'fix/compose-preview-overflow' into 'main'
Fix compose preview overflow not being scrollable in modal

Closes #179

See merge request soapbox-pub/ditto!126
2026-03-28 00:07:25 +00:00
filemon 1d87315426 Adjust eyebrow position higher for owli and froggi forms 2026-03-27 21:01:17 -03:00
Alex Gleason 4a1e21e820 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 19:01:02 -05:00
Alex Gleason a8181c45d0 Regenerate iOS app icon 2026-03-27 18:59:59 -05:00
Alex Gleason 84ca17ebc4 Add iOS icon generation to generate-icons.sh 2026-03-27 18:54:51 -05:00
filemon 4db0e8870d Fix clipPath ID collision causing eyes to disappear with multiple Blobbis 2026-03-27 20:54:06 -03:00
Mary Kate Fain 310993d57c Fix compose preview overflow not being scrollable in modal 2026-03-27 18:51:14 -05:00
Mary Kate d624b93d8c Merge branch 'fix/mobile-emoji-picker-close' into 'main'
Fix compose modal closing when dismissing emoji picker on mobile

See merge request soapbox-pub/ditto!125
2026-03-27 23:46:40 +00:00
Mary Kate Fain 872d319220 Fix compose modal closing when dismissing emoji picker on mobile
Prevent the compose modal from being accidentally dismissed when the user
taps the emoji/GIF picker overlay to close it.  On mobile this was very
easy to trigger, causing the draft to be lost.

Add onInteractOutside and onEscapeKeyDown handlers to the compose modal's
DialogContent that detect when a nested dialog (emoji picker) is open and
prevent the dismiss event from propagating to the parent modal.
2026-03-27 18:31:58 -05:00
Mary Kate cd44ae6bc0 Merge branch 'fix/notification-kind-labels' into 'main'
Use kind-specific labels in notification action text instead of generic 'post'

See merge request soapbox-pub/ditto!124
2026-03-27 23:25:35 +00:00
filemon deb59b314b Fix adult eye layering by excluding EyeBase gradients from eye white detection
The isEyeWhiteElement function was incorrectly matching colored eye rim gradients
like froggiEyeBase3D (green frog eye bulge) because it matched any gradient with
'Eye' in the name. This caused the eyelid to be placed behind the eye base layer,
making only the eyelid visible.

Now the detection:
- EXCLUDES EyeBase patterns (colored eye rims)
- INCLUDES EyeWhite patterns (actual white of eye)
- INCLUDES generic Eye gradients without 'Base' (baby Blobbi, etc.)
2026-03-27 20:25:32 -03:00
filemon 4c75d4f559 Add missing form-specific color customizers for adult Blobbi SVGs
Complete the color mapping system by adding customizers for:
- breezy: body, inner, veins, arms, legs, floating leaves
- bloomi: all 6 petals with color variations, center, pollen
- cacti: body, arms (pot keeps original red)
- cloudi: body, highlights, raindrops
- crysti: body, inner (facets keep colorful nature)
- owli: body, ears, wings (beak keeps yellow/orange)

Pandi intentionally excluded as it's a panda with black/white coloring by design.
2026-03-27 20:17:26 -03:00
Mary Kate Fain c5bc900212 Use kind-specific labels in notification action text instead of generic 'post'
Notifications now say 'reacted to your badge', 'reposted your theme',
'commented on your nsite', etc. instead of always saying 'your post'
or 'your note'. Uses the referenced event's kind to look up a
human-readable noun from a comprehensive kind-to-label map.
2026-03-27 18:14:18 -05:00
filemon 8bd2bca879 Add form-specific color customizers for adult Blobbi SVGs
Implement comprehensive gradient replacement for each adult form to ensure
Blobbi custom colors are properly applied to all visual elements (body, ears,
tail, arms, legs, petals, etc.) while preserving 3D shading gradients.

Forms with full color mapping: catti, droppi, flammi, froggi, leafy, mushie,
rocky, rosey, starri. Forms owli/pandi keep original colors by design.
2026-03-27 20:12:01 -03:00
Mary Kate 27283384bf Merge branch 'fix/zap-emoji-support' into 'main'
Add emoji picker and shortcode autocomplete to zap comment box

Closes #176

See merge request soapbox-pub/ditto!123
2026-03-27 23:02:55 +00:00
Mary Kate Fain 9901635008 Add zap button to badge detail view action bar
The BadgeDetailContent action bar had reactions, reposts, and comments
but was missing the zap button that NoteCard renders for the same
events in the feed.
2026-03-27 17:52:52 -05:00
Mary Kate Fain 1f5ce2546c Add emoji picker and shortcode autocomplete to zap comment box
Extract shared useInsertText hook to DRY up the duplicated text
insertion logic across ComposeBox, DMChatArea, and ZapDialog.
Add EmojiPicker (GUI) and EmojiShortcodeAutocomplete (:shortcode
typing) to the zap comment textarea, and also add shortcode
autocomplete to the DM chat input which was previously missing it.

Closes #176
2026-03-27 17:46:16 -05:00
Mary Kate 2f0adcce7c Merge branch 'fix/theme-description-expand' into 'main'
Show full theme description on theme detail page

Closes #124

See merge request soapbox-pub/ditto!122
2026-03-27 22:34:08 +00:00
Lemon 7bfab65042 Fix toast swipe direction to match entry direction on mobile
On mobile, toasts enter from the top but previously could only be swiped
right to dismiss. Now swipe direction is responsive: swipe up on mobile
(top-positioned), swipe right on desktop (bottom-right positioned). Exit
animations also match the swipe direction at each breakpoint.
2026-03-27 15:13:43 -07:00
Mary Kate Fain b59eeeca81 Show theme description on 'updated their theme' (kind 16767) posts
Kind 16767 events previously hardcoded description to undefined, so the
theme description never appeared on 'updated their theme' posts in the
feed or detail view.

Three changes:
- buildActiveThemeTags now accepts and includes a description tag, so
  future kind 16767 events carry the description directly
- setActiveTheme accepts description to thread it through publishing
- ThemeContent extracts the description tag from kind 16767 events, and
  for older events without one, falls back to querying the source theme
  definition via the a-tag reference
2026-03-27 17:09:08 -05:00
Mary Kate Fain 81e42f24c8 Exclude Capacitor native-only plugins from Vite dep optimization
@capacitor/filesystem and @capacitor/share are dynamically imported
behind a Capacitor.isNativePlatform() guard, but Vite's import analysis
plugin still tries to resolve them at transform time in dev mode. This
causes a 'Failed to resolve import' error when running the dev server.

Excluding them from optimizeDeps prevents Vite from pre-bundling these
packages, letting the dynamic imports resolve naturally at runtime.
2026-03-27 16:59:24 -05:00
Mary Kate Fain d4a928b682 Show full theme description on post detail page
On the feed, theme descriptions are truncated to a single line. On the
post detail page, the full description is now displayed so users can
read long descriptions that don't fit in the thumbnail card.

Closes #124
2026-03-27 16:52:20 -05:00
Alex Gleason da27054a9b Fix file downloads and URL opening on Capacitor iOS
The <a download> and <a target="_blank"> patterns don't work in
WKWebView. Add downloadTextFile() and openUrl() utilities in
src/lib/downloadFile.ts that use @capacitor/filesystem and
@capacitor/share on native platforms, falling back to standard
browser behavior on web.

Update all call sites: onboarding key download (InitialSyncGate,
SignupDialog), image lightbox buttons (ImageGallery, ProfilePage).

Document Capacitor compatibility constraints in AGENTS.md.
2026-03-27 16:42:36 -05:00
Alex Gleason 3c54cd27fe Fix package-lock name 2026-03-27 15:41:39 -05:00
Alex Gleason 8fe8525b06 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 15:40:40 -05:00
Mary Kate 9169cd5d1f Merge branch 'fix/letter-notifications' into 'main'
Fix: add letter (kind 8211) notifications

Closes #188

See merge request soapbox-pub/ditto!118
2026-03-27 20:30:42 +00:00
Mary Kate 490b8554e2 Merge branch 'badge-notification-preview' into 'main'
Show badge thumbnail preview in badge award notifications

Closes #186

See merge request soapbox-pub/ditto!119
2026-03-27 20:29:16 +00:00
filemon 97748dfd34 Remove page entry from streak triggers - only real interactions count
Streak now only updates from actual care interactions:
- Direct actions (play_music, sing)
- Inventory item use (feed, clean, treat, etc.)
- Stage transitions (hatch, evolve)
- Rest action (sleep/wake toggle)

Page visits and app opens no longer count toward streak.
2026-03-27 17:08:27 -03:00
filemon 0d8b320f31 Implement improved Blobbi care streak system with day-based tracking
Add new streak tags to kind 31124 events:
- care_streak: Consecutive days of care (starts at 1, resets to 1 if 2+ days missed)
- care_streak_last_at: Unix timestamp of last streak update
- care_streak_last_day: Local calendar day (YYYY-MM-DD) of last update

Streak validation rules:
- Initialize to 1 on first activity
- Increment when activity occurs on the next local day
- Same-day activity does not increment (at most once per day)
- Missing 2+ days resets streak to 1

Files added:
- blobbi-streak.ts: Centralized streak calculation logic
- useBlobbiCareActivity.ts: Hook for registering care activity

Streak integration points:
- Blobbi page entry (automatic check-in)
- Direct actions (play_music, sing)
- Inventory item use
- Stage transitions (hatch, evolve)
- Rest action (sleep/wake)
- Companion item use (outside BlobbiPage)
2026-03-27 17:01:35 -03:00
Alex Gleason 32b0cef65d Change toast swipe direction from right to up for mobile dismissal 2026-03-27 14:57:51 -05:00
Mary Kate Fain abcb51c0e2 Show badge thumbnail preview in badge award notifications
Closes #186. Badge award notifications now display a visual preview
card with the badge image, name, and description for both single and
grouped badge notifications.
2026-03-27 14:44:33 -05:00
Mary Kate Fain 8d02645e26 Enhance letter notification with envelope card preview
Show the sender's profile pic via the EnvelopeCard component (the same
Wii-Mail-inspired envelope tile used in the letters inbox). The card
auto-decrypts to display stationery colors and the sender's avatar as a
wax seal. Clicking the envelope navigates to /letters.
2026-03-27 14:31:05 -05:00
filemon 6ab05471b2 Migrate Blobbonaut profile from kind 31125 to 11125 with auto-onboarding
- Change KIND_BLOBBONAUT_PROFILE constant from 31125 to 11125
- Add KIND_BLOBBONAUT_PROFILE_LEGACY (31125) for migration support
- Add BLOBBONAUT_PROFILE_KINDS array to query both kinds
- Update useBlobbonautProfile to prefer 11125 over 31125
- Add needsKindMigration flag for legacy profile detection
- Extend useBlobbonautProfileNormalization to auto-migrate legacy kinds
- Refactor onboarding to auto-create profile using kind 0 name
- Remove manual name entry step from onboarding flow
- Delete unused BlobbiProfileOnboarding component
- Update all documentation comments to reference kind 11125
2026-03-27 16:27:26 -03:00
Mary Kate Fain f82adab05d Fix: add letter (kind 8211) notifications
Letters were completely absent from the notification pipeline — users had
to visit the Letters page to discover incoming letters. This integrates
kind 8211 into every layer of the notification system:

- useNotifications: query, grouping, and referenced-event exclusion
- useHasUnreadNotifications: unread dot indicator
- NotificationsPage: LetterNotification component with link to /letters
- NotificationSettings: toggleable Letters row
- notificationTemplates: web push template
- Android NotificationRelayService + NostrPoller: native push support
- EncryptedSettings + schema: letters preference field

Closes #188
2026-03-27 14:15:50 -05:00
filemon 1291a0e932 Fix: ensure t and client tags are removed from Blobbi events
The previous commit removed these tags from event building functions, but
the tag validation/repair system in blobbi-tag-schema.ts was re-adding them:

1. BLOBBI_TAG_SCHEMA had 't' marked as required:true with defaultValue:'blobbi'
2. RECOVERABLE_SYSTEM_TAGS had both 't' and 'client' with default values
3. DEPRECATED_TAG_SCHEMA did not include 't' or 'client'

Fixed by:
- Removing 't' and 'client' from BLOBBI_TAG_SCHEMA (no longer required)
- Removing 't' and 'client' from RECOVERABLE_SYSTEM_TAGS
- Adding 't' and 'client' to DEPRECATED_TAG_SCHEMA

Now the validateAndRepairBlobbiTags function will properly filter out
these tags during any republish/migration/update flow.
2026-03-27 15:58:44 -03:00
filemon a2d40c5cbf Remove Blobbi shape system and clean up t/client tags
- Remove BLOBBI_TOPIC_TAG and BLOBBI_CLIENT_TAG from event building
- Add t and client to DEPRECATED_BLOBBI_TAG_NAMES for migration cleanup
- Update validation functions to not require t tag
- Delete blobbiShapes.ts and BlobbiShapePicker.tsx entirely
- Simplify avatarShape.ts to only support emoji shapes
- Remove blobbi_shape task from useEvolveTasks
- Remove change_shape task from useHatchTasks
- Remove change_shape mission from daily-missions
- Clean up ProfileCard shape picker to only show emoji picker

The app's useNostrPublish hook already adds client tags automatically,
making the explicit client tag redundant. Old events with these tags
will have them stripped on next save.
2026-03-27 15:54:20 -03:00
Alex Gleason 17954e0504 Fix badges page showing infinite skeleton when logged out
The useBadgeFeed hook required a logged-in user before enabling the query,
causing the follows tab to show loading skeletons forever when logged out.
Now fetches the Team Soapbox follow pack (kind 39089) and uses its members
as the authors filter, giving logged-out users a curated badge feed.
2026-03-27 13:15:44 -05:00
filemon 2e5e6c9ad3 feat: add 'hungry' emotion for Blobbi
New emotion that conveys low energy + wanting food:
- Watery eyes (like sad) but WITHOUT blue water fill - longing, not crying
- Worried/sad eyebrows for that wanting/longing look
- Droopy mouth - less curved than sad frown, softer and more tired
- Small drool drop from corner of mouth with subtle wobble animation
- Fork & knife icon above head (subtle, 65% opacity)

Also adds new config types:
- DroolConfig: drool drop effect
- FoodIconConfig: utensils/plate icon above head
- DroopyMouthConfig: weak/tired frown with adjustable width and curve
2026-03-27 12:56:45 -03:00
filemon c1e9143483 fix: add missing pupil colors for adult Blobbi eye detection
Added #1e1b4b (dark indigo) for starri/crysti and #0891b2 (cyan) for
droppi to the PUPIL_COLORS array. Without these colors, the eye
animation system couldn't detect pupils in these adult forms, causing
eyes to not render properly (only showing eyelids).
2026-03-27 12:25:59 -03:00
filemon 774f7d2dbe Add sparkles around Blobbi and create excited variation B
Sparkles:
- Move sparkles from around eyes to around entire Blobbi body
- 11 sparkles distributed around the perimeter (top, sides, bottom)
- Subtle fade/twinkle animation with staggered timing
- Soft opacity (0.7 max) for gentle effect

Excited variations:
- Excited A (original): star eyes + big smile
- Excited B (new): star eyes + round 'O' mouth (like curious)
- Both include sparkles around the Blobbi
- Added excitedB to emotion tester panel for comparison
2026-03-27 12:16:25 -03:00
filemon de9eab1e4e Finalize emotion visual tweaks
Sleepy closed-eye lines:
- Increase curve depth (0.5x radius) to match eye curvature
- Position slightly lower (0.75x radius offset)
- Disappear immediately at 63% (as eyes start opening)

Excited star eyes:
- Reduce star scale from 1.4 to 0.9 for cuter look
- Insert stars INTO blobbi-eye groups so they track with eye movement
- Stars now follow mouse cursor like normal pupils

Excited sparkles:
- Add 4 animated sparkles around each eye
- Small 4-pointed star shapes that twinkle
- Staggered animations (0.3s delay between each)
- Positioned at orbit around the eyes
2026-03-27 12:07:27 -03:00
filemon 9b1925d6de Refine excited, sleepy, and adoring emotions
Excited eyes:
- Keep white eye circle visible behind the star
- Only hide pupils (.blobbi-eye), not entire blink group

Sleepy animation:
- Update to use new clip-path blink system
- Add SMIL animations to clip-path rects for eye closing
- Remove old scaleY CSS animation
- Eyes now close with natural eyelid-down effect

Adoring eyes:
- Add includeWaterFill option to PupilModification
- Adoring uses watery highlights but no blue fill
- Sad retains the blue watery semicircle
2026-03-27 12:02:24 -03:00
filemon a6ad1bdcdb Refine blink animation and eyelid system
Blink behavior:
- Change from scaleY to clip-path mask approach
- Eye keeps original size, visible area cropped from top to bottom
- Creates natural eyelid-closing effect revealing eyelid layer behind
- Add clipPath definitions and animate rect Y/height

Eyelid color:
- Reduce darken amount from 15% to 8%
- Subtle contrast that reads as eyelid, not shadow

Sleepy closed-eye lines:
- Lower position by 70% of eye radius
- Aligns with final closed eye position in clip-path system
2026-03-27 11:53:25 -03:00
filemon f9c8bbc4cc Add eyelid background layer and update excited emotion with star eyes
Eyelid layer:
- Add blobbi-eyelid ellipse behind each eye white
- Derive color from base body color (darkened by 15%)
- Pass baseColor to addEyeAnimation from visual components
- Ready for future blink animation integration

Excited emotion:
- Replace eyes with 5-pointed golden stars
- Add big smile (30% wider, 40% deeper curve)
- Hide normal eyes when stars are active
- Clean, readable across baby and adult variants
2026-03-27 11:44:30 -03:00
filemon 3634440c8b Update excited emotion and add adoring emotion
- excited: use normal eyes, keep animated eyebrows, use curious round mouth
- adoring: watery eyes (like sad), no eyebrows, curious round mouth
- Add adoring to emotion tester panel
2026-03-27 11:37:18 -03:00
Chad Curtis 488ce5750d Hide top nav on scroll and unify top padding across pages
- MobileTopBar: changed from sticky to fixed positioning with scroll-hide
  transform animation (mirrors bottom nav behavior via useScrollDirection)
- MainLayout: replaced -mt-mobile-bar overlap trick with pt-mobile-bar
  padding since the top bar is now fixed; added data-nav-hidden attribute
  to drive CSS transitions on sticky sub-headers
- SubHeaderBar/top-mobile-bar: sticky top offset transitions to 0 when
  the top bar hides, keeping sub-headers flush with the viewport top
- NotificationsPage, ProfilePage: added arc overhang spacer after
  SubHeaderBar to match Feed's spacing
2026-03-27 02:07:12 -05:00
filemon c963673a19 feat(blobbi): add dizzy, excited, mischievous emotions with animated effects
- Add dizzy emotion with rotating spiral eyes (counter-clockwise)
- Add excited emotion with watery eyes, bouncing sad-style eyebrows, and smile
- Add mischievous emotion with bouncing angry-style eyebrows and small smug smile
- Wrap eyebrows in groups to preserve rotation while CSS animates translateY
- Add new emotions to DEV emotion panel for testing
2026-03-27 02:17:49 -03:00
Alex Gleason 6e2589e125 fix: use project-relative path for release notes file 2026-03-26 23:57:24 -05:00
Alex Gleason d980fdf96d Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-26 23:42:19 -05:00
Alex Gleason 89fe5b8937 fix: use native release: keyword instead of glab to fix 403 permission error 2026-03-26 23:42:04 -05:00
Chad Curtis 93bc669f24 Lazy-load hls.js in VideoPlayer to reduce main bundle by ~500KB 2026-03-26 23:33:09 -05:00
filemon 0602c1b59d refine(blobbi): adjust sleepy animation timing and transitions
Three targeted refinements to sleepy emotion:

1. Mouth transition simplified:
   - Now goes directly: smile → U-shaped → smile
   - Removed intermediate flat line phase
   - Smoother, more natural transition

2. Eyes fully hidden when closed:
   - Changed scaleY from 0.05 to 0 when fully closed
   - Original eye completely disappears
   - Only curved closed-eye line visible during sleep

3. Zzz appears from the beginning:
   - Starts at 0% with opacity 0
   - Fades in softly: 10% → 0.2, 20% → 0.4
   - Full opacity by 35% (during sleep)
   - Creates 'getting sleepy' feel from the start
2026-03-27 01:33:04 -03:00
Alex Gleason 6e2716d957 Strengthen commit requirement in AGENTS.md: always commit after changes 2026-03-26 23:31:53 -05:00
Alex Gleason 9c3ec58246 Rename package from mkstack to ditto 2026-03-26 23:31:24 -05:00
filemon d8a81879b1 feat(blobbi): redesign sleepy emotion as micro-sleep animation
Complete rewrite of sleepy emotion to be a 'micro-sleep' cycle:

Eye Animation:
- Slow eye closing over ~3 seconds (not partial blinks)
- Eyes fully close with scaleY near 0
- Curved closed-eye lines appear when eyes shut (like eyelids)
- Wake-up includes quick right-left glance before settling

Mouth Animation:
- Smile → flat line → small round mouth (while falling asleep)
- Small round mouth holds during sleep
- Reverses back: round → flat → smile (while waking)
- Uses SMIL path animation for smooth morphing

Sleep State:
- Zzz text appears above head during sleep
- Zzz fades in/out and floats upward
- Sleep holds for ~1 second before waking

Full 8-second cycle timeline:
- 0-10%: Awake, normal smile
- 10-35%: Getting sleepy, eyes closing, mouth flattening
- 35-50%: Eyes closed, mouth becomes round
- 50-62%: Asleep (hold with Zzz)
- 62-75%: Waking, eyes opening
- 75-90%: Quick right-left glance
- 90-100%: Return to normal, cycle repeats
2026-03-27 01:27:46 -03:00
filemon c054bc7bc7 Merge branch 'main' into feat-blobbi 2026-03-27 01:25:27 -03:00
Alex Gleason c46e7b98e0 npm audit fix 2026-03-26 23:22:21 -05:00
Alex Gleason 65762e8645 skill: fix MARKETING_VERSION occurrence count in release instructions 2026-03-26 23:20:38 -05:00
Alex Gleason 689ac34946 skill: pull before committing release to prevent tag/rebase issues 2026-03-26 23:18:22 -05:00
filemon daf35f6e41 feat(blobbi): add curious asymmetric eyebrow and sleepy animation
Curious emotion:
- Right eyebrow now raised slightly more than left for questioning look
- Added per-eye override support in EyebrowConfig (leftEyeOverride/rightEyeOverride)

Sleepy emotion:
- Implemented 3-stage tired blink animation cycle:
  1. Small blink (~25% closed)
  2. Medium blink (~55% closed)
  3. Heavy blink (~80% closed)
- Mouth animates from smile → flat → smile in sync with blinks
- Uses CSS keyframe animation for smooth, slow transitions
- 8-second cycle duration for natural tired feel
- Added SleepyAnimationConfig type for configuration
2026-03-27 01:17:05 -03:00
Alex Gleason 58a5c470bd release: v2.1.0 2026-03-26 23:14:03 -05:00
Chad Curtis aecddf6fb5 Fix drawing canvas 'done' button clipped by drawer max-height 2026-03-26 23:13:42 -05:00
Alex Gleason f702513a64 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-26 23:02:47 -05:00
Alex Gleason 3126ad2380 fix: pass CI_JOB_TOKEN to glab in release job 2026-03-26 23:02:06 -05:00
filemon d947a951ad feat(blobbi): add curious/surprised emotions and adjust baby eyebrows
- Add 'curious' emotion: small round mouth, soft curved eyebrows
- Add 'surprised' emotion: larger round mouth, raised curved eyebrows
- Add round mouth generation (replaces default mouth like sad/angry)
- Add curved eyebrow support via new 'curve' config option
- Move baby eyebrows slightly farther from eyes (-2px offset)
- Add variant parameter to applyEmotion for baby/adult differences
- Add both new emotions to DEV emotion panel
2026-03-27 01:00:01 -03:00
Chad Curtis 51fffc0ae1 Merge branch 'envelope-inbox' into 'main'
Add Letters inbox page with full letter composing from lief

See merge request soapbox-pub/ditto!117
2026-03-27 03:59:48 +00:00
filemon 273cf1094d fix(blobbi): fix sad eye highlights and add angry body animation
- Fix invalid SVG generated by sad highlight injection regex
- Refactor water fill to insert inside blink groups (below pupil, above eye white)
- Add anger-rise body effect that animates red color from bottom to top
- Uses clipPath to constrain effect to body shape
2026-03-27 00:51:32 -03:00
Chad Curtis 403946bac5 Use mailbox icon from lief for Letters page and sidebar 2026-03-26 22:43:11 -05:00
Alex Gleason cb81fd3315 fix: push only the specific release tag, never --tags 2026-03-26 22:40:06 -05:00
filemon c907779b3c Fix sad eye highlight injection with robust parsing and debug logs
- Replace fragile regex with index-based string manipulation
- Find opening tag first, then locate closing tag by position
- Add DEV-only debug logging for eye detection and injection
- Log whether blobbi-eye groups are found and matched
- Log eye positions and water fill generation
- Should fix sad highlights not appearing in blobbi-eye groups
2026-03-27 00:35:24 -03:00
Chad Curtis 3e6b947893 Fix gift attachment blocking backdrop clicks to close letter dialog 2026-03-26 22:00:01 -05:00
Chad Curtis 9b1615480f Polish letter attachment: use actual color moment pattern, theme-derived text color, more spacing 2026-03-26 21:56:22 -05:00
Chad Curtis a32c620b4e Add letter attachment: tap to apply embedded color moment or theme as your Ditto theme 2026-03-26 21:40:30 -05:00
Chad Curtis 7a8fbe3ee5 Fix review items 6-10: use default relays, lazy-load fonts, paginate letters, cap SVG size, fix exhaustive-deps 2026-03-26 21:32:18 -05:00
Chad Curtis 13480d528a Replace LetterRecipientInput with ProfileSearchDropdown, fix compose padding 2026-03-26 21:20:59 -05:00
Chad Curtis 7ddaf135b4 Link about dialogs to in-app routes for color moments, themes, and emoji packs 2026-03-26 20:48:05 -05:00
Chad Curtis 848ac15ef0 Fix CI: remove unused prop, centralize color helpers, extract constants, memoize Sets, move @types/dompurify to devDeps 2026-03-26 19:45:51 -05:00
Chad Curtis ae1f97eb08 Letters: bigger wax seals, adaptive text, inline labels, font loading, SVG sticker fix 2026-03-26 19:17:58 -05:00
Chad Curtis a2fa2a6b96 Letters: redesign inbox as Wii Mail-style envelope grid with modal detail view 2026-03-26 18:51:45 -05:00
filemon 2d9ff34ded Fix sad eye tracking/blinking integration and slow down tears
- Hide original highlights by adding opacity=0 inside blobbi-eye groups
- Inject sad highlights INTO blobbi-eye groups so they track with pupil movement
- Sad highlights now move with eye tracking and participate in blinking
- Blue water fill stays as overlay (on eye white, doesn't need tracking)
- Slow tears: duration 6s with 3s pause between cycles
- Alternating tear mode: tears switch sides each cycle (no flickering)
- Only affects SAD emotion, angry/neutral unchanged
2026-03-26 20:32:18 -03:00
filemon d1a85659ba Fix sad eye visuals: reposition water shape and highlights
- Position blue water shape relative to eye white (not pupil center)
- Water now sits at bottom of eye white like pooled tears
- Reposition highlights: upper (larger) and lower (smaller) with clear separation
- Upper highlight at cy - radius*0.55, lower at cy + radius*0.35
- Only affects SAD emotion (generateSadEyeEffects), angry unchanged
2026-03-26 20:28:44 -03:00
filemon d832e6e364 Fix Blobbi sad emotion visuals and dev panel labeling
- Lower sad mouth position by adding Y offset based on curve amount
- Fix sad eye highlights: larger top-left, smaller right-side
- Change blue watery fill to proper lower 1/3 semicircle shape using path
- Fix baby eye detection to match gradient fills (url(#...Pupil...))
- Swap sad/angry eyebrow angles: sad now worried (/\), angry now aggressive (\/)
- Replace mouth safely using regex-only approach (no section slicing)
- Update dev panel to label default as 'Default' with happy emoji
2026-03-26 20:24:05 -03:00
Chad Curtis 08a5c808f8 Letters: send animation with envelope, wax seal, Ditto logo, and pre-rendered letter 2026-03-26 18:22:15 -05:00
filemon 66cfe9ee45 Add Blobbi emotion system with DEV testing panel
- Add EmotionDevContext and BlobbiEmotionPanel for dev-only emotion testing
- Create emotions.ts with configurable emotion overlays (sad, happy, angry, surprised, sleepy)
- Use deterministic tear selection (hash-based) to prevent flickering
- Add marker-based SVG detection with regex fallback for mouth/eye elements
- Update visual components to pass emotion prop through hierarchy
- Add SVG comment markers to all Blobbi base SVGs for reliable element detection
2026-03-26 19:47:36 -03:00
Chad Curtis c2b14e4f07 Letters: send animation scoped to feed column, inbox updates immediately 2026-03-26 17:40:13 -05:00
Chad Curtis 628dd47772 Letters: send animation with envelope, wax seal, and Ditto logo 2026-03-26 17:35:31 -05:00
Chad Curtis f8612ee20e Letters: arc tab bars for compose, prefs, and inbox 2026-03-26 17:31:15 -05:00
Chad Curtis 172bebe24a Move letter tool buttons into SubHeaderBar below app top bar
- LetterEditor: replace stickyHeader/headerLeft with renderToolbar render prop;
  callers decide where to place the tool buttons
- Both ComposeLetterSheet and LetterPreferencesSection: use SubHeaderBar noArc
  + useLayoutOptions({ hasSubHeader: true }) so tools sit in the sticky sub-header
  matching every other tabbed/sub-header page in the app
2026-03-26 16:58:02 -05:00
Chad Curtis 3c45641ef4 Letters inbox: replace custom FAB with FabButton 2026-03-26 16:53:18 -05:00
Chad Curtis 5eb6af1ab6 Compose send: use FabButton matching app FAB shape; extract FabButton component
- Extract FabButton from FloatingComposeButton (avatar shape mask + primary bg)
- FloatingComposeButton now delegates to FabButton
- ComposeLetterSheet: replace inline send button with FabButton FAB,
  fixed bottom-right on mobile, sticky in column on desktop
- Remove separate page-level send button from compose header
2026-03-26 16:51:31 -05:00
Chad Curtis c1b48058d5 Compose header: use ArcBackground to match app top nav curve 2026-03-26 16:45:42 -05:00
Chad Curtis 33ebeec2ac Compose header: sticky, no border 2026-03-26 16:43:39 -05:00
Chad Curtis a3e5ff9f4a Fix letter preferences header to match app PageHeader style
- LetterEditor: add stickyHeader prop (default true); when false renders a plain
  border-b toolbar row instead of sticky backdrop-blurred header
- LetterPreferencesPage: restore PageHeader (back arrow + title) as the page header
- LetterPreferencesSection: use stickyHeader={false}, remove headerLeft slot usage
2026-03-26 16:41:06 -05:00
Chad Curtis 203ef9dd44 Fix letter stationery theme sync and remove drawer card shape
- useLetterPreferences: simplify to just expose raw saved prefs + isThemeDefault flag,
  no longer conflates theme stationery with saved stationery
- LetterPreferencesSection: always pull from useThemeStationery directly when
  isThemeDefault, persist only on explicit user picks (handleSetStationery),
  sync preview live when theme changes
- ComposeLetterSheet: same pattern — init from themeStationery, switch to saved
  pref once settings load, track explicit user picks to avoid theme override
- LetterEditor drawer: remove bg-background / rounded-b-3xl / border-b card shape
2026-03-26 16:27:31 -05:00
Chad Curtis bdfb8f9dc6 Fix letter compose/prefs: use Ditto theme, single toolbar, inline send button
- ComposeLetterSheet: use themeStationery immediately (no parchment fallback),
  switch to saved pref once encrypted settings load, sync with theme changes
- ComposeLetterSheet: move send button to inline flow below the card (no more fixed overlay)
- LetterPreferencesPage: remove PageHeader — back button + title now live inside
  LetterEditor's headerLeft slot, eliminating the double bar
2026-03-26 16:23:45 -05:00
Chad Curtis 865fabce98 Add Letters inbox page with full letter composing from lief
- Port letter protocol (kind 8211, NIP-44 encrypted) from lief
- LettersPage at /letters with inbox and sent tabs
- ComposeLetterSheet with full stationery, font, frame, sticker, drawing support
- LetterCard with expand-to-read animation and deletion
- LetterPreferencesSection stored in encrypted settings (NIP-78)
- /settings/letters route for letter preferences
- Letters added to sidebar nav
- All letter lib utilities: letterTypes, letterUtils, colorUtils extensions, sanitizeSvg, svgDrawing
- StationeryBackground, StationeryPicker, FramePicker, StickerPicker, DrawingCanvas all ported
2026-03-26 16:11:29 -05:00
Alex Gleason e530e38721 fix: prevent font picker clicks from passing through to theme selector
Fixes #171
2026-03-25 18:35:25 -05:00
Alex Gleason d98ae9cdbf fix: consistent feed header ordering — title above tabs on all feed pages
Fixes #153, #155, #156, #157, #158, #159
2026-03-25 18:29:52 -05:00
Alex Gleason 5100b76ad3 fix: improve contrast on focused emoji category text in picker
Increases the opacity of emoji-mart nav button text from 0.65 to 0.85
by injecting CSS overrides into the shadow DOM. This improves readability
and meets WCAG contrast requirements for the category navigation icons.

Fixes #174
2026-03-25 18:28:45 -05:00
Alex Gleason 8328af802f fix: auto-focus search box when emoji picker opens
Fixes #162
2026-03-25 18:27:43 -05:00
The Daniel 87914291c6 ci: retrigger pipeline 2026-03-25 15:27:53 -04:00
filemon 0c7daef65e Add DEV MODE Blobbi state editor for testing
- Create BlobbiDevEditor modal component for direct state editing
- Add useBlobbiDevUpdate hook using standard update/publish flow
- Support editing: stage, state, adult form, all stats
- Support editing: experience, care streak, generation, breeding ready, visibility
- Add stat presets: Max Stats, Starving, Exhausted, Dirty, Sad, Critical Health, etc.
- Add wrench icon button to hero section (DEV only)
- Wire to existing updateBlobbiTags pipeline for consistent Nostr events
- Only renders in development mode (import.meta.env.DEV)
2026-03-25 12:42:44 -03:00
filemon 09da778d3b Add need-based Blobbi reactions when items land
- Create centralized need detection system with configurable thresholds
- Add continuous gravity for items dropped mid-air (fall to ground)
- Blobbi now glances at items it doesn't need (brief look)
- Blobbi shows interest in items it needs (longer attention)
- Add ItemLandedData interface with position info for reactions
- Create useCompanionItemReaction hook for need-based behavior
- Expose triggerAttention from useBlobbiCompanion hook
2026-03-25 12:23:13 -03:00
filemon 2ad64bbca7 Filter egg-only items from companion interaction system
Since companions can only be baby or adult (not egg), egg-only items
like Shell Repair Kit should never appear in the companion flow.

Changes:
- Update resolveItemsForAction to use centralized canUseItemForStage
- Add item-stage validation in useBlobbiItemUse mutation
- Egg-only items are now filtered at both display and use layers

The filtering is now enforced by:
1. resolveItemsForAction - items won't appear in hanging items menu
2. useBlobbiItemUse - validation prevents use even if somehow displayed
2026-03-25 11:33:05 -03:00
filemon 247b94f3b3 Fix item effect display consistency across Blobbi UIs
- Create shared ItemEffectDisplay component as single source for effect rendering
- Update BlobbiInventoryModal to show ALL effects (was truncated to 2)
- Update BlobbiShopItemRow to show ALL effects (was truncated to 3)
- Update BlobbiPurchaseDialog to use shared component
- Use canonical stat display order: hunger, happiness, energy, hygiene, health
- Deprecate formatEffectSummary in favor of ItemEffectDisplay component

The root cause was effect display truncation in the UI, not inconsistent data.
All item definitions remain in blobbi-shop-items.ts (single source of truth).
2026-03-25 11:24:36 -03:00
filemon 8078ad5609 Refactor hanging items to support multiple dropped instances
- Replace releasedItemIds (Set) with releasedCountByItemId (Map) to track
  how many instances of each item type have been released
- Generate unique instanceId for each dropped item (format: itemId-timestamp-counter)
- Hanging items now show remaining quantity (quantity - releasedCount)
- Multiple instances of the same item type can exist on the ground simultaneously
- Each dropped instance tracks independently via instanceId
- Update all callbacks and state tracking to use instanceId instead of item.id
- When item is used successfully, decrement releasedCount for that item type

This enables the desired UX where clicking a hanging item immediately shows
a new copy in the hanging slot (if quantity > 1), while the released instance
falls independently.
2026-03-25 11:07:54 -03:00
filemon 7fd70ac0d9 fix(blobbi): make item-use work reliably from companion system
Major architectural fix for Blobbi companion item-use system:

1. Created shared useBlobbiItemUse hook
   - Works standalone outside of BlobbiPage
   - Uses same real item-use logic as BlobbiPage
   - Built-in per-item cooldown tracking
   - Fetches companion/profile data on-demand when needed

2. Refactored BlobbiActionsContext
   - Now has built-in fallback using useBlobbiItemUse
   - Item use works from ANY page, not just /blobbi
   - BlobbiPage registration is optional (provides better cache access)
   - No more 'canUseItems = false' when BlobbiPage not mounted

3. Fixed retry/flood issues in HangingItems
   - Added per-item cooldown (3s on failure, 0.5s on success)
   - Implemented zone ENTRY detection (not continuous overlap)
   - Items only trigger auto-use when ENTERING the Blobbi zone
   - Items must leave zone before re-triggering
   - Multiple protection layers prevent spam

4. Fixed useBlobbonautProfile side-effect
   - Moved setBootCache from useMemo to useEffect
   - Added ref-based signature tracking to prevent loops
   - Proper cleanup and stable dependencies

Files changed:
- NEW: src/blobbi/companion/interaction/useBlobbiItemUse.ts
- src/blobbi/companion/interaction/BlobbiActionsContext.tsx
- src/blobbi/companion/interaction/HangingItems.tsx
- src/blobbi/companion/interaction/index.ts
- src/blobbi/companion/components/BlobbiCompanionLayer.tsx
- src/hooks/useBlobbonautProfile.ts
2026-03-25 06:38:37 -03:00
filemon dbdaff2ada fix(blobbi): resolve performance issues in falling-item drag-drop system
Root cause analysis and fixes:

1. Drag-to-use freeze/loop (HangingItems):
   - Problem: When dropping on Blobbi, item position was set ON Blobbi,
     triggering contact detection to also call attemptUseItem, creating a loop
   - Problem: attemptUseItem had itemsBeingUsed as a dependency, so when it
     changed (inside the callback), the callback identity changed, re-triggering
     the contact detection effect
   - Fix: Changed itemsBeingUsed from state to ref to avoid callback recreation
   - Fix: When dropping on Blobbi, reset item to ORIGINAL position before
     attempting use (prevents contact detection from firing)
   - Fix: Made attemptUseItem have no dependencies (uses refs for everything)
   - Fix: Gated all console.log calls behind import.meta.env.DEV

2. useBlobbonautProfile console flood:
   - Problem: Unconditional console.log at line 63 ran on EVERY render
   - Problem: queryFn had multiple console.logs that ran on every query
   - Fix: Removed/commented out all console.logs in the hook
   - Analysis: The hook itself was NOT causing extra renders - it was just
     exposing the render frequency with its logging

3. BlobbiActionsContext registration instability:
   - Problem: Registration used useState which triggered re-renders on every
     update, and the registration effect depended on useItem identity
   - Fix: Refactored to use refs instead of state for registration data
   - Fix: Added subscription pattern for manual notification only when
     canUseItems actually changes (major state change)
   - Fix: Consumer hook's useItem callback is now stable (reads from ref)
   - Fix: Provider context value is now stable (never changes identity)

Guards now preventing repeated item-use attempts:
- itemsBeingUsedRef.current check at start of attemptUseItem
- Contact detection skips items in itemsBeingUsedRef
- Drag-drop resets item position BEFORE calling attemptUseItem
- attemptUseItem has no dependencies that could trigger recreation
2026-03-24 22:20:04 -03:00
filemon 3e53e368a4 fix(blobbi): wire BlobbiActionsContext correctly and add drag-drop for items
Part 1: Context Wiring Fix
- Refactored BlobbiActionsContext to use registration pattern
- BlobbiActionsProvider now mounted at app level in AppRouter (wraps BlobbiCompanionLayer)
- BlobbiPage registers its item-use function via useBlobbiActionsRegistration hook
- BlobbiCompanionLayer now receives real context with canUseItems: true
- Added debug logs to confirm context state changes

Part 2: Drag-and-Drop for Released Items
- Added 'dragging' state to ReleasedItemState
- Implemented pointer event handlers for drag detection (threshold-based)
- Items can be dragged after landing on the ground
- Visual feedback: items scale up when over Blobbi, glow effect on Blobbi
- Drop-on-Blobbi triggers real item use flow
- Drop elsewhere leaves item at drop position

All three item use paths (contact, click, drag-drop) use the same
real onItemUse callback, ensuring consistent behavior and proper
kind 31124 event publishing.
2026-03-24 22:05:23 -03:00
filemon 6a7c037ea8 feat(blobbi): implement real item use system for companion falling items
- Add BlobbiActionsContext to bridge companion UI with item use functionality
- Create useCompanionItemUse hook with category-to-action mapping (food→feed, toy→play, etc.)
- Update HangingItems with async onItemUse callback and success/failure handling
- Wire BlobbiCompanionLayer to use context-provided item actions
- Provide BlobbiActionsProvider in BlobbiPage so items actually update stats
- Items only disappear after successful use, stay on screen if use fails
2026-03-24 21:57:56 -03:00
filemon 2414441efa Merge branch 'main' into feat-blobbi 2026-03-24 21:32:47 -03:00
filemon 14deb86a7a Fix stuck rescue handoff: eliminate visual flash and improve gravity
Visual flash fix:
- Pass wasResolvedFromStuck flag through to BlobbiCompanion
- When entry was resolved from stuck and phase is 'complete', skip entry
  animation position and use motion.position directly
- This prevents the one-frame flash where 'complete' phase returns
  groundPosition before acknowledgeCompletion() runs

Gravity fix:
- Increase gravity from 800 to 3500 px/s² to match entry animation feel
- Previous value caused slow floaty descent after drag release
- New value creates responsive, natural-feeling fall that matches the
  scripted entry fall animation

The handoff now works cleanly:
1. User drags Blobbi while stuck_permanent
2. User releases → isDragging=false, motion.position=drag release point
3. resolvePermanentStuck() sets wasResolvedFromStuck=true, phase='complete'
4. BlobbiCompanion sees wasResolvedFromStuck+complete → uses motion.position
5. Physics system applies gravity from the exact release position
6. acknowledgeCompletion() runs → phase='idle' → normal motion continues
2026-03-24 21:24:38 -03:00
filemon 572c3b082e Fix stuck rescue handoff: preserve drag release position instead of snapping to groundPosition
- Add wasResolvedFromStuck flag to track whether entry completed via
  stuck rescue vs natural animation completion
- Skip setPosition(groundPosition) when entry was resolved from stuck
  rescue, since motion.position already has the correct drag release
  position from the user's drag interaction
- Motion system now continues naturally from the drag release position,
  handling gravity/falling as expected
2026-03-24 21:15:06 -03:00
filemon d2b466df93 Fix stuck_permanent visibility: increase chance to 40%, add wiggle animation, and prevent auto-resolve
- Increase trulyStuckChance from 20% to 40% for more visible stuck behavior
- Add wiggle/struggle animation when Blobbi is truly stuck at ceiling
- Fix bug where stuck_permanent would auto-resolve after 50ms because
  isDragging starts as false - now tracks whether user has actually
  started dragging before allowing resolve on release
2026-03-24 21:12:17 -03:00
filemon 3f11465a7e Fix shadow to be a floor shadow that only appears near ground
Ground Proximity Detection:
- BlobbiCompanion now calculates distanceFromGround from actual Y position
- isOnGround = not entering, not dragging, and within 5px of ground position
- Both values passed to BlobbiCompanionVisual as new props

Shadow Visibility Rules:
- Shadow only shows when isOnGround is true
- Shadow hidden during: dragging, entry animations (fall/rise), falling
- Shadow fades smoothly over SHADOW_FADE_DISTANCE (30px)
- Additional subtle fade during float animation for breathing effect

Shadow Visual Changes:
- Position: bottom -12px → -20px (farther from body, feels like floor)
- Width: 55% → 50% of size (slightly narrower)
- Height: 10% → 8% of size (thinner, more subtle)
- Blur: 3px → 4px (softer edge)
- Max opacity: 0.4 → 0.35 (more subtle)
- Added CSS opacity transition for smooth fade in/out

States Where Shadow Is Hidden:
- Being dragged (isDragging)
- Fall entry animation (isEntering, entryType='fall')
- Rise entry animation (isEntering, entryType='rise')
- Any state where distanceFromGround >= 30px
- Any off-ground position (y < groundPosition.y - 5)

States Where Shadow Is Visible:
- Idle on ground
- Walking on ground
- Floating (with subtle fade based on float offset)
2026-03-24 20:47:03 -03:00
filemon ece9a37af4 Increase Blobbi size 35% and move shadow farther away
Size Changes:
- Companion size: 80px → 108px (+35%)
- Changed in DEFAULT_COMPANION_CONFIG.size (central config)
- All derived calculations automatically use new size:
  - Entry animations
  - Drag behavior
  - Ground positioning
  - Movement bounds
  - Gaze calculations

Padding Adjustments:
- Left padding: 80px → 100px (more room for larger Blobbi)
- Right padding: 20px → 24px
- Bottom padding: 20px → 24px

Shadow Improvements:
- Position: bottom -4px → -12px (3x farther from body)
- Width: 60% → 55% of size (slightly narrower)
- Height: 12% → 10% of size (slightly thinner)
- Blur: 2px → 3px (softer edge)
- Gradient adjusted for cleaner fade

Action Menu Adjustment:
- Radius: 70px → 85px (maintains visual spacing from larger Blobbi)

HangingItems Default:
- Updated companionSize default: 80 → 108 to match config

All systems continue to work correctly:
- Entry/exit animations scale with size
- Drag centering uses config.size
- Contact detection uses passed companionSize
- Action menu follows rendered position
2026-03-24 20:41:17 -03:00
filemon 7f4cf8bdcd Fix first-click animation bug, reduce hanging/released item sizes
Animation Bug Fix:
- Root cause: useEffect with releasedItems dependency only ran AFTER
  state update, but animation check happened once at effect start
- Fix: Use refs to track animation state and latest releasedItems
- runAnimationLoop() is now a stable callback that reads from refs
- handleItemClick calls runAnimationLoop via setTimeout(0) to ensure
  state update is processed first
- isAnimatingRef prevents duplicate animation loops
- Animation now starts immediately on first item click

Size Reductions (Hanging Items):
- Circle size: 72px → 56px
- Emoji size: 2.25rem → 1.75rem
- Item spacing: 100px → 80px
- Line length: 120px → 100px
- Badge size: 24px → 20px

Size Reductions (Released/Landed Items):
- Falling emoji: 2.5rem → 1.875rem
- Landed hitbox: 48px → 40px
- Contact radius: 60px → 50px
- Fall duration: 700ms → 600ms

Preserved Behavior:
- Hanging items use line + circle
- Only emoji falls after release (no circle)
- Continuous object from falling to landed
- Landed items remain on ground
- Contact detection still removes items
2026-03-24 20:35:12 -03:00
filemon d6538aac50 Continuous item lifecycle: emoji-only fall, no respawn, Blobbi contact detection
Falling Visual - Only Emoji Falls:
- When clicked, the hanging circle/container disappears immediately
- Only the emoji itself falls (no enclosing circle, no badge)
- ReleasedItem component renders just the emoji with drop shadow
- Slightly larger emoji size for falling/landed state (2.5rem)

Continuous Object Lifecycle:
- Single ReleasedItemData tracks item through entire lifecycle
- States: hanging → falling → landed (same object throughout)
- Position animated via requestAnimationFrame (not CSS animation)
- No disappear-and-respawn: emoji smoothly transitions from fall to ground
- Fall uses eased animation: accelerates then slows near ground

Contact Detection with Blobbi:
- Receives companionPosition and companionSize from parent
- Checks distance between Blobbi center and each landed item
- Contact threshold: companionSize/2 + 60px radius
- On contact: item removed, onItemCollected callback fired
- Works both ways: Blobbi walks into item OR item lands near Blobbi
- Manual pickup also supported (clicking landed item)

State Model:
- releasedItemIds: Set<string> - tracks which items left hanging state
- releasedItems: Map<string, ReleasedItemData> - full lifecycle data
- ReleasedItemData contains: item, state, x, y, startY, targetY, fallStartTime

Future-Ready Structure:
- onItemCollected callback ready for effects/reactions
- Position data available for drag implementation
- State model supports attraction behavior
- Clean separation: hanging container vs released items
2026-03-24 20:25:25 -03:00
filemon 3a47fccf16 Improve hanging items: larger size, slide animations, landed items persist
Size Improvements:
- Circle size: 52px → 72px (38% larger)
- Emoji size: text-2xl → text-4xl (2.25rem)
- Item spacing: 80px → 100px (center to center)
- Line length: 100px → 120px
- Badge size: 20px → 24px

Open/Close Slide Animation:
- Container states: hidden → opening → open → closing → hidden
- Items descend from above when opening (slide down animation)
- Items ascend when closing (slide up animation)
- 350ms slide animation duration
- Items no longer abruptly appear/disappear

Landed Items System:
- Released items fall all the way to the ground (calculated from viewport)
- Items remain visible on ground after landing
- Landed items render as separate LandedItem components
- Landed items persist even after menu closes
- Landed items are clickable (placeholder for future pickup)

State Model:
- ContainerState: hidden | opening | open | closing
- ItemState: hanging | falling | landed
- ItemStateData tracks: id, state, fallStartX, landedY
- landedItems Map persists item positions for ground rendering

Future-Ready Structure:
- onPickup callback ready for landed items
- Separate LandedItem component for ground items
- State model supports drag, attraction, consumption
- CSS variables for dynamic fall distance
2026-03-24 20:16:26 -03:00
filemon c659eaeead Replace item bubbles with hanging items, fix action menu tracking
Action Menu Fixes:
- Remove useMemo for position calculations to avoid stale values
- Calculate button positions directly each render
- Menu now follows Blobbi continuously during all states
  (idle, walking, floating, dragging, settling)

Hanging Items System:
- New HangingItems component replaces CompanionItemBubbles
- Items appear as circles hanging from vertical lines at top of screen
- Wider horizontal spacing (80px between items)
- Playful, spatial presentation instead of modal-like container

Click-to-Fall Animation:
- Clicking an item releases it from the hanger
- Line and quantity badge disappear instantly
- Item falls with rotation animation (800ms)
- Structured for future extension (drag, attraction, reactions)

Pointer Events:
- Container uses pointer-events-none
- Individual items use pointer-events-auto
- All items are now properly clickable

Removed:
- CompanionItemBubbles component (deleted)
- Modal-like container presentation
- Close button (no longer needed)
2026-03-24 20:07:16 -03:00
filemon 37c7a37bdf Fix companion menu positioning and pointer-events
- Expose rendered position from BlobbiCompanion via onPositionUpdate callback
- Track actual visual position in BlobbiCompanionLayer (includes entry animation + float offset)
- Pass rendered position to CompanionActionMenu instead of logical motion.position
- Add pointer-events-auto to action menu backdrop, buttons, and item bubbles
- Fix pointer events hierarchy (parent layer has pointer-events-none)
- Fix unused variable lint errors (entryProgress, isPermanentlyStuck, etc.)
2026-03-24 19:56:34 -03:00
filemon d3f23544cc Add companion action menu and item bubbles interaction layer
Implement the first interaction layer for the Blobbi companion:

Click vs Drag detection:
- Created useClickDetection hook to distinguish clicks from drags
- Movement threshold: 5px (beyond = drag)
- Time threshold: 300ms (beyond = not a click)
- Updated BlobbiCompanion pointer/touch handlers to use detection

Action Menu (CompanionActionMenu):
- Radial/arc layout centered above Blobbi
- 5 actions: feed, play, medicine, clean, sleep
- Stage-aware: eggs only see medicine and clean
- Smooth fade-in + zoom animation with stagger
- Click outside closes menu
- Route change closes menu

Item Bubbles (CompanionItemBubbles):
- Horizontal row of item bubbles near top of screen
- Shows emoji + quantity badge for each item
- Resolves real inventory items for selected action
- Empty state message when no items available
- Staggered appearance animation

Architecture:
- /interaction/types.ts - Type definitions and config
- /interaction/useCompanionActionMenu.ts - Menu state hook
- /interaction/useClickDetection.ts - Click/drag detection
- /interaction/CompanionActionMenu.tsx - Radial menu component
- /interaction/CompanionItemBubbles.tsx - Item display component
- /interaction/index.ts - Module exports

Action to item category mapping:
- feed -> food items
- play -> toy items
- medicine -> medicine items
- clean -> hygiene items
- sleep -> (no items, direct action)

Future-ready for:
- Item falling animation
- Drag item to Blobbi
- Blobbi walking toward items
- Item consumption logic
- Per-item Blobbi reactions
2026-03-24 19:44:48 -03:00
filemon 9d5ea22806 Upgrade typing attention to caret-aware targeting with 4s timeout
Caret tracking implementation (priority order):
1. contenteditable: window.getSelection() + Range.getBoundingClientRect()
2. input/textarea: selectionStart + mirrored text measurement
3. Fallback: right-side typing region (where new text appears)
4. Last resort: field center

Changes from field-center version:
- Added getContentEditableCaretPosition() using Selection API
- Added getInputCaretPosition() with text width measurement via temp span
- Added getRightTypingRegion() as smart fallback (better than center)
- computeCaretPosition() tries each method in priority order

Event handling (event-driven, no polling):
- keydown: detect typing, update caret after DOM updates via rAF
- input: catch paste, autocomplete, non-keydown text changes
- selectionchange: update when caret moves via arrow keys or click
- focusin/focusout: track element changes, clean up on blur

Timing:
- Increased idle timeout from 2s to 4s for more stable observation
- Timeout resets on any typing event

Priority:
- Typing attention now uses 'high' priority
- Overrides generic modal attention while typing
- Keeps Blobbi focused on caret, not just modal center

Handles edge cases:
- Focus leaving field clears typing attention
- Modal close clears via overlay detection
- Switching fields updates target to new field's caret
- Graceful fallback when exact caret rect unavailable
- Caret position clamped to element bounds
2026-03-24 12:32:08 -03:00
filemon 1388d0e514 Add typing attention behavior for Blobbi in modal text fields
When user types in a text input inside a modal/dialog, Blobbi now:
- Detects focus on text inputs (input, textarea, contenteditable, role=textbox)
- Only activates when the field is inside an overlay (modal, dialog, sheet, drawer)
- Locks attention to the focused field's center while typing continues
- Releases attention after 2s idle timeout (no typing)
- Properly cleans up when focus changes or modal closes

Implementation:
- New useTypingAttention hook handles focus/blur/keydown events
- Integrated into useBlobbiAttention with priority below 'high' but above 'low'
- Typing attention overrides random gaze and mouse-follow during active typing
- Lightweight: uses event listeners, not polling; targets field center, not caret

Config:
- Added typingIdleTimeout (2000ms) to attention config

Edge cases handled:
- Focus moving between text fields updates attention target
- Focus leaving text inputs clears typing attention
- Modal close clears typing attention via overlay detection
- Only typing keys (chars, backspace, delete, enter) reset idle timer
2026-03-24 12:24:20 -03:00
filemon e3412fac46 Merge branch 'main' into feat-blobbi 2026-03-24 12:20:40 -03:00
filemon e43c0b1e2e Fix reactive companion visibility and add BlobbiPage duplicate prevention
Root cause: useBlobbiCompanionData had a separate query key (['companion-profile'])
from useBlobbonautProfile (['blobbonaut-profile']). When companion was
selected/removed via BlobbiPage, only the main profile query was invalidated,
leaving the companion layer with stale data.

Fix:
- Rewrote useBlobbiCompanionData to use useBlobbonautProfile instead of
  duplicating the profile query
- Now shares the same query cache, so profile updates (including currentCompanion
  changes) immediately propagate to the companion layer
- Added explicit null return when currentCompanionD is undefined for reactive removal

BlobbiPage duplicate prevention:
- Added check for active floating companion (isActiveFloatingCompanion)
- When the displayed Blobbi is the same as the floating companion, show a
  friendly message: '{name} is out exploring right now.' with Footprints icon
- Prevents seeing two identical Blobbis (one floating, one in-page)

This makes companion selection/removal/replacement fully reactive without
requiring page refresh.
2026-03-24 12:19:00 -03:00
filemon b13a5ae1ae Add reactive companion visibility with companionId tracking
- Track companionId in useBlobbiEntryAnimation to detect companion changes
- When companion is selected/changed, trigger new entry animation immediately
- Random entry direction (fall or rise) for new companion appearances
- No transition delay for companion changes (unlike route changes)
- Companion removal hides Blobbi via existing isVisible logic
2026-03-24 12:09:12 -03:00
filemon 8c52848212 Fix intermittent snap-to-ground bug in FALL entry animation
Root cause:
When entry animation completed, there was a race condition:
1. entryState.phase becomes 'complete'
2. Effect calls completeEntry() which sets isEntering = false
3. Component checks 'if (isEntering)' and switches to motion.position
4. But motion.position wasn't synced to groundPosition yet
5. Result: Blobbi snaps to old position instead of falling smoothly

The fix uses a two-phase handoff:

1. Component now checks 'isEntering || entryState.phase !== idle'
   - Keeps using entry animation position during 'complete' phase
   - The 'complete' phase returns groundPosition, so rendering is correct

2. Added acknowledgeCompletion() function
   - Called after position is synced AND rendered (via requestAnimationFrame)
   - Resets phase to 'idle' to allow normal motion to take over
   - Ensures no frame shows wrong position

3. Position sync effect now:
   - Detects phase === 'complete' (not just !isEntering)
   - Syncs position to groundPosition
   - Waits one frame with requestAnimationFrame
   - Then calls acknowledgeCompletion() to handoff to motion

Files changed:
- useBlobbiEntryAnimation.ts: Add acknowledgeCompletion()
- useBlobbiCompanion.ts: Use acknowledgeCompletion after sync
- BlobbiCompanion.tsx: Check phase !== 'idle' for entry position
2026-03-24 12:05:28 -03:00
filemon 053007e7ea Fix route change: hide Blobbi immediately, delay only new entry
Before: Blobbi stayed visible during 1s delay, then disappeared and restarted
After: Blobbi disappears immediately, 1s clean delay, then new entry starts

Changes:
- Add isHiddenForTransition state to entry animation hook
- Set true immediately on route change (before delay)
- Clear when new entry actually starts
- useBlobbiCompanion now uses shouldBeVisible = isVisible && !isHiddenForTransition

Flow:
1. Route changes
2. Blobbi hidden immediately (isHiddenForTransition = true)
3. Wait 1 second (screen is clean)
4. Start new entry (isHiddenForTransition = false, entry begins)
2026-03-24 11:57:08 -03:00
filemon 9889cb07d4 Simplify FALL entry to 2 vertical pulls, add rare permanent stuck behavior
FALL entry changes:
- Simplified to 2 pull attempts (was wiggle-based)
- Each pull: quick down, slower up (purely vertical, no diagonal)
- Normal flow (~80%): stuck -> pull_1 -> pause_1 -> pull_2 -> pause_2 -> fall -> land
- Rare flow (~20%): stuck -> pull_1 -> pause_1 -> pull_2 -> stuck_permanent

Permanent stuck behavior:
- 20% chance on FALL entry (configurable via trulyStuckChance)
- Blobbi hangs at top, won't fall automatically
- User must drag and release to rescue
- Resolves when drag ends (after any movement away from stuck position)

Route change handling:
- Cancels current entry immediately (no continuation)
- Waits 1 second after new page appears
- Then restarts entry animation for the new route

Timing (total ~1470ms for normal fall):
- stuck: 200ms (15% visible)
- pull_1: 280ms (10% drop)
- pause_1: 140ms
- pull_2: 300ms (14% drop)
- pause_2: 100ms
- fall: 450ms
- land: 200ms

Files changed:
- companion.types.ts: New phases (pulling_1/2, pause_1/2, stuck_permanent), isTrulyStuck flag
- companionConfig.ts: New timing config for 2-pull system
- animation.ts: Simplified vertical pull calculations
- useBlobbiEntryAnimation.ts: New state machine with 20% stuck chance
- useBlobbiCompanion.ts: Pass isDragging to entry hook
- BlobbiCompanion.tsx: Updated config mapping
2026-03-24 11:46:52 -03:00
filemon 142b144318 Add pause phase to FALL entry, refine butt wiggle motion
- Add 'pause' phase (180ms) between tugging and wiggling
  Creates readable 'hmm... still stuck' beat before wiggle

- Refine wiggle to feel like lower/back part:
  - Side-to-side primary motion (not diagonal whole-body)
  - Small downward pull synced with each wiggle (tugging loose)
  - Minimal rotation (2°) - just organic, not full-body tilt
  - ~1.25 cycles instead of 1.5 (brief and playful)

- Adjusted timing:
  - wiggleIntensity: 5 → 4px
  - wiggleRotation: 3 → 2°
  - wiggleDuration: 350 → 320ms

New sequence with clear beats:
  stuck (250ms) → tugging (300ms) → pause (180ms) →
  wiggling (320ms) → falling (500ms) → landing (200ms)

Total: ~1750ms
2026-03-24 11:28:53 -03:00
filemon a47e53ff2d Refine FALL entry: add tugging phase, reduce visible amount, subtler wiggle
- Add 'tugging' phase: tries to fall but gets stuck (down-up motion)
- Reduce stuckVisibleAmount from 25% to 15% (tiny butt peek)
- Make wiggle subtler: 5px intensity, 3° rotation (was 8px, 6°)
- Wiggle now diagonal movement, not just horizontal shaking
- New sequence: stuck -> tugging -> wiggling -> falling -> landing

Timing: 250ms stuck, 300ms tug, 350ms wiggle, 500ms fall, 200ms land
2026-03-24 11:23:41 -03:00
Sergey B. 25bfe4f0fa fix: query case variants for tag feed parity with search 2026-03-24 17:13:33 +03:00
filemon 8b88cd3cf6 Add stuck and wiggling phases to Blobbi fall entry animation
- Add 'stuck' phase where Blobbi's butt hangs from top edge (25% visible)
- Add 'wiggling' phase with left-right wiggle (±8px, ±6° rotation) to get loose
- Update entry state machine to handle new phases: stuck -> wiggling -> falling -> landing
- Add config values for stuckDuration (300ms) and wiggleDuration (400ms)
- Update animation utils with wiggle offset calculations
2026-03-24 11:12:33 -03:00
filemon 748bf43847 Replace sidebar entry with vertical entry based on navigation direction
- Add vertical entry system: fall from top when navigating DOWN sidebar,
  rise from bottom with inspection when navigating UP sidebar
- Add sidebarNavigation.ts utility to map routes to sidebar order
- Remove old sidebar-based entry (clipping, peeking from left)
- Fix motion sync to use groundPosition (center) instead of old restingPosition
- Entry now ends at center of screen, no teleport on completion
2026-03-24 10:58:12 -03:00
filemon 586c536c46 up animation 2026-03-24 10:16:03 -03:00
filemon 4a84a782db Add tab attention, post-route attention, and improve upward eye movement
Tab switch attention:
- Added TAB_SELECTORS for Radix UI tabs with data-state='active'
- Tab changes trigger brief glance (1.2s) instead of full attention (3s)
- Uses shorter glanceCooldown (0.8s) to allow noticing multiple tabs
- Low priority so overlays can still interrupt
- Skipped tooltips entirely (too noisy)

Post-route attention:
- After entry animation completes, Blobbi looks at main content
- findMainContentPosition() tries common selectors: main, [role=main], .main-content, article
- Falls back to center-top of viewport if no main content found
- Uses postRouteDuration (2.5s) for how long to look
- Small postRouteDelay (200ms) before triggering
- Low priority so modals can immediately interrupt

Improved upward pupil movement:
- Asymmetric vertical scaling in BlobbiBabyVisual and BlobbiAdultVisual
- Looking up: full range (4px/4.5px for baby/adult)
- Looking down: reduced range (2.4px/2.7px, 0.6x) to avoid droopy look
- Updated calculateEyeOffset() with asymmetric maxDistanceY:
  - 350px for looking up (easier to reach full upward gaze)
  - 500px for looking down (normal distance)
- Updated generateRandomScreenGaze() to favor upward looks (-0.6 to +0.5)

New config options:
- attention.glanceDuration: 1200ms for brief tab glances
- attention.glanceCooldown: 800ms between glances
- attention.postRouteDuration: 2500ms for post-route attention
- attention.postRouteDelay: 200ms delay before post-route attention

Files changed:
- companion.types.ts: New config options
- companionConfig.ts: New timing values
- companionMachine.ts: Asymmetric calculateEyeOffset
- useBlobbiAttention.ts: Tab detection, overlay/tab separation
- useBlobbiCompanion.ts: Post-route attention trigger
- useBlobbiCompanionGaze.ts: Improved random gaze range
- BlobbiBabyVisual.tsx: Asymmetric vertical eye movement
- BlobbiAdultVisual.tsx: Asymmetric vertical eye movement
2026-03-24 07:59:40 -03:00
filemon e2dbc0e1cf Improve Blobbi companion behavior: calmer, more observant, reactive to UI
Behavioral rebalancing:
- Reduced walk chance from 75% to 30% for much calmer behavior
- Increased idle time from 2-6s to 4-10s
- Increased random gaze interval from 0.8-2.5s to 1.5-4s for more deliberate observation
- Mouse follow now more frequent (35% vs 25%), longer duration (2.5s vs 1.5s)
- Observation look duration increased from 2-4s to 3-6s

New attention system for UI changes:
- Added useBlobbiAttention hook that watches for new UI elements
- Uses MutationObserver to detect modals, dialogs, sheets, popovers appearing
- Supports Radix UI, Vaul drawer, and common dialog patterns
- New 'attending' state has highest priority, interrupts walking
- Blobbi stops and looks at new UI element for ~3 seconds
- Priority system (low/normal/high) prevents spamming
- Cooldown prevents excessive reactions (1.5s minimum between events)

Architecture for future extensibility:
- AttentionTarget type with id, position, duration, priority, source
- AttentionPriority type for behavior hierarchy
- Exported via module index for external use
- Easy to add video/audio/notification attention types later

Files changed:
- companion.types.ts: Added 'attending' state, 'attend-ui' gaze mode, AttentionTarget type
- companionConfig.ts: Rebalanced all timing values, added attention config
- companionMachine.ts: Reduced walk chance, handle attending in motion
- useBlobbiAttention.ts: New hook for UI attention detection
- useBlobbiCompanionState.ts: Handle attending state, save/restore behavior
- useBlobbiCompanionGaze.ts: Handle attend-ui gaze mode with fast snap
- useBlobbiCompanion.ts: Wire attention system through
- index.ts: Export new hook and types
2026-03-24 07:44:25 -03:00
filemon 2b434de40b Fix Blobbi companion eye behavior system
- Fix competing eye systems: add disableTracking option to useBlobbiEyes
  so external systems can control eye position while keeping blinking
- Add externalEyeOffset prop to BlobbiBabyVisual and BlobbiAdultVisual
  for direct companion control of eye position
- Increase pupil movement visibility (4px for baby, 4.5px for adult)
- Increase movement direction gaze offset (0.85 instead of 0.7)
- Add observation target behavior: Blobbi picks a random screen position,
  walks toward it, then looks at it for 2-4 seconds
- Add new 'observe-target' gaze mode and 'watching' state
- Wire eyeOffset through companion pipeline to visual components
- Clear separation of concerns: useBlobbiEyes handles blinking,
  companion system handles gaze direction
2026-03-24 07:15:19 -03:00
Sergey B. a51c174021 fix: allowing zap Primal users without error 2026-03-24 11:51:02 +03:00
Sergey B. c1373174ca feat: NIP-11 relay information on network settings page 2026-03-24 10:43:25 +03:00
filemon c3f0ecf7d5 Improve companion eye gaze behavior
- When moving: Eyes look in direction Blobbi is going (left/right)
- When idle: Eyes look around randomly, observing the screen
  - Wider gaze range for more noticeable movement
  - Faster gaze changes (0.8-2.5s) feel more alive
- Mouse focus: Brief glances at cursor
  - 25% chance every 2-4 seconds (after 6s cooldown)
  - Only lasts 1.5s, then returns to normal
  - Never gets stuck on mouse
- Smooth transitions between all gaze targets
  - Mouse follow: responsive (0.15 factor)
  - Forward: moderate (0.1 factor)
  - Random: gentle (0.06 factor)
- Cleaner state management using refs to avoid unnecessary re-renders
2026-03-23 22:52:29 -03:00
filemon 2f2fdb1809 Improve companion: restore organic movement, fix eye gaze, fix adult form rendering
- Restore charming float/sway animation with layered sine waves
  - Walking: lively bouncy motion with playful tilt
  - Idle: dreamy calm floating like gentle breathing
- Fix eye gaze behavior:
  - Eyes now move randomly when idle (not stuck)
  - Occasional brief mouse following that properly times out
  - Look in movement direction when walking
- Fix adult form rendering:
  - Pass adultType and seed to visual component
  - Adults now render their actual form instead of always catti
- Keep ground contact fixes:
  - SVG alignment via translateY compensation
  - SVG fills container with width/height 100%
- Add debug mode infrastructure (disabled by default)
2026-03-23 22:48:06 -03:00
filemon 95a123532b Fix Blobbi ground contact - compensate for SVG padding in ground calculation
Root cause: The Blobbi SVG has ~12% empty space at the bottom of its
viewBox (body ends at Y=88 in a 0-100 viewBox). This caused the visual
body to appear elevated above the ground.

Fix: Added SVG padding compensation in the ground position calculation
rather than trying to hack it in the visual component.

Changes:
- calculateGroundY(): Add svgBottomPadding (12% of size) to push
  container down so Blobbi's actual body touches ground
- calculateMovementBounds(): Same adjustment for maxY bound
- Removed visual margin hacks (marginBottom, items-end) that were
  trying to compensate in the wrong place

The ground Y calculation now accounts for the SVG's internal padding,
so the container is positioned lower and Blobbi's body correctly
touches the ground level.
2026-03-23 22:26:57 -03:00
filemon 2cc7c7bcaf Fix Blobbi ground contact by compensating for SVG padding
The SVG viewBox has empty space at the bottom (~12% padding).
This was causing Blobbi to appear floating above the ground.

Fix:
- Changed inner container to use 'items-end' for bottom alignment
- Added negative marginBottom (-10% of size) to pull Blobbi down
- This compensates for the SVG's internal bottom padding

Shadow restored:
- Back to bottom: -4 (was 0)
- Size: 60% width, 12% height
- Blur: 2px
- Better visual separation from Blobbi

Result: Blobbi now visually sits on the ground with proper
shadow placement underneath.
2026-03-23 22:20:16 -03:00
filemon b314b98dd6 Fix Blobbi ground contact - now properly touches ground
Float animation fix:
- Changed from abs(sin) to (1-cos)/2 wave formula
- This creates a 0-to-1 range that regularly returns to zero (ground)
- Y offset now goes from 0 (ground contact) to -3/-4.5 (slight lift)
- Blobbi settles back to ground between float cycles

Walking animation:
- Faster cycle (~0.5s) for rhythmic bobbing
- Range: 0 to -4.5px lift
- Reduced sway (1.5px) and rotation (1.5°)

Idle animation:
- Slower cycle (~2.5s) for calm breathing
- Range: 0 to -3px lift
- Subtle sway (0.8px) and rotation (0.8°)

Shadow adjustments:
- Moved to bottom: 0 (right at ground level)
- Smaller size (55% width, 10% height) for subtler effect
- Less blur (1px) for sharper ground contact
- Stronger base opacity (0.4) that fades as Blobbi lifts
- Scale shrinks more noticeably when lifted
2026-03-23 22:13:40 -03:00
filemon 5ec79e9612 Fix Blobbi ground alignment and shadow anchoring
- Float animation now only moves Blobbi upward from ground level
  - Uses abs(sin) so Y offset is always negative (up) or zero
  - Base position = on the ground, animation lifts slightly above
- Shadow stays anchored to ground while Blobbi floats above it
  - Shadow doesn't move with float offset
  - Shadow scales/fades based on float height for depth illusion
- Reduced horizontal sway and rotation for subtler effect
  - Walking: 2px sway, 2° rotation (was 3px, 3°)
  - Idle: 1px sway, 1° rotation (was 1.5px, 1.5°)
2026-03-23 22:10:00 -03:00
filemon 9e89972008 Improve companion movement and visual polish
- Simplify entry animation to smooth walking emergence (no stuck/squeeze)
- Add forced initial walk after entry - Blobbi walks right immediately
- Improve walking behavior - 75% walk chance, shorter idle periods
- Remove visual flip when changing direction - Blobbi always faces same way
- Add soft floating/swaying animation with different speeds for walk vs idle
  - Walking: faster rhythmic bobbing (~0.8s cycle)
  - Idle: slower calm breathing (~3s cycle)
- Add soft shadow underneath for depth/floating effect
  - Stronger opacity (0.35), blur, and gradient for better visibility
  - Shadow reacts to float height
- Keep clipping behavior for sidebar emergence on desktop
- Mobile uses simple slide-in from left edge
2026-03-23 22:06:45 -03:00
filemon 05864d001a Improve companion entry animation - emerge from content area with playful squeeze effect
Entry position changes:
- Add layout config with sidebarWidth (300px) and maxContentWidth (1200px)
- Calculate main content left edge accounting for centered layout
- Entry now starts at left edge of main content area, not viewport edge
- Resting position is inside the content area with proper padding

Playful entry animation (2.2 seconds total):
- Phase 1 (0-25%): Emerge diagonally with slight forward lean and squish
- Phase 2 (25-40%): Get 'stuck' halfway with wobble effect
- Phase 3 (40-70%): Tug motions - 3 cycles of forward/back pulls, each stronger
- Phase 4 (70-100%): Break free and walk smoothly to final position

Visual effects during entry:
- Rotation (lean forward/back during tugging)
- ScaleX/ScaleY (squish/stretch for squeeze effect)
- Transform origin at center bottom for natural pivoting

The animation feels like Blobbi is squeezing out from the previous page
into the current one, getting briefly stuck, then breaking free.
2026-03-23 20:56:10 -03:00
filemon f9fc81ce71 Fix companion entry animation - no teleport, spawn from behind sidebar
- Change entry position to start behind the sidebar (padding.left/2 - size)
  instead of off-screen left edge, so companion emerges from sidebar
- Add setPosition function to motion hook for syncing position
- Sync motion position to restingPosition when entry animation completes
  to prevent teleport between animated and physics-controlled movement
- Entry is now continuous: emerges from sidebar -> slides to resting position
2026-03-23 20:48:43 -03:00
filemon 66a23cc99b Implement Blobbi companion system with modular architecture
Add a complete companion module under blobbi/companion with:

Core architecture:
- types/companion.types.ts - Type definitions for companion state, motion, gaze
- core/companionConfig.ts - Configuration constants and helpers
- core/companionMachine.ts - State machine for behavior transitions

Hooks:
- useBlobbiCompanionData - Fetches current_companion from kind 31125 profile
- useBlobbiCompanionState - Manages idle/walking/watching behavior
- useBlobbiCompanionMotion - Handles physics, walking, gravity, drag
- useBlobbiCompanionGaze - Eye movement, random observation, mouse following
- useBlobbiCompanion - Main hook combining all systems

Components:
- BlobbiCompanionVisual - Renders baby/adult with external eye control
- BlobbiCompanion - Interactive component with drag support
- BlobbiCompanionLayer - Global overlay layer

Utils:
- movement.ts - Position calculations, bounds, interpolation
- animation.ts - Bob, bounce, easing functions

Features:
- Fetches companion from user's Blobbonaut profile on app load
- Entry animation from behind left sidebar on route changes
- Autonomous walking with energy-based speed
- Random gaze observation when idle
- Mouse cursor following at random intervals
- Drag and gravity physics
- Bottom viewport roaming area
2026-03-23 20:19:31 -03:00
filemon aa2d724a13 Merge branch 'main' into feat-blobbi 2026-03-23 20:10:15 -03:00
filemon 67f840c0ec Add stage validation for companion - only baby/adult allowed
- Add canBeCompanion check to prevent eggs from being set as companion
- Show toast error if user tries to set egg as companion
- Disable companion button for eggs with helpful tooltip
- Update tooltip to show 'Hatch first to set as companion' for eggs
2026-03-23 20:06:35 -03:00
filemon f2e545ff09 Fix companion tag handling and add indicator in Blobbies modal
- Remove current_companion tag entirely when unsetting (instead of empty string)
- Filter out existing current_companion tags before adding new one
- Add Footprints icon indicator in BlobbiSelectorCard for current companion
- Include tooltip 'Current companion' on the indicator icon
- Pass currentCompanion prop through BlobbiSelectorPage and modal
2026-03-23 19:45:02 -03:00
filemon 11142bc96a Implement Set as Companion toggle in Blobbi Hero section
- Add isCurrentCompanion and isUpdatingCompanion props to BlobbiDashboardFloatingControls
- Implement handleSetAsCompanion to toggle current_companion tag on profile
- Show green icon color when Blobbi is the current companion
- Add disabled state support to FloatingActionDef for loading states
- Pass publishEvent to BlobbiDashboard for profile updates
2026-03-23 19:38:26 -03:00
filemon e3d01bc6aa Make Missions modal sections collapsible
- Daily Missions section is now collapsible with chevron toggle
- Hatch Tasks section is collapsible when active
- Evolve Tasks section is collapsible when active
- All sections expanded by default for easy access
- Header shows progress count (e.g., 2/4) for task sections
- Header shows coins earned for Daily Missions section
- Smooth chevron rotation animation on expand/collapse
2026-03-23 18:13:43 -03:00
filemon d3fc1c602a Add Users icon to Blobbi Selector modal title 2026-03-23 18:08:00 -03:00
The Daniel 926ad380f3 feat: show link preview cards in quoted posts instead of raw URLs 2026-03-23 17:02:43 -04:00
filemon b55a9bae43 Add sticky header and close button to Blobbi Selector modal
Apply the same pattern used in other Blobbi modals:
- Sticky header with bg-background
- Explicit DialogClose button in header
- Scrollable content area with flex-1 min-h-0 overflow-y-auto
2026-03-23 17:29:19 -03:00
filemon f8c46d7a11 Add sticky header to BlobbiInfoModal and fix close buttons in all Blobbi modals
- Add sticky header pattern to BlobbiInfoModal (main Blobbi view)
- Add explicit DialogClose button inside each sticky header
- Hide default DialogContent close button with [&>button:last-child]:hidden
- Close button now stays visible when scrolling in all modals

Updated modals:
- BlobbiActionsModal
- BlobbiActionInventoryModal
- BlobbiInventoryModal
- BlobbiMissionsModal
- BlobbiShopModal
- BlobbiInfoModal
2026-03-23 17:21:36 -03:00
filemon 75ca14c900 Fix modal padding and add sticky headers for Blobbi modals
- Add pr-12 to DialogHeader to account for close button, fixing right padding
- Make DialogHeader sticky with proper background for all Blobbi modals
- Structure modals as flex column with min-h-0 for proper scrolling
- Apply pattern to: BlobbiActionsModal, BlobbiActionInventoryModal,
  BlobbiInventoryModal, BlobbiMissionsModal, BlobbiShopModal
2026-03-23 17:10:56 -03:00
filemon 163712471c Improve Blobbi UI mobile responsiveness and add compact coin formatting
- Add formatCompactNumber utility for compact coin display (1.2K, 15.4K, 1.2M)
- Fix BlobbiActionInventoryModal (medicine items) layout for mobile
- Fix BlobbiBottomBar to prevent overflow on narrow screens
- Fix BlobbiInventoryModal item cards for mobile
- Fix BlobbiMissionsModal horizontal scroll and layout issues
- Fix DailyMissionsPanel and TasksPanel for mobile
- Fix BlobbiShopModal and related dialogs for mobile
- Apply compact number formatting to all coin displays
2026-03-23 17:07:16 -03:00
filemon 974cdcccc9 Merge branch 'main' into feat-blobbi 2026-03-23 10:58:25 -03:00
Derek Ross bbe53a4c69 Filter Mentions tab to only show pure mentions, not replies
The Mentions tab now excludes kind 1 reply events (those with NIP-10
reply/root e-tags), showing only pure mentions where someone tagged the
user in a new post. Kind 1111 comments continue to appear in both tabs.
2026-03-22 15:54:41 -04:00
Derek Ross 88d9e783b8 Distinguish replies from mentions in notifications
Kind 1 events that are replies (have NIP-10 reply/root e-tags) now show
a reply icon with 'replied to your note' instead of the '@' icon with
'mentioned you'. Only pure mentions (no reply threading) use the mention
label.
2026-03-22 15:50:11 -04:00
Derek Ross 854f9aca23 Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts
- Fix silent notification drops: reactions, reposts, and zaps were being
  discarded when the referenced event couldn't be fetched from relays.
  Now keeps notifications with missing context instead of hiding them.
  Zaps no longer require the author-ownership check since the #p filter
  already confirms the user is the recipient. (fixes tabs appearing
  identical and missing zap notifications)

- Add real-time WebSocket subscriptions for instant notification updates
  instead of relying solely on 60-second polling. Both the full
  notification list and the unread dot indicator now react immediately
  when new events arrive.

- Wire up zap amounts from NIP-85 stats (zap_amount tag on kind 30383)
  through to the NoteCard action bar, replacing the hardcoded 0.

- Seed client-side reply counts into the event-stats cache from the
  loaded comment tree in PostDetailPage, ensuring sub-comment counts
  are visible even when NIP-85 stats are unavailable for kind 1111.

Closes #136
2026-03-22 15:42:45 -04:00
filemon 69dde41d9c fix(blobbi): rebalance daily mission rewards and persist to kind 31125
- Rebalance rewards: interact_6=30, feed_2=20, clean_1=20, sing_1=25,
  play_music_1=25, sleep_1=20, change_shape_1=40, take_photo_1=35
- Total daily reward now 75-105 coins (was 240-330)
- Add useClaimMissionReward hook to persist coins to Blobbonaut profile
- Claim flow now updates kind 31125 event, not just localStorage
- Add idempotency check to prevent double-crediting
- Update query cache and invalidate after successful claim
- Pass profile and updateProfileEvent to BlobbiMissionsModal
2026-03-21 21:55:18 -03:00
filemon e159e5bb6d feat(blobbi): implement daily missions system
- Add mission pool with 8 missions (interact, feed, clean, sing, play_music, sleep, take_photo, change_shape)
- Implement weighted random selection for 3 daily missions per user
- Add localStorage persistence with automatic daily reset
- Track progress from all Blobbi actions (inventory, direct actions, sleep, photo, profile shape)
- Add DailyMissionsPanel UI component with progress bars and claim buttons
- Integrate daily missions section into BlobbiMissionsModal
- Each mission rewards 80-110 coins depending on difficulty
2026-03-21 21:43:42 -03:00
filemon fce3c81029 fix(blobbi): ensure Blobbi eyes are always open in photo exports
Added disableBlink option to prevent Blobbi from being captured mid-blink
when downloading or posting photos.

Changes:
- useBlobbiEyes: Added disableBlink option that keeps blinkScaleY at 1 (fully open)
- BlobbiBabyVisual: Added disableBlink prop, passed to useBlobbiEyes
- BlobbiAdultVisual: Added disableBlink prop, passed to useBlobbiEyes
- BlobbiStageVisual: Added disableBlink prop, passed to child visuals
- BlobbiPolaroidCard: Now uses disableBlink={true} alongside lookMode='forward'

Photo mode behavior:
- lookMode='forward': Eyes look straight ahead (no mouse tracking)
- disableBlink={true}: Eyes stay fully open (no blinking animation)
- animated={false}: No ambient animations

Normal dashboard behavior unchanged - Blobbi still blinks and follows pointer.
2026-03-21 20:56:13 -03:00
filemon b14717eddb fix(blobbi): ensure polaroid export matches modal preview exactly
The date text was wrapping to a second line in the exported PNG but not
in the modal preview. This was caused by html-to-image rendering text
differently when using Tailwind classes.

Fixes:
- Convert caption area to inline styles for consistent html-to-image export
- Add whitespace: nowrap to date, stage badge, and caption elements
- Add explicit width constraint to caption container
- Use inline styles instead of Tailwind classes for all caption text

This ensures the downloaded PNG and Blossom-posted image match the
modal preview exactly, with the date staying on one line.
2026-03-21 20:50:23 -03:00
filemon 5891014ff6 refactor(blobbi): refine polaroid photo implementation
1. Polaroid layout - now looks like a real polaroid:
   - White frame on ALL sides (top: 16px, sides: 16px, bottom: 80px)
   - Photo area is inset within the white frame
   - Caption area positioned at bottom of frame
   - Consistent off-white background (#fafafa) for export

2. Removed noisy debug logs:
   - Removed [BlobbiStageVisual][baby] and [adult] console.logs
   - These were render-time logs that added noise without value

3. Improved export/post robustness:
   - URL extraction now explicitly finds 'url' tag instead of assuming [0][1]
   - Added error handling if upload returns no URL
   - Safer parsing that doesn't depend on tag order

4. Verified visual export consistency:
   - White frame visible on all sides
   - Blobbi centered in photo area
   - No clipping or gaps
   - Caption properly positioned
2026-03-21 20:39:50 -03:00
filemon b51535bfa0 feat(blobbi): implement Blobbi Photo (Polaroid) feature
Add the ability to take polaroid-style photos of Blobbis and share them:

- Add lookMode prop to Blobbi rendering system:
  - 'follow-pointer': Eyes track mouse cursor (default, existing behavior)
  - 'forward': Eyes look straight ahead (for photos/export)
  - Updated useBlobbiEyes, BlobbiBabyVisual, BlobbiAdultVisual, BlobbiStageVisual

- Create BlobbiPolaroidCard component:
  - Classic polaroid-style frame with white background and shadow
  - Soft gradient background for photo area
  - Caption area with Blobbi name, stage, and date
  - Fixed dimensions (320x400) for consistent export
  - Built with HTML+CSS (not canvas) for easy customization

- Create BlobbiPhotoModal component:
  - Opens from 'Take a Photo' button on BlobbiPage
  - Shows polaroid preview with Blobbi looking forward
  - Download button: exports as PNG using html-to-image
  - Post button: uploads to Blossom and creates kind 1 note
  - Clean, minimal UI focused on the photo

- Wire up to BlobbiPage:
  - Photo modal state and handler
  - Connected to floating 'Take a Photo' action button

Dependencies:
- Added html-to-image for DOM-to-PNG conversion
2026-03-21 20:29:03 -03:00
filemon 8476edd18f chore(blobbi): temporarily hide unimplemented floating action buttons
Hide 'Set as Companion' and 'Open PiP' buttons from BlobbiDashboardFloatingControls
until their features are implemented. Code is commented out with TODO markers for
easy re-enablement later.

Remaining visible buttons: Take a Photo, Info, Hatch/Evolve actions
2026-03-21 20:15:04 -03:00
filemon e528cdb36d feat(blobbi): add stage-aware validation and semantic cleanup
- Detect final stage from tags and validate tags against stage constraints
- Remove tags not valid for the detected stage (e.g., adult_type only on adults)
- Fix state after transitions (incubating/evolving -> active)
- Validate state is valid for the stage (per VALID_STATES_BY_STAGE)
- Skip required tag recovery for tags not valid for current stage
- Skip persistent tag recovery for tags not valid for current stage
- Add dev diagnostics with console.warn when repairs are applied
- Return finalStage in TagRepairResult for caller inspection
2026-03-21 20:08:56 -03:00
filemon 8696c698ed feat(blobbi): implement tag integrity guard for all Blobbi events
Add validateAndRepairBlobbiTags function that ensures tag integrity
whenever a Blobbi is republished. The guard:

1. Validates tags against the canonical schema
2. Removes deprecated tags automatically
3. Removes task-related tags during stage transitions (when requested)
4. Recovers missing required tags from:
   - Previous canonical tags (if available)
   - System defaults (for b, t, client only)
5. Preserves all persistent tags from previous state
6. NEVER invents personality/trait/adult_type values

Integration points:
- mergeBlobbiStateTagsForRepublish: validates all tag merges
- useBlobbiHatch: validates with task cleanup before publishing
- useBlobbiEvolve: validates with task cleanup before publishing
- buildMigrationTags: validates migration output

Repair strategy:
- System tags (b, t, client): recover from defaults
- Identity tags (name, seed, d): recover from previous only, never invent
- Personality tags: preserve if exist, never invent
- Visual tags: preserve (regenerable from seed if needed)
- Stats: preserve current values
- Task tags: cleanup during transitions
2026-03-21 20:02:20 -03:00
filemon c236caefad docs(blobbi): add product spec for Blobbi tag schema
Create docs/blobbi/blobbi-tag-schema.md as the canonical source of truth
for Blobbi tag definitions. The runtime schema at blobbi-tag-schema.ts
MUST align with this spec.

Includes:
- All 35 canonical tags organized into 11 categories
- Required vs optional designation
- Persistence rules across stage transitions
- Stage transition rules (hatch, evolve)
- Migration rules
- Validation rules
- 8 deprecated tags with migration guidance
2026-03-21 19:50:03 -03:00
filemon f9de5282c9 fix(blobbi): preserve personality/extension tags in migration flow
Update buildMigrationTags to preserve all persistent tags when migrating
legacy Blobbis to canonical format:

- personality, trait, favorite_food, voice_type, mood
- adult_type
- theme, crossover_app

Per blobbi-tag-schema.md spec: Do NOT invent values for tags that don't
exist. Only preserve values that are already present in the legacy event.

Also adds docs/blobbi/blobbi-tag-schema.md as the product spec for all
Blobbi tag definitions. The runtime schema in blobbi-tag-schema.ts MUST
align with this spec.
2026-03-21 19:49:53 -03:00
filemon a4c2895c68 docs(blobbi): add canonical tag schema for Blobbi events
Create blobbi-tag-schema.ts as the single source of truth for all
Blobbi tag definitions. The schema documents:

- 35 canonical tags organized into 11 categories
- Tag metadata: required, stages, persistent, source, regenerable
- 8 deprecated tags with migration guidance
- Helper functions for schema queries and validation

Categories:
- system: d, b, t, client
- identity: name, seed, generation
- visual: base_color, secondary_color, eye_color, pattern, special_mark, size
- personality: personality, trait, favorite_food, voice_type, mood
- stats: hunger, happiness, health, hygiene, energy
- state: stage, state, last_interaction, last_decay_at
- task: state_started_at, task, task_completed
- progression: experience, care_streak
- social: visible_to_others, breeding_ready
- evolution: adult_type
- extension: theme, crossover_app

Also updates MANAGED_BLOBBI_STATE_TAG_NAMES to include extension
tags (theme, crossover_app) and reorganizes comments by category.
2026-03-21 19:39:34 -03:00
filemon e33ee800bc fix(blobbi): preserve identity attributes across stage transitions
- Add identity/personality tags (personality, trait, favorite_food,
  voice_type, mood, adult_type) to MANAGED_BLOBBI_STATE_TAG_NAMES
- Add 'interact_6_progress' to DEPRECATED_BLOBBI_TAG_NAMES to remove
  legacy interaction tracking
- Update hatch flow to clean only task/state-specific tags while
  preserving all identity attributes from canonical.allTags
- Update evolve flow to clean only task/state-specific tags while
  preserving all identity attributes, and set state to 'active'

This ensures Blobbi identity persists across egg → baby → adult
transitions, treating them as persistent entities rather than
reconstructed objects at each stage.
2026-03-21 19:35:53 -03:00
filemon 24aa80840c Merge branch 'main' into feat-blobbi 2026-03-21 19:24:47 -03:00
The Daniel d9aa6258cd fix: allow nsec paste in login field 2026-03-21 18:07:46 -04:00
The Daniel 941e6ee4e6 feat: block nsec paste in all input fields with warning toast
Adds a global capture-phase paste listener that detects nsec private
keys and prevents them from being pasted into any field. Shows a
destructive toast warning the user that private keys should never
be shared.
2026-03-21 18:03:17 -04:00
filemon ffab9d3aaa feat(blobbi): add dev-only instant stage transition button
- Add 'Dev Hatch' button for eggs (bypasses incubation tasks)
- Add 'Dev Evolve' button for babies (bypasses evolution tasks)
- Button only appears in dev mode (import.meta.env.DEV)
- Uses dashed amber border style to clearly indicate dev-only
- Reuses existing onHatch/onEvolve callbacks
- No button shown for adults (no further transitions)
2026-03-20 18:15:30 -03:00
filemon 4d16e1ab83 Merge branch 'main' into feat-blobbi 2026-03-20 18:13:32 -03:00
filemon 6781685252 update typo in epsy url in blobbi 2026-03-20 18:03:52 -03:00
filemon 88bdf87e95 feat(blobbi): reduce evolve post requirement from 3 to 1
Make evolve process lighter and less repetitive:
- EVOLVE_REQUIRED_POSTS: 3 → 1
- Update task name: 'Create Posts' → 'Share Evolution'
- Update task description: 'Share 3 posts about evolving' → 'Post about your Blobbi evolving'
- Reduce query limit since only 1 post needed

Hatch process unchanged - still requires 3 posts with hatch-specific validation
2026-03-20 17:29:14 -03:00
filemon 6b3d98bd66 refactor(blobbi): unify task process with useActiveTaskProcess hook
- Add useActiveTaskProcess hook to consolidate hatch/evolve task logic
- Rename useSyncHatchTaskCompletions to useSyncTaskCompletions
- Fix badge to include dynamic tasks (was only counting persistent)
- Fix 'Edit Wall' task link to point to /settings/profile
- Reduce duplication in BlobbiPage.tsx by using unified hook
- Export new hook and types from blobbi/actions index
2026-03-20 16:07:38 -03:00
filemon ec7ceb2352 fix(blobbi): fix task tag writing and unify badge logic for both processes
BUG 1 - task/task_completed tags not being written:
- incrementInteractionTaskTags now accepts requiredInteractions param
  (7 for hatch, 21 for evolve) instead of hardcoded value
- useBlobbiDirectAction and useBlobbiUseInventoryItem now increment
  interactions for BOTH incubating AND evolving states
- useSyncHatchTaskCompletions now syncs for both processes

BUG 2 - evolving missions not affecting badge:
- remainingTasksCount now uses active process tasks (hatch or evolve)
- allTasksComplete now checks both incubating and evolving states
- BlobbiBottomBar uses isInTaskProcess instead of isIncubating

Architecture rules preserved:
- Persistent tasks: can be cached in task/task_completed tags
- Dynamic tasks: NEVER stored in tags, UI-only
- No infinite loops, no retroactive increments
- Interactions only increment during real user actions
2026-03-20 16:01:15 -03:00
filemon c62c38b136 feat(blobbi): implement evolve task system with dynamic stat requirements
Add comprehensive evolution task system parallel to incubation:

Architecture:
- Separate persistent tasks (event-based, cacheable in tags) from
  dynamic tasks (stat-based, never cached, recomputed every render)
- Both hatch and evolve require all persistent AND dynamic tasks complete

New hooks:
- useEvolveTasks: 6 persistent tasks + 1 dynamic stat task (all stats >= 80)
- useStartEvolution/useStopEvolution: manage evolution state transitions

New components:
- TasksPanel: generalized task display for both hatch and evolve
- StartEvolutionDialog: confirmation dialog for starting evolution
- Updated BlobbiMissionsModal to handle both hatch and evolve flows

Evolve tasks (baby → adult):
- Create 3 themes, 3 color moments, 3 evolve posts
- 21 interactions, use Blobbi shape, edit wall once
- Dynamic: maintain all 5 stats >= 80

Key fixes:
- Renamed isEvolving variable collision in BlobbiPage
- Filter dynamic tasks from sync to prevent tag pollution
- Updated BlobbiPostModal to support evolve posts dynamically
2026-03-20 15:31:02 -03:00
DanConwayDev 33bf59f353 fix: remove unexported NLoginBunker import and prefer-const lint error 2026-03-20 17:22:38 +00:00
DanConwayDev b4d3c4833c feat: remote signer UX improvements for Amber/NIP-46 users on Android
Amber users on Android who manually approve events must switch from Ditto to
Amber to approve, then switch back. Backgrounding Ditto can freeze its
WebSocket, causing the NIP-46 response to be silently dropped — leaving the
operation hanging with no feedback and no way out. Users with Amber
notifications working correctly are unaffected, as approving via notification
does not background Ditto.

Even with auto-approve enabled, kinds outside Amber's default whitelist
require manual approval. Ditto uses several of these regularly: kind 1059
(NIP-17 gift-wrap DMs), 1111 (comments), 1311 (live chat), 31925 (RSVPs),
24242 (Blossom file upload auth), and 30078 (app settings).

This introduces signerWithNudge, a NostrSigner wrapper with the following
behaviour:

Nudge toast after 4 seconds
If a signing or encryption op is still pending after 4 s, a persistent toast
appears naming what is being approved (e.g. 'Approve file upload auth'), with
a human-readable label derived from the event kind.

Android 'Approve in signer' button
On Android the nudge toast includes an 'Approve in signer' button that opens
Amber via the nostrsigner: URI scheme, keeping the WebSocket alive. After
tapping, the button becomes a spinner and a Cancel button appears.

Automatic retry on foreground resume
When Ditto returns to the foreground after being backgrounded, it
automatically retries the pending NIP-46 request (up to 2 times) and shows a
brief 'Checking for signer response' toast.

Hard 45-second timeout
Operations with no response within 45 s are rejected with a clear error.

Cancel / Skip
The nudge toast has a Skip link throughout. After tapping 'Approve in signer'
it becomes a full Cancel button.

Multi-phase encrypt-then-sign
Saving app settings (kind 30078) and mute lists (kind 10000) require a nip44
encrypt followed immediately by a signEvent. When the encrypt nudge was shown,
a phase-transition toast tells the user a second approval is coming. The check
is kind-specific to avoid false positives.

Success feedback
A brief 'Approved' toast confirms the outcome when the nudge was shown.

Relay connectivity check
At nudge time, if the bunker relay WebSocket is not OPEN the toast warns
'Signer relay unreachable' instead of prompting for an approval that cannot
be delivered.

Accessibility
Toast buttons meet the 44 px touch target minimum. Text size and contrast
were increased for readability on small screens.
2026-03-20 17:22:37 +00:00
filemon 44b54f6c32 fix(blobbi): fix remainingTasksCount memo and allTasksComplete condition
- remainingTasksCount now depends on hatchTasks.tasks (not just completedTaskIds)
  to correctly reflect loading state and task changes
- allTasksComplete is now a memoized value that prevents false positives by
  checking: isIncubating && !isLoading && tasks.length > 0 && remaining === 0
- Changed missions badge symbol from '?' to '!' when all tasks complete
2026-03-20 12:02:12 -03:00
filemon 57f7af3141 refactor(blobbi): stabilize task sync, fix hatch content, improve UI indicators
Task Sync Stability:
- Remove hatchTasks.tasks from useEffect dependencies (was causing instability)
- Derive tasksToSync and remainingTasksCount via useMemo keyed off completedTaskIds
- Effect dependencies now only include stable primitives: completedTaskIds,
  cachedCompletedIds, hatchTasks.isLoading, companion?.state

Content Fix on Stage Transition:
- Add generateBlobbiContent() helper with correct grammar ('an egg' vs 'a baby')
- useBlobbiHatch now generates new content: '{name} is a baby Blobbi.'
- useBlobbiEvolve now generates new content: '{name} is an adult Blobbi.'
- Content always reflects current stage (was keeping old egg content)

Missions Icon Badge:
- Show remaining task count when incubating with tasks remaining
- Show '?' badge (success variant) when all tasks complete during incubation
- Badge variants: default (blue), warning (amber), success (emerald)

Blobbies Icon Badge:
- Now shows count of Blobbies needing care (any stat < CARE_THRESHOLD=40)
- Warning variant (amber) when there are needy Blobbies
- Only shows badge when count > 0

Selector Modal Warning:
- Add AlertTriangle indicator in top-right corner for Blobbies needing care
- Uses same companionNeedsCare() logic as bottom bar badge
2026-03-20 11:44:48 -03:00
filemon 23dbd9112a fix(blobbi): prevent infinite loop in task sync, add missions badge
Infinite Loop Fix:
- Add useRef anti-loop memory (lastSyncedKeyRef) to track last synced key
- Mark key as synced BEFORE calling sync to prevent race conditions
- Add guard for companion.state !== 'incubating'
- Remove syncTaskCompletions from useEffect deps (intentional, prevents
  re-triggering when mutation function reference changes)
- Reset ref on error to allow retry

Missions Badge:
- Add remainingTasksCount prop to BlobbiBottomBar
- Show badge on Missions button when incubating with incomplete tasks
- Update BottomBarButton to show badge when count > 0 (was > 1)

The interaction task tags were already being updated correctly by
incrementInteractionTaskTags() in useBlobbiDirectAction and
useBlobbiUseInventoryItem hooks during real user interactions.
2026-03-20 11:19:41 -03:00
filemon 44c8103600 refactor(blobbi): make incubation flow explicit, require Blobbi name in posts
- useStartIncubation now requires explicit mode ('start', 'restart', 'switch')
  instead of auto-detecting behavior. This makes the flow predictable.
- StartIncubationDialog determines mode and passes it to onConfirm callback
- Removed useUpdateTaskProgress hook (architecturally inconsistent - updated
  last_interaction during cache-only sync, violating the rule that only real
  user actions should update timestamps)
- BlobbiPostModal now requires blobbiName and process props for stage-aware
  post generation with Blobbi name as first hashtag
- isValidBlobbiPost() now validates the Blobbi name hashtag is present
- Added sanitizeToHashtag helper to both BlobbiPostModal and useHatchTasks
  for consistent hashtag generation
2026-03-20 10:28:10 -03:00
filemon d2441b345b fix(blobbi): make task sync fully idempotent and safe
Audit and hardening of hatch task cache sync:

1. BlobbiPage useEffect:
   - Use useMemo to create stable string keys for completion comparison
   - Only trigger sync when computed completions differ from cached
   - Skip sync entirely if no diff exists
   - Add dev-only debug logs

2. useSyncHatchTaskCompletions:
   - Remove last_interaction update (this is cache sync, not user action)
   - Add double diff check: first against companion.tasksCompleted, then against canonical.allTags
   - Return detailed result with skip reasons for debugging
   - Add dev-only debug logs for all sync decisions

3. incrementInteractionTaskTags:
   - Add check for already-completed state to prevent duplicate task_completed tags
   - Return previousCount for debugging
   - Add dev-only debug logs
   - Document that this is NOT idempotent by design (each call = real interaction)

Key guarantees:
- Cache-only sync never mutates last_interaction
- Multiple renders with same data = no publish
- WebSocket updates or refetches = no publish unless real diff
- No duplicate task_completed tags possible
2026-03-20 10:13:19 -03:00
filemon 2c828c8778 feat(blobbi): add missions modal, single incubation enforcement, and task sync
Phase 2 of Blobbi incubation audit:

- Add BlobbiMissionsModal component with HatchTasksPanel integration
- Move hatch tasks UI from main page to Missions modal
- Add useStopIncubation hook with confirmation dialog
- Enforce only one Blobbi incubating at a time (auto-stops previous)
- Enhance StartIncubationDialog to show switch warning for other incubating Blobbi
- Add useSyncHatchTaskCompletions hook to sync task completions to kind 31124 tags
- Consolidate STAT_MIN/STAT_MAX to single source in src/lib/blobbi.ts
- Remove unused BlobbiPlaceholderModal component
2026-03-20 09:59:50 -03:00
filemon 8097f0e5fb fix(blobbi): fix selection reset bug, stat safety, and migration visual traits
- Remove auto-save effect that was overwriting user selection during
  WebSocket/query updates. User selection now only persists via explicit
  handleSelectBlobbi() call.
- Add debug logging to trace selection changes in development mode.
- Add STAT_MIN=1 and STAT_MAX=100 constants to blobbi-decay.ts and update
  clamp() to use STAT_MIN instead of 0, preventing soft-lock issues.
- Fix buildMigrationTags() to always derive and include all visual traits
  during migration, ensuring every migrated event has complete visual data.
2026-03-20 09:47:11 -03:00
filemon 14644d0cb3 Clean up deprecated tags in migration and republish flows
- Remove incubation_time and start_incubation from coreStateTags in
  buildMigrationTags - these obsolete fields should not be carried
  forward during migration
- Add DEPRECATED_BLOBBI_TAG_NAMES to the exclusion set in buildMigrationTags
  when filtering unknown tags, preventing deprecated tags from being
  preserved in migrated events
- Add DEPRECATED_BLOBBI_TAG_NAMES filtering to mergeTagsForRepublish so
  deprecated tags are not carried forward when republishing existing events

The deprecated tags (incubation_time, start_incubation, incubation_progress,
egg_status, fees) are now properly excluded from:
1. New events (already handled by buildEggTags)
2. Migrated events (buildMigrationTags)
3. Republished events (mergeTagsForRepublish, mergeBlobbiStateTagsForRepublish)

Legacy parsing still reads these fields for backwards compatibility, but
they are never written to new events.
2026-03-19 13:06:31 -03:00
filemon 4c89a20bbe Fix critical bug: Blobbis disappear after starting incubation
The isValidBlobbiEvent function only accepted 3 states (active, sleeping,
hibernating) but BlobbiState type includes 5 states. When state changed to
'incubating' or 'evolving', events failed validation and were filtered out
by useBlobbisCollection and useBlobbiCompanion hooks.

Added 'incubating' and 'evolving' to the valid states list.
2026-03-19 11:50:45 -03:00
filemon 6a2285ef72 Clean up Blobbi incubation system and enforce consistency
Deprecated tags removed from creation:
- incubation_time: No longer created in buildEggTags or previewToEventTags
- start_incubation: No longer in managed tags
- egg_status: Removed from LEGACY_VISUAL_TAG_NAMES (was duplicated)

These tags are now in DEPRECATED_BLOBBI_TAG_NAMES and stripped on republish.

Visual trait consistency:
- buildEggTags now includes all visual traits (base_color, secondary_color,
  eye_color, pattern, special_mark, size) derived from seed
- VISUAL_TRAIT_TAG_NAMES replaces LEGACY_VISUAL_TAG_NAMES
- Visual traits added to MANAGED_BLOBBI_STATE_TAG_NAMES

Stat safety:
- STAT_MIN = 1 (was 0) to prevent soft-lock
- STAT_MAX = 100
- clampStat now clamps to 1-100 range instead of 0-100
- Recovery is always possible with any healing item

Layout improvements:
- BlobbiPage container: max-w-2xl on mobile, max-w-3xl on desktop
- Reduced side whitespace on mobile (px-2)
- Better breathing room overall

BlobbiCompanion interface:
- incubationTime and startIncubation marked as @deprecated
- DEFAULT_INCUBATION_TIME marked as @deprecated

state_started_at remains the single source of truth for process timing.
2026-03-19 09:55:26 -03:00
filemon 37a791f113 Fix incubation-start flow to properly apply decay and clean obsolete tags
- Apply accumulated decay from last_decay_at to now before state change
- Write decayed stat values into the incubation-start event
- Set last_decay_at = state_started_at = last_interaction for consistency
- Add incubation_progress, egg_status, fees to DEPRECATED_BLOBBI_TAG_NAMES

The incubation-start event now has consistent timestamps:
- created_at: NOW
- state_started_at: NOW
- last_interaction: NOW
- last_decay_at: NOW (was incorrectly preserving old value)
2026-03-19 09:39:03 -03:00
filemon 06f820d355 Refine Blobbi incubation/hatch task system
- Fix Color Moment URL: espy.social -> espy.you
- Improve shape-change task detection: only counts true post-start changes
- Remove duplicate Start Incubation button, integrate into evolve/hatch button
- Extract shared incrementInteractionTaskTags helper for code reuse
- Update floating controls to show incubation action for eggs not yet incubating

The evolve/hatch button now serves as the single entry point:
- Egg (not incubating): Opens incubation dialog
- Egg (incubating): Button hidden, hatch action in HatchTasksPanel
- Baby: Evolves to adult
2026-03-19 09:04:08 -03:00
filemon 3a2571ccc6 Merge branch 'main' into feat-blobbi 2026-03-19 08:08:37 -03:00
filemon dd614e6a6a Merge branch 'main' into feat-blobbi 2026-03-18 18:00:55 -03:00
filemon f5595b3477 feat(blobbi): implement hatch task system for egg incubation
Add task-based progression system for hatching Blobbi eggs:

- Extend BlobbiState with 'incubating' and 'evolving' states
- Add task tracking fields to BlobbiCompanion interface
- Create useStartIncubation hook to begin incubation process
- Create useHatchTasks hook to compute task progress from Nostr events
- Add HatchTasksPanel UI component showing 5 hatch tasks:
  - Create Theme (kind 36767)
  - Color Moment (kind 3367)
  - Change Avatar Shape (kind 0 with shape change)
  - Create Post with required prefix/hashtags
  - 7 Blobbi interactions
- Add BlobbiPostModal with enforced content requirements
- Add StartIncubationDialog confirmation dialog
- Auto-increment interaction counter in action hooks
- Gate hatch button visibility based on task completion
2026-03-18 16:44:38 -03:00
filemon e9c34df51e fix(blobbi): show aggregated stat preview in inventory use dialog
The inventory modal's use confirmation dialog was showing simple
multiplication (effect * quantity) instead of the actual clamped
values that would be applied.

Now it simulates the sequential application of effects, clamping
at each step, to show the true total effect that will be applied
when confirming. This matches the behavior of the action modal's
confirmation dialog.
2026-03-18 11:34:25 -03:00
filemon 82b9964a61 feat(blobbi): improve item usage UX with quantity selector and inventory actions
- Add quantity selector to item usage modal (feed/play/clean/medicine)
  - Users can now use multiple items at once
  - Effects are applied sequentially with proper clamping at each step
  - Shows estimated total effect preview

- Add 'Use' button to inventory modal
  - Items can now be used directly from inventory
  - Reuses same logic as normal item usage flow
  - Opens confirmation dialog with quantity selector

- Add stage-based item blocking in inventory
  - Eggs cannot use food or toys (shown disabled with reason)
  - Shell Repair Kit only usable by eggs (blocked for baby/adult)
  - Blocked items remain visible but cannot be used

- Shell Repair Kit visibility in medicine modal
  - Only appears for eggs, hidden for other stages

- Make shop modal narrower (max-w-4xl to max-w-2xl)

- Add centralized item usability logic (canUseItemForStage)
  - Single source of truth for item/stage restrictions
  - Exported from actions module for reuse
2026-03-18 11:24:07 -03:00
filemon a0cb5cc307 Merge branch 'main' into feat-blobbi 2026-03-18 10:51:45 -03:00
filemon 0b26ef51a2 feat: add pot to Rosey shape 2026-03-18 10:50:44 -03:00
filemon da3cc5f997 fix: apply shape mask to hover/edit overlay in ProfileCard
The hover overlay was showing as a full rectangle instead of respecting
the Blobbi shape mask. This happened because the overlay was using the
sync getAvatarMaskUrl() which returns empty for Blobbi shapes (they now
render asynchronously as PNG).

Changed ProfileCard to:
- Use getAvatarMaskUrlAsync() for the overlay mask
- Load mask URL via useEffect with proper cleanup
- Apply the same mask to the hover overlay as the avatar itself

Now the hover/edit darkening effect respects the actual avatar shape,
only appearing inside the visible silhouette.
2026-03-18 10:44:28 -03:00
filemon 96356eb804 fix: render Blobbi masks to PNG via canvas for reliable CSS mask-image
CSS mask-image with SVG data URLs doesn't work reliably for complex SVGs
with transforms in some browsers. The picker preview works because it renders
inline SVG directly, but the avatar mask-image was failing for shapes like
droppi, flammi, leafy, mushie, owli, rocky, and rosey.

Changes:
- Rewrite getBlobbiMaskUrl() to render SVG to canvas and export PNG
- Use Blob URL for SVG loading (more reliable than data URL)
- Add async mask generation with proper caching and deduplication
- Update Avatar component to load masks asynchronously via useEffect
- Add getAvatarMaskUrlAsync() for async mask URL retrieval

The picker preview continues to use inline SVG (which works fine),
while the avatar mask now uses rasterized PNG (which works everywhere).
2026-03-18 10:36:27 -03:00
filemon 80067a212c fix: inject fill/stroke attributes directly into SVG elements for mask rendering
The previous approach using <style> tags with CSS selectors inside SVG data URLs
didn't work reliably when the SVG was used as a CSS mask-image. Some browsers
don't process CSS inside SVG data URLs correctly.

This fix:
- Adds injectWhiteFillStroke() function to directly inject fill="white" and
  stroke="white" attributes into each SVG shape element
- Properly handles elements with transform attributes (the root cause of
  droppi, flammi, leafy, mushie, owli, rocky, rosey failing)
- Preserves fill="none" for stroke-only elements (like catti's tail)
- Works reliably across all browsers since it uses SVG attributes, not CSS
2026-03-18 10:28:08 -03:00
filemon d962d7952b fix: remove hardcoded white stroke from catti tail and fix avatar mask updates
1. Remove stroke="white" from catti shape - let the styling apply colors
2. Fix Avatar component to properly update mask when shape changes:
   - Compute maskUrl outside useMemo so it's always fresh
   - Use maskUrl directly in dependencies instead of shape string
   - This ensures the mask updates immediately when selecting a new shape
2026-03-18 10:21:39 -03:00
filemon ae4847ce50 fix: use SVG data URL directly for Blobbi mask instead of canvas rendering
The previous implementation tried to render SVG to canvas synchronously,
but Image loading is asynchronous, causing getBlobbiMaskUrl() to return
empty string and avatars to fall back to squares.

CSS mask-image supports SVG data URLs directly, so we now skip the
canvas conversion entirely. This is simpler, more reliable, and works
synchronously.

- Remove renderSvgToMaskUrl() and drawImageToCanvas() functions
- Simplify getBlobbiMaskUrlAsync() to just wrap sync version
2026-03-18 10:15:45 -03:00
filemon a07a2de786 fix: scope Blobbi shape colors and increase preview size
- Use fill/stroke attributes on <g> instead of global CSS to prevent color leakage
- Reduce grid columns from 5 to 4 for larger shape previews
- Reduce inner padding from inset-1 to inset-0.5 for bigger shapes
2026-03-18 10:09:08 -03:00
filemon 96ce34c7f1 refactor: use raw SVG markup for Blobbi shapes instead of single path
- Change BlobbiShape type from 'path: string' to 'svg: string'
- Store original SVG body markup preserving circles, ellipses, rects, paths, transforms, and strokes
- Update getBlobbiMaskUrl() to render SVG string via Image element instead of Path2D
- Add getBlobbiMaskUrlAsync() for guaranteed async loading
- Add getBlobbiShapeSvg() helper to get complete SVG markup with custom fill
- Update BlobbiShapePicker to render multi-element SVG with dangerouslySetInnerHTML
- Shapes now visually match original SVG files exactly (e.g., catti tail is stroke-based)
2026-03-18 09:50:13 -03:00
filemon c6fa1acf66 refactor: improve Blobbi shape picker UI and update character silhouettes
- Add tight viewBox computation for better shape visibility in picker
- Change grid layout to 5 columns for larger, more prominent shapes
- Remove shape name labels for cleaner picker interface
- Rename 'Blobbi' tab to 'Blobbids' in avatar picker dialog
- Update all adult Blobbi SVGs with refined designs
- Redesign shape paths with detailed silhouettes including limbs and accessories
- Add pot to Leafy, adjust Pandi arms, remove shadows from Rocky
2026-03-18 09:44:23 -03:00
filemon a7c29c4a85 feat: add Blobbi shapes as avatar masks
New feature allowing users to select Blobbi character silhouettes as
avatar masks, in addition to existing emoji shapes.

New files:
- src/lib/blobbiShapes.ts: Shape definitions with SVG paths for all
  Blobbi forms (egg, baby, 16 adults)
- src/components/BlobbiShapePicker.tsx: Grid picker with category tabs

Changes:
- Extended avatarShape.ts to support 'blobbi:' prefixed shape values
- Added getAvatarMaskUrl() unified function for both shape types
- Updated Avatar component to render Blobbi masks
- Added tabbed UI in ProfileCard (Emoji/Blobbi tabs)
- Renamed emojiAvatarBorderStyle to shapedAvatarBorderStyle

Shape format: 'blobbi:<id>' (e.g., 'blobbi:baby', 'blobbi:catti')
Stored in kind-0 metadata as 'shape' property, same as emojis.

Available shapes:
- Egg (simple egg silhouette)
- Baby Blobbi (water droplet)
- Adults: catti, owli, froggi, droppi, flammi, crysti, cloudi,
  mushie, starri, pandi, cacti, breezy, leafy, rocky, rosey, bloomi
2026-03-17 12:32:46 -03:00
filemon 91364385c3 fix: use data attributes for blink center instead of CSS parsing
Changes:
- Store blink center as data-cx/data-cy attributes on .blobbi-blink groups
- Use eye white center (cx, cy) as blink anchor when available
- Fallback to pupil center if no eye white found
- Read center from data attributes in animation loop (more reliable than CSS)

Structure per eye:
<g class="blobbi-blink" data-cx="38" data-cy="45">  <!-- blink group -->
  <ellipse ... />                                        <!-- eye white -->
  <g class="blobbi-eye">                               <!-- tracking group -->
    <circle ... />                                       <!-- pupil -->
    <circle ... />                                       <!-- highlight -->
  </g>
</g>

Blink transform: translate(cx,cy) scale(1,blinkY) translate(-cx,-cy)
- Scales around the actual eye center from SVG element data
- No CSS transform-origin parsing needed
- Eye closes in place without shifting
2026-03-17 12:21:02 -03:00
filemon d958722e63 fix: scale blink around eye center to prevent downward shift
Problem: scale(1 blinkY) was scaling from top-left origin, causing
the eye to move down during blink.

Solution: Use translate-scale-translate pattern to scale around the
eye's center point:
  translate(cx cy) scale(1 blinkY) translate(-cx -cy)

The center coordinates are extracted from the transform-origin style
that was already set during SVG processing.

Tracking behavior remains completely unchanged - only the blink
transform application was modified.
2026-03-17 12:11:10 -03:00
filemon d3a19ebfaa fix: separate tracking and blink into distinct transform groups
Problem: Blink was only affecting pupil, not whole eye.
Previous attempt: Wrapping entire eye in one group caused eye white to
move with mouse tracking.

Solution: Two separate nested groups per eye:
- .blobbi-blink (outer): wraps entire eye for scaleY blink animation
- .blobbi-eye (inner): wraps only pupil+highlight for translate tracking

Structure:
<g class="blobbi-blink">     <!-- blink: scale(1 blinkY) -->
  <ellipse ... />             <!-- eye white - stays fixed -->
  <g class="blobbi-eye">    <!-- tracking: translate(x y) -->
    <circle ... />            <!-- pupil - moves with mouse -->
    <circle ... />            <!-- highlight - moves with mouse -->
  </g>
</g>

Result:
- Mouse tracking: only pupil+highlight translate (unchanged behavior)
- Blinking: entire eye scales vertically (natural cartoon blink)
- Eye white: never moves, only scales during blink
2026-03-17 12:01:08 -03:00
filemon c17883bdb8 feat: add natural blinking system for Blobbi eyes
- Random blink intervals between 2-5 seconds for organic feel
- Blink animation: fast close (~80ms), pause (~100ms), slower open (~120ms)
- 20% chance for double blinks (extra polish)
- Uses scaleY transform combined with mouse tracking translate
- Easing functions: ease-in for close, ease-out for open
- Disabled when Blobbi is sleeping
- No CSS transitions - all animation via RAF for instant response
2026-03-17 11:42:40 -03:00
filemon 0b90b0206b fix: eliminate eye tracking lag with direct SVG transforms
- Remove CSS transitions from .blobbi-eye class (root cause of delay)
- Use SVG transform attribute instead of style.transform for reliable repaints
- Cache eye element references after mount with automatic refresh on SVG changes
- Hook now manages DOM directly without onUpdate callback
- Simplified visual components to just pass containerRef
2026-03-17 11:34:58 -03:00
filemon 8f958ef6c7 refactor: simplify eye system to always track mouse globally
Removed from previous system:
- Idle random movement logic
- Energy-based behavior (timing, smoothing, micro-movements)
- Tracking radius (200px distance check)
- Idle/tracking state switching
- lerp interpolation for tracking
- isTracking state and callback parameter
- Per-instance mouse listeners

New behavior:
- Eyes ALWAYS follow the mouse cursor
- Works across the entire screen (no distance limit)
- Instant response (no interpolation, no lag)
- Simple angle calculation every frame

How the new tracking loop works:
1. Global mouse listener updates globalMouseX/globalMouseY
2. RAF loop runs every frame
3. Calculate angle: atan2(mouseY - centerY, mouseX - centerX)
4. Calculate position: cos(angle) * max, sin(angle) * max * 0.7
5. Call onUpdate callback with position
6. DOM updated directly (no React state)

Performance optimizations:
- Single global mouse listener shared by all Blobbi instances
- Instance count tracking for cleanup
- No React state in animation loop
- Direct DOM manipulation via callback
- Minimal computation per frame (just trig)

Hook reduced from ~390 lines to ~140 lines.
2026-03-17 10:35:44 -03:00
filemon 2118fa483b fix: remove React state from animation loop for real-time eye tracking
Problems fixed:

1. React state caused tracking delay
   - setState() batches updates and triggers re-renders
   - Even with refs, calling setState inside RAF caused 1-2 frame lag
   - Eyes only followed mouse properly when it stopped moving

2. Tracking intensity was inverted
   - Old: farther mouse = stronger movement (wrong)
   - New: closer mouse = stronger movement (correct)
   - Formula: intensity = 1 - Math.pow(normalizedDistance, 0.5)

3. Idle froze after mouse left
   - Old: scheduled idle change 300-800ms later
   - New: immediately triggers new idle target when tracking stops
   - Formula: nextIdleChangeRef.current = currentTime (force immediate)

Solution - callback-based architecture:

- REMOVED all setState calls from animation loop
- Added onUpdate(left, right, isTracking) callback option
- Callback is called every RAF frame with current positions
- Components apply transforms directly to DOM via querySelectorAll
- Zero React re-renders during animation = zero lag

Data flow now:
  RAF loop → compute position → onUpdate callback → direct DOM update

Before:
  RAF loop → setState → React re-render → useEffect → DOM update

The onUpdate callback receives positions every frame and applies
transforms immediately, bypassing React's batching entirely.
2026-03-17 10:29:25 -03:00
filemon 2bb9c7738a feat: instant mouse tracking and energy-based idle behavior for Blobbi eyes
1. Mouse tracking now INSTANT (no lag):
   - When tracking, eyes lock directly onto cursor position
   - No interpolation/lerp during tracking mode
   - Feels like 'locked on target' instead of floating/chasing

2. Energy-based idle behavior:
   - High energy (100): frequent movement, shorter pauses, quicker smoothing
   - Low energy (0): lazy movement, longer pauses, slower drift
   - Energy affects: idle duration, smoothing speed, micro-movement chance

3. Micro-movements for aliveness:
   - Small movements (0.2-0.5px) happen randomly
   - High energy = 50% chance, Low energy = 10% chance
   - Makes Blobbi feel alert and curious

4. Pause behavior scaled by energy:
   - Low energy: 50% chance to rest at center
   - High energy: 10% chance to rest at center

Values chosen:
- SMOOTHING_MIN = 0.02 (low energy - dreamy drift)
- SMOOTHING_MAX = 0.06 (high energy - alert movement)
- IDLE_DURATION_MIN = 1000ms (high energy)
- IDLE_DURATION_MAX = 6000ms (low energy)
- MICRO_MOVEMENT_MAX = 0.5px (subtle but visible)

Behavior summary:
- Mouse near -> eyes LOCK instantly on cursor
- High energy -> curious, active, moving often
- Low energy -> slower, lazy, but still alive
- Sleeping -> no movement
2026-03-17 10:17:37 -03:00
filemon 7ed55b00a6 fix: smooth eye animation with proper interpolation and mouse tracking
Root causes of previous issues:

1. Jumping behavior: The old implementation used setState() directly to
   random positions instead of interpolating toward them. Each idle
   movement instantly teleported the eyes.

2. Mouse tracking failure: The updateMouseTracking callback had isTracking
   in its dependencies, causing it to be recreated on every state change.
   This restarted the animation frame loop constantly, breaking the
   continuous tracking.

3. State conflict: Idle timeouts and tracking animation frames ran
   independently and fought each other, causing erratic behavior.

Solution - Single animation loop architecture:

- ONE requestAnimationFrame loop handles ALL animation
- Maintains separate 'current' and 'target' positions
- Always interpolates: current = lerp(current, target, smoothing)
- Idle behavior only sets new targets (doesn't move directly)
- Mouse tracking overrides targets when cursor is nearby
- Clean state machine: tracking active = idle paused

Smoothing values used:
- IDLE_SMOOTHING = 0.03 (very smooth drift)
- TRACKING_SMOOTHING = 0.08 (responsive but not snappy)
- RETURN_SMOOTHING = 0.04 (gentle return to idle)

Timing improvements:
- Idle duration: 3-6 seconds between movements
- 40% chance to pause at center (natural resting)
- Time-scaled smoothing for consistent feel across frame rates

Movement constraints:
- Baby: 2px max, Adult: 2.5px max
- Vertical movement reduced to 70% of horizontal
- State updates throttled (only when position changes > 0.001px)
2026-03-17 10:08:58 -03:00
filemon 4feb051177 fix: rewrite eye animation system for proper SVG transforms and mouse tracking
Root cause: The original implementation had two critical issues:
1. Grouping algorithm assumed highlights immediately followed pupils in SVG,
   but SVGs have all pupils first, then all highlights (proximity-based fix)
2. CSS transforms weren't working on SVG <g> elements without transform-box

Fixes:
- Rewrite pupil/highlight detection to use proximity-based grouping (15px radius)
- Add transform-box: fill-box and transform-origin: center inline styles
- Replace CSS keyframe animation with JavaScript-controlled transforms

New features:
- Natural idle behavior with random movement and pauses
- Mouse tracking when cursor is within 200px radius
- Smooth transitions between idle and tracking states
- Different delays for left/right eyes for organic feel

Implementation:
- useBlobbiEyes hook manages animation state and mouse tracking
- addEyeAnimation wraps pupil+highlight elements in <g class="blobbi-eye">
- Visual components apply transforms via DOM refs in useEffect
- CSS provides transition timing (.3s idle, .1s tracking)
2026-03-17 09:47:39 -03:00
filemon 27bce0d334 feat: add subtle eye movement animation for Blobbi baby and adult visuals
Add eye animation utility that:
- Detects pupil and highlight elements via gradient patterns and dark fills
- Wraps pupil+highlight elements in animated <g> groups
- Applies CSS keyframe animation for gentle wandering eye movement
- Uses different delays for left/right eyes for natural feel
- Only animates when awake (skips sleeping state)

Integrates animation into both BlobbiBabyVisual and BlobbiAdultVisual
components for a more lifelike appearance.
2026-03-16 21:23:24 -03:00
filemon ddf50724f0 fix: adult form resolution now uses adult_type tag as primary source
- Add adultType field to BlobbiCompanion interface
- Parse adult_type tag in parseBlobbiEvent from kind 31124
- Pass adult.evolutionForm in toBlobbiForVisual adapter
- Seed-derived form is now only used as fallback when no adult_type tag exists
2026-03-16 20:07:36 -03:00
filemon d07bd75d07 feat: implement adult Blobbi visual system with 16 evolution forms
- Add adult-blobbi module with types, SVG resolver, and customizer
- Support 16 adult forms: bloomi, breezy, cacti, catti, cloudi, crysti,
  droppi, flammi, froggi, leafy, mushie, owli, pandi, rocky, rosey, starri
- Each form has base and sleeping SVG variants
- Adult form resolved from blobbi.adult.evolutionForm or derived from seed
- Color customization applies to body and pupil gradients via pattern matching
- BlobbiAdultVisual component with reaction animations support
- Replace adult placeholder in BlobbiStageVisual with real visuals
2026-03-16 19:48:43 -03:00
filemon d59ba03cc6 fix: sing reaction only starts when recording begins, adjust animation timing
- Add onRecordingStart/onRecordingStop callbacks to InlineSingCard
- Move singing reaction trigger from card open to actual recording start
- Reduce sing bounce animation movement (6px → 3px for baby, 4px → 2px for egg)
- Slow down sing bounce animation (0.4s → 0.5s for baby, 0.5s → 0.6s for egg)
- Change Record button label to Sing
2026-03-16 19:29:50 -03:00
filemon 04112110f7 polish: faster music animation timing and fix egg centering with music notes 2026-03-16 19:22:40 -03:00
filemon d835cb5e6a feat: add floating music notes and tune animation timing for Blobbi reactions 2026-03-16 19:10:20 -03:00
filemon 6e5a6b5d91 feat: reusable music/dance reaction system for all Blobbi stages
BREAKING: Egg no longer sways by default when animated=true.
Sway animation now requires explicit reaction state.

Changes:
- Remove default egg sway from animated prop in EggGraphic
- Add reaction prop to EggGraphic, BlobbiEggVisual, BlobbiBabyVisual, BlobbiStageVisual
- Add EggReactionState, BabyReactionState, BlobbiReaction types
- Wire blobbiReaction state from BlobbiPage to BlobbiStageVisual
- Add animate-blobbi-sway and animate-blobbi-bounce CSS animations
- Add animate-egg-bounce for singing reaction on eggs

Reaction states:
- idle: no animation (default)
- listening: gentle sway (music playing)
- swaying: gentle sway
- singing: bouncy animation (sing action active)
- happy: gentle sway

Trigger behavior:
- Play Music: listening reaction when audio plays
- Sing: singing reaction when card opens
- Close activity: returns to idle
2026-03-16 18:48:57 -03:00
filemon 270cb51acc fix: volume control popup now renders via portal to avoid clipping
The volume slider was being clipped by the card's overflow:hidden.
Now using Popover component (Radix UI) which renders via portal,
ensuring the volume control appears above all UI elements correctly.
2026-03-16 18:31:02 -03:00
filemon 579c78b2ad feat: add volume control to InlineMusicPlayer
- Add volume state and setVolume function to useAudioPlayback hook
- Default volume is 0.8, persists across track changes
- Volume button shows Volume2 icon (or VolumeX when muted)
- Clicking volume button shows horizontal slider popup
- Slider controls real audio volume (0-1 range)
- Click outside to dismiss volume slider
2026-03-16 18:23:39 -03:00
filemon 470cdd1c76 refactor: change stop button to restart button in InlineMusicPlayer
- Add restart() function to useAudioPlayback hook (sets currentTime=0 and plays)
- Replace Square icon with RotateCcw for restart semantics
- Restart button resets track to beginning and continues playing
- Stop function still exists for cleanup on close
2026-03-16 18:04:31 -03:00
filemon 2abbae38d9 fix: stop button now truly stops playback instead of auto-restarting
The auto-start effect was incorrectly triggering on 'stopped' state,
causing immediate restart. Now 'stopped' is a terminal state that
requires explicit play button click to restart.
2026-03-16 18:00:43 -03:00
filemon 46dc8d9e12 fix: inline music player stop/track-switch behavior and UI cleanup
- Fix stop button: add 'stopped' state so stop truly stops playback instead of pausing at 0
- Fix track switching: detect source.url changes and reload, distinguish change vs initial selection
- Disable Upload tab in PlayMusicModal (marked 'Soon' but was still clickable)
- Remove misplaced chevron arrows from lyrics toggle button in InlineSingCard
2026-03-16 17:56:28 -03:00
filemon 59cbb9d740 fix: inline activity layout and move audio assets to public/
- Move inline activity cards inside padded container to prevent overlap with fixed bottom bar
- Move audio files from src/blobbi/audio/ to public/blobbi/audio/ for correct Vite asset loading
- Update track metadata with accurate durations from ffprobe
- Update documentation comments to reflect correct asset location
- Remove unused MicOff import from InlineSingCard
2026-03-16 17:38:21 -03:00
filemon c50c9bec7e refactor: inline activity cards for Play Music and Sing
- Add InlineMusicPlayer component for persistent music playback UI
- Add InlineSingCard component for inline recording/lyrics experience
- Add useAudioPlayback hook for reusable audio playback logic
- Add blobbi-activity-state types for activity and reaction state management

Play Music flow:
- PlayMusicModal now serves as track picker only
- After track selection, inline player appears with play/pause/stop controls
- Action published first, playback starts only after success

Sing flow:
- Recording happens inline (no modal)
- Lyrics panel expands upward with random lyrics
- Action published only when user confirms with 'Sing for Blobbi'

Blobbi reaction state prepared for future visual animations
2026-03-16 16:21:00 -03:00
filemon 14ebcb1165 Merge branch 'main' into feat-blobbi 2026-03-16 15:53:56 -03:00
filemon 3728ad02e6 fix: CSP and audio recording/playback for SingModal
- Add blob: to media-src CSP directive to allow recorded audio playback
- Add robust MIME type selection helper for MediaRecorder
- Try MIME types in order: webm;opus, webm, mp4, ogg;opus, ogg
- Track actual recorder MIME type and use it for blob creation
- Add user-friendly playback error messages (non-fatal amber warnings)
- Verify PlayMusicModal blob URL handling works correctly
- Add 'Soon' badge to Upload tab in PlayMusicModal
2026-03-16 15:50:22 -03:00
filemon ba9e1f5375 fix: cleanup pass for Blobbi play_music and sing actions
- Relax egg-stage blocking in canUseAction() (UI visibility vs domain logic)
- Fix egg inventory filtering to only show items with egg-compatible effects
- Remove 'shell' wording from medicine UI text
- Fix canonical data usage in mutations (use canonical.companion for decay)
- Fix browser timer typing (NodeJS.Timeout -> ReturnType<typeof setInterval>)
- Fix PlayMusicModal audio source switching (recreate Audio on source change)
- Fix SingModal recording playback (track current playback URL)
- Add random lyrics helper for Sing action with collapsible UI
2026-03-16 15:32:09 -03:00
filemon 598c5f90ea Add play_music and sing actions, fix egg clean/medicine consistency
- Fix egg stage actions: clean and medicine now work for eggs
- Add play_music action with built-in tracks and file upload
- Add sing action with in-browser audio recording
- Hide feed/play/sleep actions for eggs in UI (not hard-blocked)
- Both new actions increase happiness only (+15/+20)
- Placeholder built-in tracks in blobbi-builtin-tracks.ts
2026-03-16 15:08:30 -03:00
filemon 6e1a195615 Deprecate egg_temperature - eggs now use warmth prop fallback 2026-03-16 11:11:34 -03:00
filemon 452848f14f Remove shell_integrity - eggs now use standard health stat
BREAKING: shell_integrity is fully removed from the egg model.
Eggs now use the standard 3-stat model: health, hygiene, happiness.

Changes:
- Remove EggStats, EggMedicineResult types from blobbi-action-utils.ts
- Remove applyMedicineToEgg function (medicine now uses applyStat directly)
- Update useBlobbiUseInventoryItem to apply medicine health effect to egg health
- Update BlobbiActionInventoryModal to preview health changes for egg medicine
- Remove shell_integrity from ItemEffect in shop types
- Remove shellIntegrity from BlobbiEggData in types/blobbi.ts
- Remove EggStats, EggMedicineResult, applyMedicineToEgg from exports
- Add DEPRECATED_BLOBBI_TAG_NAMES set with 'shell_integrity'
- Update mergeBlobbiStateTagsForRepublish to filter out deprecated tags

Migration: Existing events with shell_integrity tags will have them
automatically removed on the next republish (any user interaction).

Egg stat model is now fully consistent:
- health, hygiene, happiness: active (decay + medicine)
- hunger, energy: fixed at 100
2026-03-16 00:20:33 -03:00
filemon 4ccc123209 Apply decay before stage transitions (hatch/evolve)
- Create useBlobbiHatch hook for egg -> baby transition
- Create useBlobbiEvolve hook for baby -> adult transition
- Both hooks apply accumulated decay before publishing new state
- Wire up floating action button to trigger hatch/evolve based on stage
- Hide evolve button for adults (already fully evolved)
- Show loading state during transitions
- Export new hooks and types from blobbi/actions module

Stage transitions now consistently apply decay first, ensuring
no transition can happen from stale stats.
2026-03-15 21:10:44 -03:00
filemon a987449789 Apply decay before sleep/wake mutations in handleRest
- Import applyBlobbiDecay in BlobbiPage
- Calculate accumulated decay before state change
- Persist decayed stats along with new sleep/wake state
- Reset last_decay_at timestamp after applying decay

This ensures stats accurately reflect elapsed time when toggling
between active and sleeping states.
2026-03-15 21:04:36 -03:00
filemon ba9ff0964b feat(blobbi): implement decay system with projected UI state
Core decay system (src/lib/blobbi-decay.ts):
- Pure applyBlobbiDecay() function for deterministic decay calculation
- Stage-specific decay rates: egg (2-3hr), baby (3-5hr), adult (5-7hr)
- Health modifiers based on other stats
- Health regeneration when all stats >= 80
- Floor all deltas, clamp stats to 0-100
- Warning/critical threshold helpers

UI projection hook (src/hooks/useProjectedBlobbiState.ts):
- Calculates projected stats without publishing
- Recalculates every 60 seconds
- Returns visible stats with status indicators

BlobbiPage updates:
- Uses projected state for display
- Egg shows 3 stats (health, hygiene, happiness)
- Baby/adult shows all 5 stats
- StatIndicator supports warning/critical status styling

Mutation updates (useBlobbiUseInventoryItem):
- Applies accumulated decay before interactions
- Uses decayed stats as base for item effects
- Updates last_decay_at on every interaction

Documentation (docs/blobbi/decay-system.md):
- Comprehensive explanation of the system
- All decay rates and thresholds
- Mutation flow diagram
- Edge cases and assumptions
2026-03-15 21:01:34 -03:00
filemon 3cd64fb0af refactor(blobbi): make evolve button icon stage-aware
- egg stage: Shows Egg icon with 'Hatch' tooltip
- baby/adult stages: Shows Sparkles icon with 'Evolve' tooltip

Implementation:
- Added 'stage' prop to BlobbiDashboardFloatingControlsProps
- Created getEvolveIcon() helper - returns Egg or Sparkles based on stage
- Created getEvolveTooltip() helper - returns 'Hatch' or 'Evolve'
- Removed unused Zap import

Icon choice rationale:
- Sparkles was chosen for non-egg stages because it communicates magical
  transformation, which fits the Blobbi fantasy/pet theme better than
  technical icons like TrendingUp or ArrowUpCircle
2026-03-15 19:48:43 -03:00
filemon 6e8e6fe243 feat(blobbi): add floating action buttons to dashboard (placeholders)
New components:
- FloatingActionDef: Typed interface for button definitions
- BlobbiDashboardFloatingControls: Component that renders left and right
  floating button clusters

Buttons added (all visual-only placeholders):
- Right side (top cluster):
  - Settings (Settings icon)
  - Set as Companion (Heart icon)
  - Take a Photo (Camera icon)
  - Open PiP (PictureInPicture2 icon)
  - Blobbi Info (Info icon) - wired to existing info modal
  - Evolve (Zap icon) - styled with accent/primary colors
- Left side:
  - Back button (ArrowLeft icon) - optional, not rendered by default

Implementation:
- Uses existing QuickActionButton for consistent styling
- Evolve button has distinct accent styling (primary colors)
- Button definitions centralized in typed arrays
- Placeholder handlers use console.log('TODO: ...')
- Existing info modal functionality preserved
2026-03-15 19:37:35 -03:00
filemon 476f1bade2 feat(blobbi): apply base color to dashboard and info modal names
- BlobbiDashboard: The h2 element displaying companion.name now uses
  style={{ color: companion.visualTraits.baseColor }}
- BlobbiInfoModal: The DialogTitle displaying companion.name now uses
  style={{ color: companion.visualTraits.baseColor }}
- Both update correctly when selecting a different Blobbi
- Selector card names left unchanged (not prominent display)
2026-03-15 19:29:39 -03:00
filemon cf1d9ad53f fix(blobbi): add separate colored name display above egg
- Removed baseColor styling from Input (was not working reliably)
- Added a separate <p> element between the input and egg visual
- The <p> displays trimmedName with style={{ color: preview.visualTraits.baseColor }}
- This element updates live as the user types (uses trimmedName from preview.name)
- Input reverted to normal theme styling (text-center font-medium)
- No duplicate name under the egg (title still not passed to EggGraphic)
2026-03-15 19:22:10 -03:00
filemon 680ff86202 fix(blobbi): apply egg base color to name input field
- Removed the extra styled name display div (was creating a second name)
- Applied base color directly to the Input element via style prop
- Changed Input className to 'text-center font-semibold text-lg' for better visibility
- Now only ONE visible name exists: the input field itself, styled with the egg's baseColor
2026-03-15 19:19:06 -03:00
filemon 9797fcd95a fix(blobbi): canonicalize patterns and fix duplicate name display
Pattern canonicalization:
- Changed VALID_PATTERNS from ['gradient', 'solid', 'speckled', 'striped']
  to ['solid', 'spotted', 'striped', 'gradient'] to match domain model
- 'spotted' is the canonical value (used by BlobbiPattern type, BLOBBI_PATTERNS,
  derivePatternFromSeed, normalizePatternTag, and PATTERN_MAP)

Duplicate name fix:
- Removed title from toEggGraphicVisualBlobbi() adapter - the EggGraphic
  'title' field is for special designations (e.g., 'Divine'), not pet names
- The duplicate was: input field above egg + title display below egg
- Now only the input field exists, plus a new styled name display

Name styling:
- Added styled name display above the egg using the egg's baseColor
- Styling matches the former bottom title: bg-black/20, backdrop-blur-sm,
  font-semibold, text-shadow, and color from preview.visualTraits.baseColor
2026-03-15 19:14:52 -03:00
filemon 5d3841d6a7 fix(blobbi): cleanup egg validation inconsistencies
- Fix getColorRarity() to properly merge both base color palettes by
  creating MERGED_BASE_COLORS_BY_RARITY that combines colors per rarity
  tier (previous spread syntax overwrote keys instead of merging arrays)
- Update validation error messages to match actual validation logic:
  colors now accept any valid hex format, not just specification palettes
- Add Rarity type for better type safety in rarity functions
- Add JSDoc clarifying that getColorRarity returns null for domain model
  colors (BLOBBI_BASE_COLORS) which are not in the legacy palettes
2026-03-15 18:39:10 -03:00
filemon 9e5de53ad4 fix(blobbi): make egg colors update on reroll
Root cause: EggGraphic validation rejected derived colors because
isValidBaseColor() used a hardcoded allowlist that didn't include
the BLOBBI_BASE_COLORS palette (e.g., #F59E0B, #55C4A2, etc.).

Changes:
- Update isValidBaseColor/isValidSecondaryColor to accept any valid
  hex color format (palette enforcement at domain level)
- Add visual trait tags (base_color, secondary_color, eye_color,
  pattern, special_mark, size) to previewToEventTags for
  deterministic rendering
- Improve useMemo dependencies in BlobbiEggVisual to ensure
  re-render on preview change
2026-03-15 13:59:36 -03:00
filemon 13cdbc565c feat(blobbi): add 'Adopt another Blobbi' entry point from selector
Adds a dedicated CTA card in the Blobbi selector modal and page to allow
existing users to adopt additional Blobbies without going through full
onboarding.

Changes:
- Added AdoptAnotherBlobbiCard component with plus icon, tooltip, and
  distinct visual styling (dashed border, centered layout)
- Updated BlobbiOnboardingFlow to support adoptionOnly prop that skips
  profile creation and adoption question, going directly to egg preview
- Updated useBlobbiOnboarding hook with adoptionOnly mode support that:
  - Derives initial step as 'preview' when adoptionOnly is true
  - Generates preview immediately on mount in adoptionOnly mode
  - Skips auto-sync logic that would interfere with explicit control
- Added adoption flow modal to BlobbiDashboard with full callback wiring
- Added adoption flow modal to BlobbiSelectorPage (Cases G and H)
- Passed required adoption callbacks through BlobbiDashboard props

UX flow:
1. User clicks 'Adopt another Blobbi' card in selector
2. Selector closes, adoption flow modal opens
3. User sees egg preview directly (no profile/adoption question steps)
4. User can reroll, name, and adopt as normal
5. On completion, modal closes and new Blobbi is selected
2026-03-15 13:44:48 -03:00
filemon 5ea4b0f73d fix(blobbi): add pettingLevel, fix reroll visual, improve onboarding stability
1. pettingLevel support:
   - Added pettingLevel to BlobbonautProfile type and parsing
   - New profiles include pettingLevel: 0 by default
   - Created useBlobbonautProfileNormalization hook to auto-add
     pettingLevel to existing profiles that are missing it

2. Reroll visual fix:
   - Added key prop to BlobbiStageVisual to force remount on preview change
   - Added debug logging to track preview identity changes (d/seed/petId)
   - Reroll preserves typed name while generating new identity

3. Onboarding stability improvements:
   - Enhanced step sync logic in useBlobbiOnboarding to handle all edge cases
   - Added defensive checks for profile state changes
   - Better debug logging for state transitions

4. Verified invariants:
   - Preview remains single source of truth for adopted event
   - Name is editable, required for adoption, preserved on reroll
   - No any types introduced
2026-03-15 13:16:26 -03:00
filemon f3e262bd3a fix(blobbi): respect user profile state in onboarding flow
- Fixed useBlobbiOnboarding to derive initial step from profile state
- Added useEffect to sync step when profile loads from cache/relay
- Added egg name customization via updatePreviewName() function
- Removed 'Maybe Later' skip option from adoption step
- Refactored BlobbiPage with cleaner state logic and debug logging
- Fixed TypeScript errors (unused vars, empty interface)

Resolves issue where onboarding always started on 'profile' step
even when user already had a profile, and adds name input to
egg preview before adoption.
2026-03-15 12:48:58 -03:00
filemon 96b8288c5b feat(blobbi): implement new onboarding flow with egg preview
Add complete Blobbi onboarding flow:
- Profile creation step with name prefill from kind 0 metadata
- Adoption question step after profile creation
- Egg preview with reroll (10 coins) and adopt (100 coins) options
- Confirmation dialog before adoption
- New profiles start with 200 coins

Key components:
- BlobbiProfileOnboarding: Profile creation with name input
- BlobbiAdoptionStep: 'Ready to adopt?' prompt
- BlobbiEggPreviewCard: Egg preview with visual traits and actions
- BlobbiAdoptionConfirmDialog: Adoption cost confirmation
- useBlobbiOnboarding: State and action orchestration hook

Preview is the source of truth for adoption - same exact data is
used to create the final kind 31124 event. Coins are deducted
from profile before publishing events.
2026-03-14 16:44:14 -03:00
filemon a85590f5fb Merge branch 'main' into feat-blobbi 2026-03-13 23:16:16 -03:00
filemon 5a93bdd0a6 Merge branch 'main' into feat-blobbi 2026-03-10 10:14:43 -03:00
filemon be582f4db7 fix: use migrated profile context in inventory item usage flow
Bug: When using Feed/Play/Clean on a legacy Blobbi, the migration
would correctly publish a canonical profile, but then the inventory
usage flow would republish the profile using stale pre-migration tags
from the hook closure, restoring legacy has/current_companion values.

Fix:
- Extend EnsureCanonicalResult to include profileAllTags and profileStorage
- Extend MigrationResult to include profileTags and profileStorage
- Update ensureCanonicalBlobbiBeforeAction to return profile context
- Update useBlobbiUseInventoryItem to use canonical.profileStorage and
  canonical.profileAllTags instead of profile.storage/profile.allTags

This ensures the post-item-use 31125 event is built from the migrated
profile state, preserving:
- canonical has[] values
- canonical current_companion
- storage changes (item decrement)
- all unknown tags
2026-03-09 17:39:53 -03:00
filemon 01a174f9e3 feat: add Medicine action with egg-specific shell_integrity support
- Add 'medicine' to InventoryAction type and related mappings
- Medicine is available for all stages: egg, baby, adult
- For eggs: health effect is converted to shell_integrity
- For eggs: other effects (energy, happiness, etc.) are ignored
- For baby/adult: all effects are applied normally

Egg-specific behavior:
- previewMedicineForEgg() shows shell_integrity changes
- applyMedicineToEgg() converts health → shell_integrity
- hasMedicineEffectForEgg() validates egg-applicable effects

Stage restriction changes:
- canUseAction(companion, action) replaces canUseInventoryItems()
- EGG_ALLOWED_ACTIONS defines which actions eggs can use
- getStageRestrictionMessage() now action-aware

UI updates:
- Medicine button added to BlobbiActionsModal (Pill icon)
- Inventory modal shows shell_integrity preview for eggs
- Contextual description: 'Strengthen your egg's shell' for eggs
2026-03-09 17:25:51 -03:00
filemon ecc306079b feat: implement inventory item usage with Feed, Play, Clean actions
- Add blobbi-action-utils.ts with stat clamping, item effects, and inventory filtering
- Create useBlobbiUseInventoryItem hook for consuming inventory items
- Add BlobbiActionInventoryModal for selecting items to use per action type
- Update BlobbiActionsModal with Feed, Play, Clean, Sleep/Wake buttons
- Integrate action modals with BlobbiPage
- Remove duplicate StorageItem from shop.types.ts (kept in lib/blobbi.ts)
- Stage restrictions: eggs cannot use items, only baby/adult can

The inventory usage flow:
1. User opens Actions modal from bottom bar
2. Selects Feed/Play/Clean to open inventory modal
3. Modal shows filtered items by action type with effect preview
4. On item use: updates Blobbi stats (31124) and decrements storage (31125)
2026-03-09 17:12:49 -03:00
filemon 251ea43e33 refactor: reorganize Blobbi shop into domain-scoped structure with list layout
- Move shop module to src/blobbi/shop/ for clear domain boundaries
- Rename components with Blobbi prefix for clarity:
  - ShopModal → BlobbiShopModal
  - InventoryModal → BlobbiInventoryModal
  - PurchaseDialog → BlobbiPurchaseDialog
  - usePurchaseItem → useBlobbiPurchaseItem
  - shop-items.ts → blobbi-shop-items.ts
  - shop.ts → shop.types.ts

- Convert shop UI from card grid to vertical list layout:
  - Replace ShopItemCard with BlobbiShopItemRow
  - Horizontal row layout: icon, name, category, effects, price, button
  - More compact and scannable design
  - Better mobile responsiveness
  - Easier to compare items at a glance

- Add blobbi-shop-utils.ts with helper functions:
  - formatEffectSummary() for compact effect display
  - getEffectCounts() for stat summaries
  - getPrimaryEffect() for tooltips/badges

- Folder structure:
  src/blobbi/shop/
    components/     (UI components)
    hooks/          (purchase hook)
    lib/            (catalog + utils)
    types/          (TypeScript types)

- Update all imports in BlobbiPage to use new paths
- Remove old generic shop paths (src/components/shop, etc.)
- Preserve all existing behavior and purchase flow
- No Lightning/sats/kind 40100/40101 (as specified)
- TypeScript strict, no any types used
2026-03-09 15:48:31 -03:00
filemon 37b8fc6752 feat: implement Blobbi Shop and Inventory system
- Add shop item catalog with 24 items (food, toys, medicine, hygiene, accessories)
- Create shop types: ShopItem, StorageItem, ItemEffect, PurchaseRequest
- Extend BlobbonautProfile with coins and storage fields
- Add storage tag parsing/serialization helpers for kind 31125
- Implement usePurchaseItem hook for atomic coin + storage updates
- Create ShopModal with category tabs and item grid
- Create PurchaseDialog with quantity selector and affordability checks
- Create InventoryModal to display purchased items
- Integrate shop and inventory into BlobbiPage footer
- Purchase flow: validates price, checks coins, stacks items, publishes single event
- Accessories marked as 'Coming Soon' (disabled state)
- No Lightning/LNURL/sats purchase flow (as specified)
- Only uses kind 31125 for profile persistence (no kind 40100/40101)
2026-03-09 15:32:03 -03:00
filemon 17b986c21d fix: use baseColor as secondaryColor fallback for legacy events without seed
When a legacy Blobbi has only base_color (no secondary_color, no seed),
the secondary color now falls back to the resolved baseColor instead of
the generic yellow default (#FCD34D).

This creates a unified palette for legacy events with partial traits,
avoiding the incorrect mixed palette (e.g., cyan base + yellow accent).
2026-03-09 13:04:51 -03:00
filemon d6b3dbc9f9 fix: respect explicit visual traits in legacy Blobbi events
The deriveVisualTraits function was returning default values immediately
when no seed was present, ignoring explicit tags in the event.

Fixed priority order (per field):
1. Explicit valid tags (always take precedence)
2. Seed-derived values (for canonical events)
3. Default fallbacks (when both are missing)

This ensures legacy Blobbi with explicit color/pattern tags render
correctly instead of falling back to default yellow/orange palette.
2026-03-09 12:51:56 -03:00
filemon 1f41478d53 wire baby rendering into the app with stage-aware visual component
- Add BlobbiBabyVisual component using baby-blobbi module for SVG resolution and customization
- Add BlobbiStageVisual component that routes rendering by life stage (egg/baby/adult)
- Update BlobbiPage to use BlobbiStageVisual for all Blobbi displays
- Uncomment BlobbiBabyData interface in types/blobbi.ts to fix type error
2026-03-09 12:27:16 -03:00
filemon bd71520cb5 add baby-blobbi file 2026-03-09 12:22:44 -03:00
filemon 6276d135e4 Merge branch 'main' into feat-blobbi 2026-03-08 10:33:58 -03:00
filemon 3abdbc2d88 cleanup: remove visibility feature, info card, and polish bottom bar spacing
Removed features:
- handleToggleVisibility handler and all related props
- Visibility quick action button from top-right floating actions
- Visibility action from BlobbiActionsModal
- Info card section (Generation, Experience, Care Streak, Last Active)

Removed unused code:
- InfoItem component
- formatTimeAgo helper function
- Card/CardContent imports

Bottom bar spacing adjustments:
- Container padding: px-2 → px-3
- Grid gap: gap-1 → gap-2
- Group internal gap: gap-0.5 → gap-1
- Center button margin: mx-1 → mx-2
- Button padding: px-2.5 → px-3
- Button min-width: 52px → 56px

Result: Cleaner dashboard with more breathing room in the bottom bar
2026-03-06 23:46:18 -03:00
filemon 241f234a82 polish: refine bottom bar layout and change center icon to Sparkles
Layout changes:
- Switch from flex justify-between to 3-column grid layout
- Left group now uses justify-end (closer to center)
- Right group now uses justify-start (closer to center)
- Reduce group gap from gap-1 to gap-0.5
- Reduce container padding from px-3 to px-2

Center button adjustments:
- Reduce vertical offset from -mt-6 to -mt-4 (more integrated)
- Reduce size from size-14 to size-12
- Add mx-1 for controlled horizontal spacing
- Replace Zap icon with Sparkles (better fits Blobbi identity)
- Reduce icon size from size-6 to size-5

Side button adjustments:
- Reduce horizontal padding from px-3 to px-2.5
- Reduce vertical padding from py-2 to py-1.5
- Reduce min-width from 60px to 52px

Result: More compact, balanced bottom bar with groups visually
closer to the center action button
2026-03-06 23:39:44 -03:00
filemon 28ee5b6881 feat: add bottom action bar and modal system to BlobbiPage
UI adjustments:
- Move top-right floating buttons lower for visual balance
- Remove switch button from top-right (now in bottom bar)
- Add fixed bottom action bar with left/center/right layout:
  - Left: Blobbies (opens selector), Missions (placeholder)
  - Center: Actions button (opens actions modal)
  - Right: Shop (placeholder), Inventory (placeholder)

New components:
- BlobbiBottomBar: Fixed bottom navigation bar
- BlobbiActionsModal: Rest/Wake and Hide/Show actions
- BlobbiPlaceholderModal: Reusable placeholder for future features
- BlobbiInfoModal: Detailed Blobbi information display
- BottomBarButton: Reusable button for bottom bar

Behavior:
- Blobbies button opens existing selector modal
- Actions modal contains functional Rest and Visibility toggles
- Info button (top-right) opens detailed info modal
- Placeholder modals ready for Missions, Shop, Inventory features
2026-03-06 23:34:21 -03:00
filemon d92790f0af refactor: replace hardcoded colors with app theme tokens in BlobbiPage
- Remove purple/pink gradients and hardcoded colors
- Use theme tokens: primary, muted, accent, border, card, background
- Keep semantic stat colors (orange/yellow/green/blue/violet) for meaning
- Page chrome now adapts to light/dark theme automatically
2026-03-06 23:22:46 -03:00
filemon e18d0592d6 refactor: redesign BlobbiPage with dashboard-style layout
Visual changes:
- Add frosted glass dashboard container with purple top border
- Add decorative purple/pink gradient overlay
- Center hero layout with prominent Blobbi visual
- Add floating quick action buttons (switch, rest, visibility)
- Replace stat bars with circular stat indicators
- Improve visual hierarchy with hero section focus
- Add glow effect behind main Blobbi visual
- Use purple/pink color theme consistently

Extracted components:
- DashboardShell: Frosted glass container wrapper
- BlobbiDashboard: Main dashboard view with hero section
- QuickActionButton: Floating circular action buttons
- StatIndicator: Circular progress stat display
- DashboardLoadingState: Skeleton loading for dashboard

Preserved unchanged:
- All data hooks (useBlobbonautProfile, useBlobbisCollection, etc.)
- Selection flow with localStorage priority
- Migration flow with ensureCanonicalBlobbiBeforeAction
- Create egg / rest / visibility handlers
- EggGraphic adapter integration
- Selector page and card components (restyled)
2026-03-06 23:16:35 -03:00
filemon 11802fc38a refactor: tighten all adapter types to exact Egg module unions
- Add EggPattern, EggSpecialMark, EggThemeVariant type aliases
- All derived via NonNullable<EggVisualBlobbi[field]>
- Update PATTERN_MAP to Record<BlobbiPattern, EggPattern>
- Update SPECIAL_MARK_MAP to Record<BlobbiSpecialMark, EggSpecialMark>
- Update all fallback constants with exact Egg types
- Update themeVariant parameter to EggThemeVariant
- No runtime changes, type-only refinement
2026-03-06 23:08:04 -03:00
filemon b2ae35d597 refactor: tighten adapter typing for EggVisualBlobbi
- Derive EggLifeStage type from EggVisualBlobbi using NonNullable
- Type LIFE_STAGE_MAP with exact EggLifeStage return type
- Add DEFAULT_THEME_VARIANT constant for consistency
- Remove unnecessary 'as const' assertions on mapping objects
- Improve JSDoc for toEggGraphicVisualBlobbi return type
- Rename areEggGraphicVisualsEqual parameter types to EggVisualBlobbi
2026-03-06 23:04:25 -03:00
filemon ec37a8befe refactor: cleanup Blobbi egg integration
- Remove fake try/catch render wrapper (not a real error boundary)
- Use real EggVisualBlobbi type from @/blobbi/egg module
- Pass full allTags to EggGraphic instead of filtering
- Adjust size mapping: sm (size-14/small), md (size-24/medium), lg (size-40/large)
- Gate debug logs behind import.meta.env.DEV check
- Simplify adapter by removing duplicated type definitions
- Reduce adapter from 290 lines to ~150 lines
- Keep architecture intact: domain → adapter → BlobbiEggVisual → EggGraphic
2026-03-06 21:24:58 -03:00
filemon 804dd550a2 feat(blobbi): add egg visual module 2026-03-06 21:10:29 -03:00
filemon 736d76f457 feat: integrate EggGraphic visual module into Blobbi UI
- Create BlobbiEggVisual reusable component in src/blobbi/ui/
- Replace placeholder Egg icons with real EggGraphic rendering
- Update adapter tags format to string[][] for EggGraphic compatibility
- Main display: large animated egg visual with seed-derived colors
- Selector cards: small egg visual showing unique traits per Blobbi
- Switch dialog: uses same selector cards with real visuals
- Add memoization for adapter output to avoid re-renders
- Include fallback safety with simple placeholder on render errors
- Preserve all existing fetch/migration/selection behavior
2026-03-06 21:06:01 -03:00
filemon 84734d7304 feat: add EggGraphic adapter and improve visual trait derivation
- Create blobbi-egg-adapter.ts with toEggGraphicVisualBlobbi() function
- Add explicit mapping tables for pattern, specialMark, size, lifeStage
- Add comprehensive VISUAL TRAIT POLICY documentation
- Export LEGACY_VISUAL_TAG_NAMES constant for migration preservation
- Improve deriveNameFromLegacyD() to handle multi-word names (mr-cool → Mr Cool)
- Update buildMigrationTags() to explicitly preserve legacy visual tags
- Add capitalizeWords() helper for human-friendly name formatting
- Add areEggGraphicVisualsEqual() utility for memoization support
2026-03-06 20:09:46 -03:00
filemon e19b99a2bc refactor: align visual traits with EggGraphic contract
Visual Traits Interface:
- baseColor: hex value (e.g., '#F59E0B')
- secondaryColor: hex value for accent
- eyeColor: hex value for eyes
- pattern: 'solid' | 'spotted' | 'striped' | 'gradient'
- specialMark: 'none' | 'star' | 'heart' | 'sparkle' | 'blush'
- size: 'small' | 'medium' | 'large'

Color Palettes (10 colors each):
- BLOBBI_BASE_COLORS: Amber, Teal, Sky Blue, Pink, Purple, Coral, Emerald, Yellow, Indigo, Orange
- BLOBBI_SECONDARY_COLORS: Light variants for accents
- BLOBBI_EYE_COLORS: 8 expressive options

Seed Derivation:
- deriveIndexFromSeed with documented offset layout
- Each trait derived from different seed segment
- Validation/normalization for legacy tags
- Falls back to DEFAULT_VISUAL_TRAITS when seed missing

Debug Logs:
- Concise format: d, name, isLegacy, hasSeed, traits summary
- Removed verbose visualTraits object dump

parseBlobbiEvent remains single source of truth for:
- name resolution (tag > legacy d > fallback)
- seed
- visualTraits
- isLegacy flag

Migration flow unchanged - ready for EggGraphic integration.
2026-03-06 14:45:58 -03:00
filemon 577334cbaa feat: add visual trait derivation from seed and centralized migration
PART 1 - Visual Traits from Seed:
- Add BlobbiVisualTraits interface with baseColor, pattern, specialMark, size
- Add deriveNumberFromSeed helper for deterministic derivation
- Add deriveBaseColorFromSeed, derivePatternFromSeed, deriveSpecialMarkFromSeed, deriveSizeFromSeed
- Add deriveVisualTraits to combine tag values with seed-derived fallbacks
- Visual trait priority: explicit tags > derived from seed > default values

PART 2 - Legacy Event Detection:
- Add isLegacyBlobbiEvent helper function
- Legacy criteria: non-canonical d-tag, missing seed, missing name tag, visual traits without seed
- Add companionNeedsMigration convenience wrapper
- Add isLegacy and visualTraits fields to BlobbiCompanion interface

PART 3-7 - Centralized Migration:
- Create useBlobbiMigration hook with ensureCanonicalBlobbiBeforeAction
- Migration auto-updates: localStorage selection, React Query caches, profile references
- Refactor BlobbiPage action handlers to use centralized helper
- Migration continues original action after upgrading legacy pet

No changes to event kinds, collection fetching, or selection system.
2026-03-06 14:27:18 -03:00
filemon 35b615dc9f fix: support legacy Blobbi names derived from d-tag
- Add deriveNameFromLegacyD() helper to extract name from legacy d-tags
- Update parseBlobbiEvent to use name resolution priority:
  1. Use 'name' tag if present
  2. Derive from legacy d-tag format (blobbi-{name} → Name)
  3. Fall back to 'Unnamed Blobbi'
- Add debug logs in parser showing d, name, nameTag, stage, state
- Add '[Blobbi UI]' debug log when selected companion changes
- Legacy pets like 'blobbi-puck' now display as 'Puck'
2026-03-06 14:13:40 -03:00
filemon ef8e9a3ccf fix: load ALL Blobbis and implement proper UI selection flow
- Update useBlobbisCollection to fetch ALL pets without limit:1
- Add chunking support (20 items per chunk) for relay compatibility
- Add debug logs for dList and 31124 query filter
- Implement localStorage-based UI selection (user-scoped key)
- Selection priority: localStorage > first in profile.has > show selector
- Add BlobbiSelectorPage for when no valid selection exists
- Add BlobbiSelectorCard component for pet selection UI
- Add 'Switch Blobbi' button in header for users with multiple pets
- Separate concerns: currentCompanion (global) vs selectedBlobbi (page UI)
2026-03-06 13:45:51 -03:00
filemon 27d5544f8b Fetch all Blobbi pets (Kind 31124) by multiple d-tags
- Add useBlobbisCollection hook to query all d-tags from profile.has[] and currentCompanion
- Update BlobbiContent to use collection hook instead of single companion hook
- Keep only newest event per d-tag for deduplication
- Add debug logging to verify multi-d-tag REQs in DevTools
- UI still renders only the selected companion (currentCompanion or first in has[])
2026-03-06 13:25:49 -03:00
filemon e98111bf00 fix: useBlobbonautProfile type alignment and add effectiveCompanionD
- Fix BlobbiBootCache type usage (companion singular, not companions plural)
- Add effectiveCompanionD to hook return value for BlobbiPage
- Add debug logging to track kind 31125 query execution
- Add refetchOnMount: 'always' with initialDataUpdatedAt for proper cache behavior
2026-03-05 21:04:38 -03:00
filemon c8c68f1898 Refactor Blobbi system for spec compliance and fix loading issues
- Fix canonical Blobbi ID regex to require hex-only petId per spec
- Add getOrDeriveSeed helper to prevent seed recomputation
- Separate managed tag sets for Kind 31124 and 31125
- Add tag deduplication helpers (deduplicateHasTags)
- Add legacy pet migration on user interactions
- Fix localStorage cache validation with pubkey ownership check
- Add effectiveCompanionD fallback to has[] array
- Prevent premature "Create Egg" UI while queries resolve
- Add usePersistentNostrSession hook with exponential backoff
- Configure React Query to prevent refetch loops (staleTime, refetchOnWindowFocus)
2026-03-05 17:32:32 -03:00
filemon f47ccbec51 Merge branch 'main' into feat-blobbi 2026-03-05 17:11:28 -03:00
filemon ba53bc05a4 Add Blobbi egg feature with profile and companion management
Implements the initial Blobbi ecosystem (egg stage only) per the spec:
- Kind 31125 (Blobbonaut Profile) with canonical d-tag and legacy support
- Kind 31124 (Blobbi Current State) with canonical d-tag and seed derivation
- localStorage boot cache for instant UI on page load
- Profile initialization, egg creation, rest action, visibility toggle
- Preserves unknown tags when republishing for forward compatibility
2026-03-04 20:29:21 -03:00
306 changed files with 59921 additions and 2951 deletions
+22 -8
View File
@@ -134,13 +134,13 @@ versionName "X.Y.Z"
#### 6c. `ios/App/App.xcodeproj/project.pbxproj`
Update `MARKETING_VERSION` in all 4 occurrences (2 Debug configs + 2 Release configs):
Update `MARKETING_VERSION` in all occurrences (Debug + Release configs):
```
MARKETING_VERSION = X.Y.Z;
```
**Important:** There are exactly 4 lines containing `MARKETING_VERSION` in this file. All 4 must be updated to the same value. Use a replaceAll operation.
**Important:** All lines containing `MARKETING_VERSION` must be updated to the same value. Use a replaceAll operation.
Do NOT change `CURRENT_PROJECT_VERSION` -- it stays at `1` (may be managed separately for App Store submissions in the future).
@@ -152,14 +152,26 @@ The changelog is served at runtime by the app from the `public/` directory. Afte
cp CHANGELOG.md public/CHANGELOG.md
```
### Step 8: Commit the Release
### Step 8: Pull Latest Changes
Before committing the release, pull the latest changes from the remote to ensure the release commit sits on top of the latest code. This **must** happen before committing and tagging.
```bash
git pull origin main
```
**CRITICAL**: Always use `git pull` (merge), NEVER `git pull --rebase`. Rebasing rewrites commit hashes, which would orphan any tag pointing to the original commit. Since version tags are often protected on the remote and cannot be deleted or updated, a broken tag cannot be easily fixed.
If there are merge conflicts with the pulled changes, resolve them before proceeding.
### Step 9: Commit the Release
```bash
git add package.json CHANGELOG.md public/CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj
git commit -m "release: vX.Y.Z"
```
### Step 9: Tag the Release
### Step 10: Tag the Release
```bash
git tag vX.Y.Z
@@ -167,18 +179,20 @@ git tag vX.Y.Z
The tag format is `v` followed by the semver version with no suffix. Examples: `v2.0.0`, `v2.1.0`, `v2.1.1`.
### Step 10: Push
### Step 11: Push
```bash
git push origin main --tags
git push origin main vX.Y.Z
```
**CRITICAL**: Push only the specific tag being released. NEVER use `--tags` -- that pushes ALL local tags, including stale or deleted ones.
This triggers the GitLab CI pipeline which will:
1. Build a signed Android APK and AAB
2. Create a GitLab Release with download links
3. Publish the APK to Zapstore
### Step 11: Confirm
### Step 12: Confirm
After pushing, inform the user:
- The new version number
@@ -193,7 +207,7 @@ After pushing, inform the user:
| `CHANGELOG.md` | Prepend new section | User-facing changelog |
| `public/CHANGELOG.md` | Copy from `CHANGELOG.md` | Served at runtime by the app |
| `android/app/build.gradle` | `versionName` on line 17 | `versionCode` is managed by CI |
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (4 occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (all occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
## CI Pipeline
+13 -9
View File
@@ -162,15 +162,19 @@ release:
if [ -z "$RELEASE_NOTES" ]; then
RELEASE_NOTES="Ditto ${CI_COMMIT_TAG}"
fi
# Create the release with glab (included in the release-cli image).
# Uses a temp file to safely pass multi-line release notes.
- echo "$RELEASE_NOTES" > /tmp/release-notes.md
- |
glab release create "$CI_COMMIT_TAG" \
--name "$CI_COMMIT_TAG" \
--notes-file /tmp/release-notes.md \
"artifacts/Ditto.apk#Ditto-${CI_COMMIT_TAG}.apk" \
"artifacts/Ditto.aab#Ditto-${CI_COMMIT_TAG}.aab"
- echo "$RELEASE_NOTES" > release-notes.md
release:
tag_name: $CI_COMMIT_TAG
name: $CI_COMMIT_TAG
description: './release-notes.md'
assets:
links:
- name: Ditto-${CI_COMMIT_TAG}.apk
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk
link_type: package
- name: Ditto-${CI_COMMIT_TAG}.aab
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab
link_type: package
publish-zapstore:
stage: publish
+63 -1
View File
@@ -12,6 +12,7 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
- **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality
- **TanStack Query**: For data fetching, caching, and state management
- **TypeScript**: For type-safe JavaScript development
- **Capacitor**: Native iOS and Android shell wrapping the web app
## Project Structure
@@ -1246,7 +1247,68 @@ If git is available in your environment (through a `shell` tool, or other git-sp
When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes.
**ALWAYS commit when you are finished making changes.**
**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.**
## Capacitor Compatibility
The app runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
### What Doesn't Work in WKWebView (iOS)
- **`<a download>` file downloads** -- Programmatically creating an anchor element with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
- **`<a target="_blank">` new tabs** -- Programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
- **`window.open()`** -- May be blocked or behave unexpectedly without user gesture context.
### File Downloads and URL Opening
The project provides two utility functions in `src/lib/downloadFile.ts` that handle the web/native split automatically:
#### `downloadTextFile(filename, content)`
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
```typescript
import { downloadTextFile } from '@/lib/downloadFile';
await downloadTextFile('backup.txt', fileContents);
```
#### `openUrl(url)`
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
```typescript
import { openUrl } from '@/lib/downloadFile';
await openUrl('https://example.com/image.jpg');
```
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. Always use the utilities above. They handle the Capacitor/web split and will work correctly on all platforms.
### Detecting Native Platforms
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
```typescript
import { Capacitor } from '@capacitor/core';
if (Capacitor.isNativePlatform()) {
// iOS or Android
} else {
// Web browser
}
```
### Installed Capacitor Plugins
- `@capacitor/app` -- App lifecycle events (deep links, back button)
- `@capacitor/core` -- Core runtime and platform detection
- `@capacitor/filesystem` -- Read/write files on the native filesystem
- `@capacitor/local-notifications` -- Schedule local push notifications
- `@capacitor/share` -- Native share sheet
- `@capacitor/status-bar` -- Control the native status bar style
After adding or removing plugins, run `npx cap sync` to update the native projects.
## CI/CD Pipeline
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen vines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber and NIP-46 users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
- Letters page added to the sidebar with a custom mailbox icon
## [2.0.1] - 2026-03-26
### Added
+1
View File
@@ -7,6 +7,7 @@
| 36767 | Theme Definition | Shareable, named custom UI theme |
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery |
---
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.0.1"
versionName "2.2.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2
View File
@@ -10,7 +10,9 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
}
@@ -25,6 +25,8 @@ public class DittoNotificationPlugin extends Plugin {
public void configure(PluginCall call) {
String userPubkey = call.getString("userPubkey");
String relayUrlsRaw = null;
String enabledKindsRaw = null;
String authorsRaw = null;
try {
JSONArray relayUrls = call.getArray("relayUrls");
@@ -35,14 +37,40 @@ public class DittoNotificationPlugin extends Plugin {
Log.w(TAG, "Failed to read relayUrls", e);
}
try {
JSONArray enabledKinds = call.getArray("enabledKinds");
if (enabledKinds != null) {
enabledKindsRaw = enabledKinds.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read enabledKinds", e);
}
try {
JSONArray authors = call.getArray("authors");
if (authors != null) {
authorsRaw = authors.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read authors", e);
}
SharedPreferences prefs = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
if (userPubkey != null && relayUrlsRaw != null) {
prefs.edit()
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw)
.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw);
.putString("relayUrls", relayUrlsRaw);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
if (authorsRaw != null) {
editor.putString("authors", authorsRaw);
} else {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
} else {
// Clear config (user logged out)
prefs.edit().clear().apply();
@@ -265,6 +265,7 @@ public class NostrPoller {
}
return "commented on your post";
}
case 8211: return "sent you a letter";
default: return "mentioned you";
}
}
@@ -243,6 +243,8 @@ public class NotificationRelayService extends Service {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String userPubkey = prefs.getString("userPubkey", null);
String relayUrlsJson = prefs.getString("relayUrls", null);
String enabledKindsJson = prefs.getString("enabledKinds", null);
String authorsJson = prefs.getString("authors", null);
if (userPubkey == null || relayUrlsJson == null) {
Log.d(TAG, "No config, skipping fetch");
@@ -268,10 +270,17 @@ public class NotificationRelayService extends Service {
return;
}
fetch(relayUrls.get(relayIndex), userPubkey);
List<Integer> enabledKinds = parseEnabledKinds(enabledKindsJson);
if (enabledKinds.isEmpty()) {
Log.d(TAG, "No enabled kinds, skipping fetch");
releaseFetchWakeLock();
return;
}
List<String> authors = parseAuthors(authorsJson);
fetch(relayUrls.get(relayIndex), userPubkey, enabledKinds, authors);
}
private void fetch(String relayUrl, String userPubkey) {
private void fetch(String relayUrl, String userPubkey, List<Integer> enabledKinds, List<String> authors) {
long since = poller.getLastSeenTimestamp();
if (since == 0) {
since = (System.currentTimeMillis() / 1000) - 300; // 5 min ago on first run
@@ -284,7 +293,9 @@ public class NotificationRelayService extends Service {
try {
JSONObject filter = new JSONObject();
JSONArray kinds = new JSONArray();
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111);
for (int kind : enabledKinds) {
kinds.put(kind);
}
filter.put("kinds", kinds);
JSONArray pTags = new JSONArray();
pTags.put(userPubkey);
@@ -292,6 +303,15 @@ public class NotificationRelayService extends Service {
filter.put("since", since + 1);
filter.put("limit", FETCH_LIMIT);
// When "only from people I follow" is enabled, restrict to those authors
if (!authors.isEmpty()) {
JSONArray authorsArr = new JSONArray();
for (String author : authors) {
authorsArr.put(author);
}
filter.put("authors", authorsArr);
}
JSONArray req = new JSONArray();
req.put("REQ");
req.put(currentSubId);
@@ -397,7 +417,8 @@ public class NotificationRelayService extends Service {
}
Log.d(TAG, "Retrying in " + backoffMs + "ms on relay " + relayIndex);
Runnable retry = () -> fetch(relayUrls.get(relayIndex), userPubkey);
// Re-read config from prefs on retry so enabled kinds stay current.
Runnable retry = this::runFetchCycle;
handler.postDelayed(retry, backoffMs);
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
@@ -515,6 +536,46 @@ public class NotificationRelayService extends Service {
return urls;
}
/**
* Parse the authors filter from JSON. Returns an empty list when the value
* is null or invalid (meaning no author restriction).
*/
private List<String> parseAuthors(String json) {
List<String> authors = new ArrayList<>();
if (json != null) {
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
authors.add(arr.getString(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse authors", e);
}
}
return authors;
}
/**
* Parse the enabled notification kinds from JSON. Returns an empty list
* when the value is null or invalid — the caller should skip polling
* when the list is empty (the JS layer always provides kinds via
* DittoNotification.configure in the same write as pubkey/relays).
*/
private List<Integer> parseEnabledKinds(String json) {
List<Integer> kinds = new ArrayList<>();
if (json != null) {
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
kinds.add(arr.getInt(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse enabled kinds", e);
}
}
return kinds;
}
private Notification buildForegroundNotification() {
Intent notificationIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(
+6
View File
@@ -5,8 +5,14 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
+255
View File
@@ -0,0 +1,255 @@
# Blobbi Tag Schema
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
## Overview
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
- All valid tags and their purposes
- Which tags are required vs optional
- Which tags persist across stage transitions
- Which tags should be removed during transitions
- Deprecated tags that should be filtered out
---
## Tag Categories
### 1. System / Metadata Tags
Core protocol-level tags required for event identification and ecosystem membership.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
### 2. Core Identity Tags
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
### 3. Visual Trait Tags
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
### 4. Personality / Trait Tags
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
### 5. Stat Tags
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
**Stage Transition Behavior**:
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
### 6. State / Lifecycle Tags
Tags that track the Blobbi's current lifecycle state.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
**State Constraints**:
- `incubating` is only valid for `stage: egg`
- `evolving` is only valid for `stage: baby`
- After hatch/evolve completes, `state` MUST be set to `active`
### 7. Task System Tags
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
### 8. Progression Tags
Long-term progress tracking that persists across all stages.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
### 9. Social / Flag Tags
User preferences and computed flags.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `visible_to_others` | No | egg, baby, adult | Yes | user | `true\|false` | true | Public visibility |
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
### 10. Evolution Tags
Tags specific to adult Blobbis.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
### 11. Extension Tags
Optional tags for themes and crossover features.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
---
## Deprecated Tags
These tags are from legacy versions and MUST be removed when republishing events.
| Tag | Reason | Replaced By |
|-----|--------|-------------|
| `shell_integrity` | Eggs use standard `health` stat | `health` |
| `egg_temperature` | Warmth handled via UI props | N/A |
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
| `egg_status` | Replaced by standard state | `state` |
| `fees` | Removed | N/A |
| `incubation_time` | Uses state_started_at | `state_started_at` |
| `start_incubation` | Uses state_started_at | `state_started_at` |
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
---
## Stage Transition Rules
### Hatch (egg → baby)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``baby`
- `state``active`
- `hunger``100`
- `happiness``100`
- `hygiene``100`
- `energy``100`
- `health` → (inherited from egg after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- All system tags (`d`, `b`, `t`, `client`)
- All identity tags (`name`, `seed`, `generation`)
- All visual tags (colors, pattern, size)
- All personality tags (if present)
- All progression tags (`experience`, `care_streak`)
- All social tags (`visible_to_others`, `breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``adult`
- `state``active`
- All stats → (inherited from baby after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- Same as hatch, plus all stats are inherited (not reset)
**Tags to ADD (optional)**:
- `adult_type` → computed based on care history
---
## Migration Rules
When migrating legacy Blobbis to canonical format:
1. **Always preserve existing values** - Do not regenerate tags that already exist
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
---
## Validation Rules
A valid Blobbi event MUST have:
- `d` tag in canonical format
- `b` tag = `blobbi:ecosystem:v1`
- `t` tag = `blobbi`
- `name` tag (non-empty)
- `seed` tag (64 hex chars)
- `stage` tag (valid value)
- `state` tag (valid value)
- `last_interaction` tag (valid timestamp)
---
## Implementation Checklist
When implementing any flow that modifies Blobbi tags:
- [ ] Start from `canonical.allTags` as the base
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
- [ ] Filter out deprecated tags
- [ ] Update only the tags that need to change
- [ ] Validate required tags are present
+7
View File
@@ -39,6 +39,13 @@ export default tseslint.config(
},
],
"custom/no-placeholder-comments": "error",
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.object.type='MetaProperty'][callee.property.name='glob']",
"message": "import.meta.glob is Vite-only and breaks other bundlers. Inline the assets or use standard imports instead.",
},
],
"no-warning-comments": [
"error",
{ terms: ["fixme"] },
+1 -1
View File
@@ -23,7 +23,7 @@
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.1;
MARKETING_VERSION = 2.2.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.1;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+4
View File
@@ -13,7 +13,9 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
],
targets: [
@@ -23,7 +25,9 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
]
)
+2468 -481
View File
File diff suppressed because it is too large Load Diff
+36 -21
View File
@@ -1,7 +1,7 @@
{
"name": "mkstack",
"name": "ditto",
"private": true,
"version": "2.0.1",
"version": "2.2.0",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -11,13 +11,14 @@
"icons": "bash scripts/generate-icons.sh"
},
"engines": {
"npm": "10.9.4",
"node": "22.x"
"node": ">=22"
},
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -26,6 +27,7 @@
"@emoji-mart/react": "^1.1.1",
"@fontsource-variable/comfortaa": "^5.2.8",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/fredoka": "^5.2.10",
"@fontsource-variable/inter": "^5.2.6",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lora": "^5.2.8",
@@ -35,19 +37,23 @@
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
"@fontsource/comic-neue": "^5.2.7",
"@fontsource/comic-relief": "^5.2.2",
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/creepster": "^5.2.7",
"@fontsource/luckiest-guy": "^5.2.8",
"@fontsource/pacifico": "^5.2.7",
"@fontsource/permanent-marker": "^5.2.7",
"@fontsource/pirata-one": "^5.2.8",
"@fontsource/press-start-2p": "^5.2.7",
"@fontsource/silkscreen": "^5.2.8",
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.3.1",
"@nostrify/react": "^0.4.0",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -56,18 +62,18 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
@@ -76,7 +82,7 @@
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.0.10",
@@ -87,19 +93,21 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
"hls.js": "^1.6.15",
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react": "^19.2.4",
"react-blurhash": "^0.3.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.71.1",
"react-intersection-observer": "^9.16.0",
@@ -112,7 +120,7 @@
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uri-templates": "^0.2.0",
"vaul": "^0.9.3",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -123,12 +131,14 @@
"@html-eslint/eslint-plugin": "^0.41.0",
"@html-eslint/parser": "^0.41.0",
"@tailwindcss/typography": "^0.5.15",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.5.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"@webxdc/types": "^2.1.2",
@@ -139,10 +149,15 @@
"globals": "^15.9.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^8.0.0",
"vite": "^8.0.3",
"vitest": "^3.1.4"
},
"overrides": {
"react": "$react",
"react-dom": "$react-dom"
}
}
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen vines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber and NIP-46 users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
- Letters page added to the sidebar with a custom mailbox icon
## [2.0.1] - 2026-03-26
### Added
+26 -5
View File
@@ -6,7 +6,7 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Generating Android app icons...${NC}\n"
echo -e "${GREEN}Generating app icons...${NC}\n"
# Check for inkscape (preferred) or rsvg-convert as fallback
if command -v inkscape &> /dev/null; then
@@ -138,12 +138,33 @@ cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
</resources>
EOF
# ── iOS App Icon (1024x1024, white logo on purple background) ──
echo "Generating iOS app icon..."
IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
if [ -d "$IOS_ICON_DIR" ]; then
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
\( "$LOGO_WHITE" -resize "614x614" \) \
-gravity center -compose over -composite \
"$IOS_ICON"
echo -e " ${GREEN}${NC} $IOS_ICON"
else
echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}"
fi
# Cleanup temp files
rm -rf "$TMPDIR"
echo -e "\n${GREEN}Android icons generated successfully!${NC}"
echo -e "\n${GREEN}App icons generated successfully!${NC}"
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
echo -e "Generated:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
echo -e " - ic_launcher.png (legacy square, all densities)"
echo -e " - ic_launcher_round.png (legacy round, all densities)"
echo -e " Android:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
echo -e " - ic_launcher.png (legacy square, all densities)"
echo -e " - ic_launcher_round.png (legacy round, all densities)"
echo -e " iOS:"
echo -e " - AppIcon-512@2x.png (1024x1024)"
+12 -6
View File
@@ -18,9 +18,11 @@ import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
const dmConfig: DMConfig = {
@@ -156,6 +158,8 @@ const defaultConfig: AppConfig = {
};
export function App() {
useNsecPasteGuard();
useEffect(() => {
// Initialize StatusBar for mobile apps
if (Capacitor.isNativePlatform()) {
@@ -180,12 +184,14 @@ export function App() {
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
<EmotionDevProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
</NWCProvider>
</NostrProvider>
+72 -45
View File
@@ -1,60 +1,75 @@
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { ReplyComposeModal } from "@/components/ReplyComposeModal";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { MainLayout } from "./components/MainLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { getExtraKindDef } from "./lib/extraKinds";
import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage";
import { AIChatPage } from "./pages/AIChatPage";
import { BadgesPage } from "./pages/BadgesPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import { BooksPage } from "./pages/BooksPage";
import { ContentPage } from "./pages/ContentPage";
import { ContentSettingsPage } from "./pages/ContentSettingsPage";
import { DomainFeedPage } from "./pages/DomainFeedPage";
import { EventsFeedPage } from "./pages/EventsFeedPage";
import { ExternalContentPage } from "./pages/ExternalContentPage";
import { GeotagPage } from "./pages/GeotagPage";
import { HashtagPage } from "./pages/HashtagPage";
import { HelpPage } from "./pages/HelpPage";
import { HomePage } from "./pages/HomePage";
// Critical-path pages: eagerly loaded (landing + fallback)
import Index from "./pages/Index";
import { KindFeedPage } from "./pages/KindFeedPage";
import { MagicSettingsPage } from "./pages/MagicSettingsPage";
import { MusicFeedPage } from "./pages/MusicFeedPage";
import { NetworkSettingsPage } from "./pages/NetworkSettingsPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
import { NotificationSettings } from "./pages/NotificationSettings";
import { NotificationsPage } from "./pages/NotificationsPage";
import { PhotosFeedPage } from "./pages/PhotosFeedPage";
import { PodcastsFeedPage } from "./pages/PodcastsFeedPage";
import { CSAEPolicyPage } from "./pages/CSAEPolicyPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { ProfileSettings } from "./pages/ProfileSettings";
import { RelayPage } from "./pages/RelayPage";
import { SearchPage } from "./pages/SearchPage";
import { SettingsPage } from "./pages/SettingsPage";
import { ThemesPage } from "./pages/ThemesPage";
import { TreasuresPage } from "./pages/TreasuresPage";
import { TrendsPage } from "./pages/TrendsPage";
import { UserListsPage } from "./pages/UserListsPage";
import { VideosFeedPage } from "./pages/VideosFeedPage";
import { VinesFeedPage } from "./pages/VinesFeedPage";
import { WalletSettingsPage } from "./pages/WalletSettingsPage";
import { WebxdcFeedPage } from "./pages/WebxdcFeedPage";
import { WorldPage } from "./pages/WorldPage";
import { ArchivePage } from "./pages/ArchivePage";
import { BlueskyPage } from "./pages/BlueskyPage";
import { ChangelogPage } from "./pages/ChangelogPage";
import { WikipediaPage } from "./pages/WikipediaPage";
// Lazy-loaded companion layer (~450K code-split)
const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => ({ default: m.BlobbiCompanionLayer })));
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
// All other pages: code-split via React.lazy
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
const MusicFeedPage = lazy(() => import("./pages/MusicFeedPage").then(m => ({ default: m.MusicFeedPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const pollsDef = getExtraKindDef("polls")!;
const colorsDef = getExtraKindDef("colors")!;
@@ -75,7 +90,11 @@ function PollsFeedPage() {
icon={sidebarItemIcon("polls", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
{composeOpen && (
<Suspense fallback={null}>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
</Suspense>
)}
</>
);
}
@@ -96,6 +115,11 @@ export function AppRouter() {
<AudioNavigationGuard />
<DeepLinkHandler />
<ScrollToTop />
<BlobbiActionsProvider>
<Suspense fallback={null}>
<BlobbiCompanionLayer />
</Suspense>
</BlobbiActionsProvider>
<Routes>
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
@@ -205,12 +229,15 @@ export function AppRouter() {
<Route path="/themes" element={<ThemesPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/blobbi" element={<BlobbiPage />} />
<Route path="/world" element={<WorldPage />} />
<Route path="/badges" element={<BadgesPage />} />
<Route path="/books" element={<BooksPage />} />
<Route path="/archive" element={<ArchivePage />} />
<Route path="/bluesky" element={<BlueskyPage />} />
<Route path="/wikipedia" element={<WikipediaPage />} />
<Route path="/letters" element={<LettersPage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
@@ -0,0 +1,614 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import { cn } from '@/lib/utils';
import {
filterInventoryByAction,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
canUseAction,
getStageRestrictionMessage,
ACTION_METADATA,
type InventoryAction,
type ResolvedInventoryItem,
type EggStatPreview,
} from '../lib/blobbi-action-utils';
interface BlobbiActionInventoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
}
export function BlobbiActionInventoryModal({
open,
onOpenChange,
action,
companion,
profile,
onUseItem,
onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// State for confirmation dialog
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Filter inventory by action type, respecting egg-compatible effects
const availableItems = useMemo(() => {
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
const stageMessage = getStageRestrictionMessage(companion, action);
const isEmpty = availableItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowConfirmDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset after starting use
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseConfirmDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const handleOpenShop = () => {
onOpenChange(false);
onOpenShop();
};
// Quantity controls
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
{actionMeta.icon}
</div>
<div className="min-w-0">
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
{actionMeta.description}
</p>
</div>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
{/* Stage Restriction Message */}
{!canUse && stageMessage && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
<span className="text-3xl">🥚</span>
</div>
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{stageMessage}
</p>
</div>
)}
{/* Empty State */}
{canUse && isEmpty && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-4">
You don't have any items for this action. Visit the shop to get some!
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
{/* Item List */}
{canUse && !isEmpty && (
<div className="grid gap-3">
{availableItems.map((item) => (
<BlobbiInventoryUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleSelectItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
</div>
)}
</div>
</DialogContent>
{/* Confirmation Dialog with Quantity Selector */}
{selectedItem && (
<BlobbiUseItemConfirmDialog
open={showConfirmDialog}
onOpenChange={handleCloseConfirmDialog}
item={selectedItem}
companion={companion}
action={action}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
)}
</Dialog>
);
}
// ─── Inventory Use Row ────────────────────────────────────────────────────────
interface BlobbiInventoryUseRowProps {
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
onUse: () => void;
isUsing: boolean;
disabled: boolean;
}
function BlobbiInventoryUseRow({
item,
companion,
action,
onUse,
isUsing,
disabled,
}: BlobbiInventoryUseRowProps) {
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
// For eggs using medicine, show health preview
// Eggs use the 3-stat model: health, hygiene, happiness
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
item.effect
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
};
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
{/* Top row on mobile: Icon + Info + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className="relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl">
{item.icon}
</div>
</div>
{/* Item Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs shrink-0">
x{item.quantity}
</Badge>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
</div>
{/* Use Button */}
<Button
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
</div>
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
);
}
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
interface BlobbiUseItemConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function BlobbiUseItemConfirmDialog({
open,
onOpenChange,
item,
companion,
action,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: BlobbiUseItemConfirmDialogProps) {
const actionMeta = ACTION_METADATA[action];
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes for the selected quantity
const statPreview = useMemo(() => {
if (!item.effect) return { normalChanges: [], eggChanges: [] };
if (isEgg && isMedicine) {
// Calculate health change for N items
const healthDelta = item.effect.health ?? 0;
let currentHealth = companion.stats.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
}
const totalDelta = currentHealth - (companion.stats.health ?? 0);
return {
normalChanges: [],
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
};
}
if (isEgg && isClean) {
// Calculate hygiene and happiness changes for N items
const hygieneDelta = item.effect.hygiene ?? 0;
const happinessDelta = item.effect.happiness ?? 0;
let currentHygiene = companion.stats.hygiene ?? 0;
let currentHappiness = companion.stats.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
}
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
return { normalChanges: [], eggChanges: changes };
}
// Normal stats preview - simulate N applications
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const changes: Array<{ stat: string; delta: number }> = [];
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
changes.push({ stat, delta });
}
}
return { normalChanges: changes, eggChanges: [] };
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>{actionMeta.label}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{hasChanges && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<div className="flex flex-wrap gap-2">
{statPreview.normalChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
{statPreview.eggChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,201 @@
// src/blobbi/actions/components/BlobbiActionsModal.tsx
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
interface BlobbiActionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
onRest: () => void;
onInventoryAction: (action: InventoryAction) => void;
onDirectAction: (action: DirectAction) => void;
actionInProgress: string | null;
isPublishing: boolean;
}
export function BlobbiActionsModal({
open,
onOpenChange,
companion,
onRest,
onInventoryAction,
onDirectAction,
actionInProgress,
isPublishing,
}: BlobbiActionsModalProps) {
const isSleeping = companion.state === 'sleeping';
const isDisabled = isPublishing || actionInProgress !== null;
const isEgg = companion.stage === 'egg';
const handleAction = (action: () => void) => {
action();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle>Blobbi Actions</DialogTitle>
<p className="text-sm text-muted-foreground">{companion.name}</p>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="grid gap-3">
{/* Feed Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('feed'))}
disabled={isDisabled}
>
<Utensils className="size-5 text-orange-500" />
<div className="text-left">
<p className="font-medium">Feed</p>
<p className="text-xs text-muted-foreground">
Give your Blobbi something to eat
</p>
</div>
</Button>
)}
{/* Play Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('play'))}
disabled={isDisabled}
>
<Gamepad2 className="size-5 text-yellow-500" />
<div className="text-left">
<p className="font-medium">Play</p>
<p className="text-xs text-muted-foreground">
Play with toys to make your Blobbi happy
</p>
</div>
</Button>
)}
{/* Clean Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('clean'))}
disabled={isDisabled}
>
<SparklesIcon className="size-5 text-blue-500" />
<div className="text-left">
<p className="font-medium">Clean</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg clean and fresh'
: 'Keep your Blobbi clean and fresh'}
</p>
</div>
</Button>
{/* Medicine Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('medicine'))}
disabled={isDisabled}
>
<Pill className="size-5 text-green-500" />
<div className="text-left">
<p className="font-medium">Medicine</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg healthy'
: 'Heal your Blobbi'}
</p>
</div>
</Button>
{/* Play Music Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('play_music'))}
disabled={isDisabled}
>
<Music className="size-5 text-pink-500" />
<div className="text-left">
<p className="font-medium">Play Music</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Play soothing music for your egg'
: 'Play music for your Blobbi'}
</p>
</div>
</Button>
{/* Sing Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('sing'))}
disabled={isDisabled}
>
<Mic className="size-5 text-purple-500" />
<div className="text-left">
<p className="font-medium">Sing</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Sing a lullaby to your egg'
: 'Sing to your Blobbi'}
</p>
</div>
</Button>
{/* Sleep/Wake Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(onRest)}
disabled={isDisabled}
>
{actionInProgress === 'rest' ? (
<Loader2 className="size-5 animate-spin" />
) : isSleeping ? (
<Sun className="size-5 text-amber-500" />
) : (
<Moon className="size-5 text-violet-500" />
)}
<div className="text-left">
<p className="font-medium">{isSleeping ? 'Wake Up' : 'Sleep'}</p>
<p className="text-xs text-muted-foreground">
{isSleeping ? 'Wake your Blobbi up' : 'Put your Blobbi to sleep'}
</p>
</div>
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,447 @@
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
/**
* Missions modal for Blobbi.
*
* Shows:
* - Daily missions (always visible, separate reward system)
* - Incubation tasks when the current Blobbi is incubating (egg stage)
* - Evolve tasks when evolving (baby stage)
*/
import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
import { formatCompactNumber, cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useState } from 'react';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { NostrEvent } from '@nostrify/nostrify';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
import { TasksPanel } from './TasksPanel';
import { DailyMissionsPanel } from './DailyMissionsPanel';
import { useDailyMissions } from '../hooks/useDailyMissions';
import { useClaimMissionReward } from '../hooks/useClaimMissionReward';
import { useRerollMission } from '../hooks/useRerollMission';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Current companion being viewed */
companion: BlobbiCompanion;
/** Current Blobbonaut profile (required for coin updates) */
profile: BlobbonautProfile | null;
/** Callback to update profile in query cache after claiming */
updateProfileEvent: (event: NostrEvent) => void;
/** Hatch tasks result from useHatchTasks */
hatchTasks: HatchTasksResult;
/** Evolve tasks result from useEvolveTasks */
evolveTasks: EvolveTasksResult;
/** Called when user clicks "Create Post" action in tasks */
onOpenPostModal: () => void;
/** Called when all hatch tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching: boolean;
/** Called when all evolve tasks are complete and user clicks "Evolve" */
onEvolve: () => void;
/** Whether evolving is in progress */
isEvolving: boolean;
/** Called when user confirms stopping incubation */
onStopIncubation: () => Promise<void>;
/** Whether stop incubation is in progress */
isStoppingIncubation: boolean;
/** Called when user confirms stopping evolution */
onStopEvolution: () => Promise<void>;
/** Whether stop evolution is in progress */
isStoppingEvolution: boolean;
/** Available Blobbi stages across all user's companions (for mission filtering) */
availableStages?: ('egg' | 'baby' | 'adult')[];
}
// ─── Daily Missions Section ───────────────────────────────────────────────────
interface DailyMissionsSectionProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
/** Available Blobbi stages the user has */
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayClaimedReward,
totalPotentialReward,
bonusAvailable,
bonusClaimed,
bonusReward,
noMissionsAvailable,
rerollsRemaining,
} = useDailyMissions({ availableStages });
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
profile,
updateProfileEvent
);
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const handleClaimReward = (missionId: string) => {
claimReward({ missionId });
};
const handleRerollMission = (missionId: string) => {
rerollMission({ missionId, availableStages });
};
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-sm">Daily Missions</h3>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0" />
<span className="whitespace-nowrap">
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
</span>
</div>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
</div>
</div>
</CollapsibleTrigger>
{/* Mission list */}
<CollapsibleContent className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={handleClaimReward}
onRerollMission={handleRerollMission}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</CollapsibleContent>
</Collapsible>
);
}
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
interface StopConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companionName: string;
processType: 'incubation' | 'evolution';
onConfirm: () => Promise<void>;
isPending: boolean;
}
function StopConfirmationDialog({
open,
onOpenChange,
companionName,
processType,
onConfirm,
isPending,
}: StopConfirmationDialogProps) {
const handleConfirm = async () => {
await onConfirm();
onOpenChange(false);
};
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Stop {label}?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
<strong>{companionName}</strong>?
</p>
<p>
This will interrupt the {processType} process and clear all task progress.
You can restart {processType} later, but you'll need to complete the tasks again.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending}
className="bg-destructive hover:bg-destructive/90"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
`Stop ${label}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
interface ProcessContentProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
onOpenPostModal: () => void;
onComplete: () => void;
isCompleting: boolean;
onStop: () => Promise<void>;
isStopping: boolean;
defaultOpen?: boolean;
}
function ProcessContent({
companion,
tasks,
processType,
onOpenPostModal,
onComplete,
isCompleting,
onStop,
isStopping,
defaultOpen = true,
}: ProcessContentProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const isIncubation = processType === 'incubation';
const emoji = isIncubation ? '🥚' : '🐣';
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
const description = isIncubation
? 'Complete these tasks to hatch your Blobbi'
: 'Complete these tasks to evolve your Blobbi';
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
const completeEmoji = isIncubation ? '🐣' : '';
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
const completedCount = tasks.tasks.filter(t => t.completed).length;
const totalTasks = tasks.tasks.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
<div className="flex items-center gap-2">
<span className="text-lg">{emoji}</span>
<h3 className="font-semibold text-sm">{title}</h3>
</div>
<div className="flex items-center gap-2">
<span className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full",
tasks.allCompleted
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{completedCount}/{totalTasks}
</span>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
</div>
</div>
</CollapsibleTrigger>
{/* Tasks content */}
<CollapsibleContent className="pt-3">
{/* Tasks Panel */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onOpenPostModal={onOpenPostModal}
onComplete={onComplete}
isCompleting={isCompleting}
emoji={emoji}
title={title}
description={description}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
/>
{/* Stop Process Button */}
<div className="mt-6 pt-4 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
{isStopping ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-4 mr-2" />
{stopLabel}
</>
)}
</Button>
</div>
</CollapsibleContent>
{/* Stop Confirmation Dialog */}
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
companionName={companion.name}
processType={processType}
onConfirm={onStop}
isPending={isStopping}
/>
</Collapsible>
);
}
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function BlobbiMissionsModal({
open,
onOpenChange,
companion,
profile,
updateProfileEvent,
hatchTasks,
evolveTasks,
onOpenPostModal,
onHatch,
isHatching,
onEvolve,
isEvolving,
onStopIncubation,
isStoppingIncubation,
onStopEvolution,
isStoppingEvolution,
availableStages,
}: BlobbiMissionsModalProps) {
const isIncubating = companion.state === 'incubating';
const isEvolvingState = companion.state === 'evolving';
const isEgg = companion.stage === 'egg';
const isBaby = companion.stage === 'baby';
// Check if there's an active hatch/evolve process
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle className="flex items-center gap-2">
<Target className="size-5 shrink-0" />
Missions
</DialogTitle>
<DialogDescription className="break-words">
Complete missions to earn rewards for {companion.name}
</DialogDescription>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-6 py-3 sm:py-4 space-y-4">
{/* Daily Missions Section - Always visible, expanded by default */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
defaultOpen={true}
/>
{/* Hatch/Evolve Process Section - Only when active, expanded by default */}
{hasActiveProcess && (
<>
{isIncubating && isEgg ? (
<ProcessContent
companion={companion}
tasks={hatchTasks}
processType="incubation"
onOpenPostModal={onOpenPostModal}
onComplete={onHatch}
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
defaultOpen={true}
/>
) : isEvolvingState && isBaby ? (
<ProcessContent
companion={companion}
tasks={evolveTasks}
processType="evolution"
onOpenPostModal={onOpenPostModal}
onComplete={onEvolve}
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
defaultOpen={true}
/>
) : null}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,323 @@
// src/blobbi/actions/components/BlobbiPostModal.tsx
/**
* Modal for creating a Blobbi post (hatch or evolve).
*
* Requirements:
* - Prefilled with stage-aware text:
* - Hatch: "Hello Nostr! Posting to hatch #<blobbiName> #blobbi #ditto #nostr"
* - Evolve: "Hello Nostr! Posting to evolve #<blobbiName> #blobbi #ditto #nostr"
* - User can ADD text but CANNOT delete the prefix or required hashtags
* - Blobbi name is sanitized into a valid hashtag format
* - Enforced programmatically
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { X, Loader2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
BLOBBI_POST_REQUIRED_HASHTAGS,
} from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The process type for the post */
export type BlobbiPostProcess = 'hatch' | 'evolve';
interface BlobbiPostModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's name (will be converted to hashtag) */
blobbiName: string;
/** The process type - 'hatch' for incubation, 'evolve' for evolution */
process?: BlobbiPostProcess;
onSuccess?: () => void;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Sanitize a name into a valid hashtag format.
* - Removes special characters
* - Replaces spaces with nothing (camelCase-like)
* - Ensures lowercase
* - Handles edge cases
*/
function sanitizeToHashtag(name: string): string {
return name
.toLowerCase()
// Remove emojis and special characters, keep letters, numbers, underscores
.replace(/[^\p{L}\p{N}_]/gu, '')
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
.replace(/^(\d)/, 'blobbi$1')
// Limit length
.slice(0, 30)
// Fallback if empty
|| 'myblobbi';
}
/**
* Build the required prefix text based on process type.
*/
function buildPrefix(process: BlobbiPostProcess): string {
return process === 'evolve'
? 'Hello Nostr! Posting to evolve'
: 'Hello Nostr! Posting to hatch';
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function BlobbiPostModal({
open,
onOpenChange,
blobbiName,
process = 'hatch',
onSuccess,
}: BlobbiPostModalProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending } = useNostrPublish();
// Compute the required elements based on props
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
const prefix = useMemo(() => buildPrefix(process), [process]);
// All required hashtags including the Blobbi name (first)
const allRequiredHashtags = useMemo(() =>
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
[blobbiHashtag]
);
// Build default content
const defaultContent = useMemo(() =>
`${prefix} #${allRequiredHashtags.join(' #')}`,
[prefix, allRequiredHashtags]
);
const [content, setContent] = useState(defaultContent);
const [validationError, setValidationError] = useState<string | null>(null);
// Reset content when modal opens or props change
useEffect(() => {
if (open) {
setContent(defaultContent);
setValidationError(null);
}
}, [open, defaultContent]);
/**
* Validate that the content still contains the required prefix and hashtags.
*/
const validateContent = useCallback((text: string): string | null => {
// Check prefix
if (!text.startsWith(prefix)) {
return 'The post must start with the required text';
}
// Check all required hashtags are present (including Blobbi name)
const lowerText = text.toLowerCase();
for (const tag of allRequiredHashtags) {
if (!lowerText.includes(`#${tag.toLowerCase()}`)) {
return `Missing required hashtag: #${tag}`;
}
}
return null;
}, [prefix, allRequiredHashtags]);
/**
* Handle content change with validation.
* Prevents deletion of required content.
*/
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value;
// Allow content changes only if it preserves the required elements
const error = validateContent(newContent);
if (error) {
setValidationError(error);
// Still update content but show error
// This allows the user to see what they're trying to do
// but the post button will be disabled
} else {
setValidationError(null);
}
setContent(newContent);
}, [validateContent]);
/**
* Handle post creation.
*/
const handlePost = useCallback(async () => {
if (!user?.pubkey) {
toast({
title: 'Not logged in',
description: 'Please log in to create a post',
variant: 'destructive',
});
return;
}
// Final validation
const error = validateContent(content);
if (error) {
setValidationError(error);
return;
}
try {
// Build tags for the post
const tags: string[][] = [];
// Add all required hashtags as 't' tags
for (const hashtag of allRequiredHashtags) {
tags.push(['t', hashtag.toLowerCase()]);
}
// Extract any additional hashtags the user added
const additionalHashtags = content.match(/#(\w+)/g) || [];
const requiredLower = allRequiredHashtags.map(t => t.toLowerCase());
for (const tag of additionalHashtags) {
const tagValue = tag.slice(1).toLowerCase();
if (!requiredLower.includes(tagValue)) {
tags.push(['t', tagValue]);
}
}
await createEvent({
kind: 1,
content,
tags,
});
toast({
title: 'Post created!',
description: process === 'evolve'
? 'Your Blobbi evolution post has been published.'
: 'Your Blobbi hatch post has been published.',
});
onOpenChange(false);
onSuccess?.();
} catch (error) {
toast({
title: 'Failed to create post',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
const canPost = !validationError && content.trim().length > 0;
const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post';
const alertText = process === 'evolve'
? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post."
: "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post.";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0">
{/* Header */}
<div className="flex items-center justify-between px-4 h-14 border-b">
<DialogTitle className="text-base font-semibold">
{dialogTitle}
</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Info alert */}
<Alert className="border-primary/20 bg-primary/5">
<AlertDescription className="text-sm">
{alertText}
</AlertDescription>
</Alert>
{/* Textarea */}
<div className="space-y-2">
<Textarea
value={content}
onChange={handleContentChange}
placeholder="Write your post..."
className="min-h-[150px] resize-none"
disabled={isPending}
/>
{/* Character count and validation */}
<div className="flex items-center justify-between text-sm">
<div>
{validationError && (
<span className="text-destructive flex items-center gap-1">
<AlertCircle className="size-3.5" />
{validationError}
</span>
)}
</div>
<span className="text-muted-foreground">
{content.length} characters
</span>
</div>
</div>
{/* Preview of required content */}
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
<p className="text-xs text-muted-foreground mb-1">Required content:</p>
<p className="text-sm font-medium">
<span className="text-primary">{prefix}</span>
{' '}
{allRequiredHashtags.map(tag => (
<span key={tag} className="text-blue-500">#{tag} </span>
))}
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/30">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handlePost}
disabled={!canPost || isPending}
className="min-w-24"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Posting...
</>
) : (
'Post'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,370 @@
/**
* DailyMissionsPanel - UI component for displaying daily missions
*
* Shows:
* - Daily mission list with progress bars
* - Completion state
* - Claim buttons for completed missions
* - Coin rewards
* - Bonus mission after completing all regular missions
* - Empty state when no missions available (egg-only users)
* - Reroll button to replace missions (max 3/day)
*/
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMission } from '../lib/daily-missions';
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
/** The daily missions to display */
missions: DailyMission[];
/** Callback when claiming a mission reward */
onClaimReward: (missionId: string) => void;
/** Callback when rerolling a mission */
onRerollMission?: (missionId: string) => void;
/** Total coins earned today */
todayCoins: number;
/** Whether claiming is disabled (e.g., during another operation) */
disabled?: boolean;
/** Whether the bonus mission is available */
bonusAvailable?: boolean;
/** Whether the bonus mission has been claimed */
bonusClaimed?: boolean;
/** Bonus mission reward amount */
bonusReward?: number;
/** Whether user has no eligible missions (e.g., only eggs) */
noMissionsAvailable?: boolean;
/** Number of rerolls remaining today */
rerollsRemaining?: number;
/** Whether a reroll is currently in progress */
isRerolling?: boolean;
}
// ─── Mission Item ─────────────────────────────────────────────────────────────
interface MissionItemProps {
mission: DailyMission;
onClaim: () => void;
onReroll?: () => void;
disabled?: boolean;
canReroll?: boolean;
isRerolling?: boolean;
}
function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) {
const progressPercent = (mission.currentCount / mission.requiredCount) * 100;
const canClaim = mission.completed && !mission.claimed;
// Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining
const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll;
return (
<div
className={cn(
'relative p-3 sm:p-4 rounded-lg border transition-colors overflow-hidden',
mission.claimed
? 'bg-primary/5 border-primary/20'
: mission.completed
? 'bg-green-500/5 border-green-500/30'
: 'bg-card border-border'
)}
>
{/* Top right area: Claimed badge OR Reroll button */}
<div className="absolute top-2 right-2">
{mission.claimed ? (
<div className="flex items-center gap-1 text-xs text-primary font-medium">
<Check className="size-3" />
Claimed
</div>
) : showRerollButton ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={onReroll}
disabled={disabled || isRerolling}
>
<RefreshCw className={cn("size-3.5", isRerolling && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace this mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
{/* Mission content */}
<div className="space-y-2 sm:space-y-3">
{/* Title and description */}
<div className="pr-14 sm:pr-16">
<h4 className="font-medium text-sm break-words">{mission.title}</h4>
<p className="text-xs text-muted-foreground mt-0.5 break-words">
{mission.description}
</p>
</div>
{/* Progress bar */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs gap-2">
<span className="text-muted-foreground whitespace-nowrap">
{mission.currentCount} / {mission.requiredCount}
</span>
<span className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400 whitespace-nowrap">
<Coins className="size-3 shrink-0" />
{formatCompactNumber(mission.reward)}
</span>
</div>
<Progress
value={progressPercent}
className={cn(
'h-2',
mission.completed && '[&>div]:bg-green-500'
)}
/>
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-green-600 hover:bg-green-700 text-white"
>
<Gift className="size-4 mr-2 shrink-0" />
<span className="truncate">Claim {formatCompactNumber(mission.reward)} Coins</span>
</Button>
)}
</div>
</div>
);
}
// ─── Bonus Mission Item ───────────────────────────────────────────────────────
interface BonusMissionItemProps {
isAvailable: boolean;
isClaimed: boolean;
reward: number;
onClaim: () => void;
disabled?: boolean;
}
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
return (
<div
className={cn(
'relative p-3 sm:p-4 rounded-lg border-2 transition-colors overflow-hidden',
isClaimed
? 'bg-amber-500/10 border-amber-500/30'
: isAvailable
? 'bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-500/40 animate-pulse'
: 'bg-muted/30 border-dashed border-muted-foreground/20'
)}
>
{/* Claimed badge */}
{isClaimed && (
<div className="absolute top-2 right-2">
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
<Check className="size-3" />
Claimed
</div>
</div>
)}
{/* Mission content */}
<div className="space-y-2 sm:space-y-3">
{/* Title and description */}
<div className={cn("pr-14 sm:pr-16", !isAvailable && !isClaimed && "opacity-50")}>
<div className="flex items-center gap-2">
<Trophy className={cn(
"size-4 shrink-0",
isClaimed
? "text-amber-600 dark:text-amber-400"
: isAvailable
? "text-amber-500"
: "text-muted-foreground"
)} />
<h4 className="font-medium text-sm">Daily Champion</h4>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions above to unlock this bonus'}
</p>
</div>
{/* Reward display */}
<div className="flex items-center justify-between text-xs gap-2">
<span className={cn(
"text-muted-foreground",
!isAvailable && !isClaimed && "opacity-50"
)}>
Bonus Reward
</span>
<span className={cn(
"flex items-center gap-1 font-medium",
isClaimed || isAvailable
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground"
)}>
<Coins className="size-3 shrink-0" />
+{formatCompactNumber(reward)}
</span>
</div>
{/* Claim button */}
{isAvailable && !isClaimed && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
<Trophy className="size-4 mr-2 shrink-0" />
<span className="truncate">Claim Bonus {formatCompactNumber(reward)} Coins!</span>
</Button>
)}
</div>
</div>
);
}
// ─── No Missions Available State ──────────────────────────────────────────────
function NoMissionsState() {
return (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Egg className="size-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm">Hatch Your Blobbi First</h4>
<p className="text-xs text-muted-foreground">
Daily missions will be available once you have
<br />
a hatched Blobbi to interact with!
</p>
</div>
</div>
);
}
// ─── All Claimed State ────────────────────────────────────────────────────────
interface AllClaimedStateProps {
todayCoins: number;
}
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
return (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-6 text-primary" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm">All Done for Today!</h4>
<p className="text-xs text-muted-foreground">
You earned <span className="font-medium text-amber-600 dark:text-amber-400">{formatCompactNumber(todayCoins)} coins</span> today.
<br />
Come back tomorrow for new missions!
</p>
</div>
</div>
);
}
// ─── Reroll Counter ───────────────────────────────────────────────────────────
interface RerollCounterProps {
remaining: number;
}
function RerollCounter({ remaining }: RerollCounterProps) {
const text = remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
return (
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
<RefreshCw className="size-3" />
<span>{text}</span>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function DailyMissionsPanel({
missions,
onClaimReward,
onRerollMission,
todayCoins,
disabled,
bonusAvailable = false,
bonusClaimed = false,
bonusReward = 50,
noMissionsAvailable = false,
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
// Show empty state if user has no eligible missions (e.g., only eggs)
if (noMissionsAvailable) {
return <NoMissionsState />;
}
const allRegularClaimed = missions.every((m) => m.claimed);
const allDone = allRegularClaimed && bonusClaimed;
// Show "all done" state only when everything including bonus is claimed
if (allDone) {
return <AllClaimedState todayCoins={todayCoins} />;
}
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
return (
<div className="space-y-3">
{/* Reroll counter - only show if reroll functionality is available */}
{onRerollMission && (
<RerollCounter remaining={rerollsRemaining} />
)}
{/* Regular missions */}
{missions.map((mission) => (
<MissionItem
key={mission.id}
mission={mission}
onClaim={() => onClaimReward(mission.id)}
onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined}
disabled={disabled}
canReroll={canReroll}
isRerolling={isRerolling}
/>
))}
{/* Bonus mission - always visible */}
<BonusMissionItem
isAvailable={bonusAvailable}
isClaimed={bonusClaimed}
reward={bonusReward}
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
disabled={disabled}
/>
</div>
);
}
@@ -0,0 +1,220 @@
// src/blobbi/actions/components/HatchTasksPanel.tsx
/**
* UI component for displaying hatch task progress.
* Shows a list of tasks with progress indicators and action buttons.
*/
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { HatchTask } from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HatchTasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching?: boolean;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
window.open(task.actionTarget, '_blank', 'noopener,noreferrer');
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
onOpenPostModal();
}
break;
}
};
const progress = task.required > 1
? Math.round((task.current / task.required) * 100)
: task.completed ? 100 : 0;
return (
<div
className={cn(
"flex items-center gap-4 p-4 rounded-xl border transition-all",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Status indicator */}
<div className={cn(
"size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-5" />
) : task.required > 1 ? (
<span className="text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className={cn(
"font-medium",
task.completed && "text-emerald-600 dark:text-emerald-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs">
Complete
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{task.description}
</p>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !task.completed && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
</div>
{/* Action button */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2"
>
{task.actionLabel}
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
</Button>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HatchTasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onHatch,
isHatching = false,
}: HatchTasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = Math.round((completedCount / totalTasks) * 100);
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🥚</span>
Hatch Tasks
</CardTitle>
<CardDescription>
Complete these tasks to hatch your Blobbi
</CardDescription>
</div>
<Badge variant="outline" className="text-base px-3 py-1">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Overall progress</span>
<span className="font-medium">{overallProgress}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{tasks.map(task => (
<TaskRow
key={task.id}
task={task}
onOpenPostModal={onOpenPostModal}
/>
))}
{/* Hatch button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<Button
onClick={onHatch}
disabled={isHatching}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
{isHatching ? (
<>
<Loader2 className="size-5 animate-spin" />
Hatching...
</>
) : (
<>
<span className="text-xl">🐣</span>
Hatch Your Blobbi!
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,256 @@
// src/blobbi/actions/components/InlineMusicPlayer.tsx
import { useCallback, useEffect } from 'react';
import { Music, Play, Pause, RotateCcw, MoreHorizontal, Loader2, AlertCircle, X, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import type { AudioSource } from './PlayMusicModal';
// Re-export for external use
export type { AudioSource as MusicTrackSource } from './PlayMusicModal';
interface InlineMusicPlayerProps {
/** The selected track source */
source: AudioSource;
/** Called when user wants to change the track */
onChangeTrack: () => void;
/** Called when user closes the player */
onClose: () => void;
/** Called when playback starts (for Blobbi reaction state) */
onPlaybackStart?: () => void;
/** Called when playback stops/pauses (for Blobbi reaction state) */
onPlaybackStop?: () => void;
/** Whether the action has been published (playback only starts after publish) */
isPublished: boolean;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineMusicPlayer({
source,
onChangeTrack,
onClose,
onPlaybackStart,
onPlaybackStop,
isPublished,
isPublishing,
}: InlineMusicPlayerProps) {
const {
state: playbackState,
error: playbackError,
load,
toggle,
restart,
stop,
isPlaying,
volume,
setVolume,
cleanup,
} = useAudioPlayback({
onEnded: () => {
onPlaybackStop?.();
},
});
// Auto-start playback when first published (idle -> playing)
// Note: 'stopped' state is NOT included here - stop is a terminal state
// that requires explicit user action (play button) to restart
useEffect(() => {
if (isPublished && playbackState === 'idle') {
load(source.url, true);
onPlaybackStart?.();
}
}, [isPublished, playbackState, source.url, load, onPlaybackStart]);
// Force reload when source URL changes while already playing/paused
useEffect(() => {
// Only trigger reload if we're in an active playback state with a different URL
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
// The load function will check if URL changed and reload if needed
load(source.url, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to source.url changes
}, [source.url]);
// Notify on playback state changes
useEffect(() => {
if (isPlaying) {
onPlaybackStart?.();
} else if (playbackState === 'paused' || playbackState === 'stopped') {
onPlaybackStop?.();
}
}, [isPlaying, playbackState, onPlaybackStart, onPlaybackStop]);
// Cleanup on close
const handleClose = useCallback(() => {
stop();
cleanup();
onPlaybackStop?.();
onClose();
}, [stop, cleanup, onPlaybackStop, onClose]);
// Handle play/pause toggle
const handleToggle = useCallback(async () => {
if (playbackState === 'idle' || playbackState === 'stopped') {
load(source.url, true);
} else {
await toggle();
}
}, [playbackState, source.url, load, toggle]);
// Track title
const trackTitle = source.type === 'builtin'
? source.track?.title ?? 'Unknown Track'
: source.file?.name ?? 'Uploaded Track';
const trackArtist = source.type === 'builtin'
? source.track?.artist
: undefined;
const isLoading = playbackState === 'loading' || isPublishing;
const hasError = playbackState === 'error';
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isPlaying && "ring-2 ring-pink-500/30"
)}>
{/* Main content row */}
<div className="flex items-center gap-3 p-3">
{/* Music icon / Now Playing indicator */}
<div className={cn(
"size-10 rounded-lg flex items-center justify-center shrink-0",
isPlaying
? "bg-pink-500/20"
: "bg-muted"
)}>
<Music className={cn(
"size-5",
isPlaying ? "text-pink-500 animate-pulse" : "text-muted-foreground"
)} />
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{trackTitle}</p>
{trackArtist && (
<p className="text-xs text-muted-foreground truncate">{trackArtist}</p>
)}
{!trackArtist && (
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : isPublishing ? 'Starting...' : 'Ready to play'}
</p>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink-0">
{/* Play/Pause button */}
<Button
size="icon"
variant="ghost"
onClick={handleToggle}
disabled={isLoading || !isPublished}
className="size-9 rounded-full"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
{/* Restart button - only show when actively playing or paused */}
{isPublished && (playbackState === 'playing' || playbackState === 'paused') && (
<Button
size="icon"
variant="ghost"
onClick={() => {
restart();
}}
className="size-9 rounded-full"
title="Restart from beginning"
>
<RotateCcw className="size-3.5" />
</Button>
)}
{/* Volume control */}
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-9 rounded-full"
title={volume === 0 ? 'Unmute' : 'Volume'}
>
{volume === 0 ? (
<VolumeX className="size-4" />
) : (
<Volume2 className="size-4" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="center"
className="w-32 p-3"
>
<Slider
value={[volume * 100]}
onValueChange={([val]) => setVolume(val / 100)}
max={100}
step={1}
className="w-full"
/>
</PopoverContent>
</Popover>
{/* Change track button */}
<Button
size="icon"
variant="ghost"
onClick={onChangeTrack}
disabled={isPublishing}
className="size-9 rounded-full"
>
<MoreHorizontal className="size-4" />
</Button>
{/* Close button */}
<Button
size="icon"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="size-9 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
</div>
</div>
{/* Error message */}
{hasError && playbackError && (
<div className="px-3 pb-3">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{playbackError.message}</p>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,487 @@
// src/blobbi/actions/components/InlineSingCard.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import {
Mic,
Play,
Pause,
Square,
FileText,
Check,
X,
Loader2,
AlertCircle,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'error';
interface InlineSingCardProps {
/** Called when user confirms the singing action (publish the action) */
onConfirm: () => Promise<void>;
/** Called when user closes the sing card */
onClose: () => void;
/** Called when recording starts (for Blobbi reaction) */
onRecordingStart?: () => void;
/** Called when recording stops (for Blobbi reaction) */
onRecordingStop?: () => void;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── MIME Type Selection ──────────────────────────────────────────────────────
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineSingCard({
onConfirm,
onClose,
onRecordingStart,
onRecordingStop,
isPublishing,
}: InlineSingCardProps) {
// Recording state
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [recordingError, setRecordingError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
// Lyrics state
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
// Refs
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Audio playback for preview
const {
state: playbackState,
error: playbackError,
load: loadAudio,
toggle: togglePlayback,
stop: stopPlayback,
isPlaying,
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, []);
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
try {
mediaRecorderRef.current.stop();
} catch {
// Ignore errors during cleanup
}
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Cleanup playback
cleanupPlayback();
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl, cleanupPlayback]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
setRecordingState('idle');
setRecordingError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
actualMimeTypeRef.current = undefined;
// Keep lyrics
}, [cleanupAll]);
// Check browser support
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setRecordingError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setRecordingError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get supported MIME type
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
mediaRecorder = new MediaRecorder(stream);
}
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setRecordingError('Recording failed. Please try again.');
setRecordingState('error');
};
mediaRecorder.start(100);
setRecordingState('recording');
setRecordingDuration(0);
// Notify parent that recording started (for Blobbi reaction)
onRecordingStart?.();
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setRecordingError('Microphone access was denied.');
} else if (err.name === 'NotFoundError') {
setRecordingError('No microphone found.');
} else {
setRecordingError(err.message);
}
} else {
setRecordingError('Failed to access microphone.');
}
setRecordingState('error');
}
}, [onRecordingStart]);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
// Notify parent that recording stopped (for Blobbi reaction)
onRecordingStop?.();
}, [onRecordingStop]);
// Handle preview playback
const handlePreview = useCallback(() => {
if (!audioUrl) return;
if (playbackState === 'idle') {
loadAudio(audioUrl, true);
} else {
togglePlayback();
}
}, [audioUrl, playbackState, loadAudio, togglePlayback]);
// Handle confirm
const handleConfirm = useCallback(async () => {
stopPlayback();
await onConfirm();
// After successful publish, close the card
onClose();
}, [stopPlayback, onConfirm, onClose]);
// Handle close
const handleClose = useCallback(() => {
cleanupAll();
onClose();
}, [cleanupAll, onClose]);
// Handle lyrics toggle
const handleLyricsToggle = useCallback(() => {
if (!currentLyrics && !showLyrics) {
// Generate lyrics on first open
setCurrentLyrics(getRandomLyrics());
}
setShowLyrics(!showLyrics);
}, [currentLyrics, showLyrics]);
// Get new lyrics
const handleNewLyrics = useCallback(() => {
setCurrentLyrics(getRandomLyrics());
}, []);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded';
const isRecording = recordingState === 'recording';
const canConfirm = hasRecording && !isPublishing;
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isRecording && "ring-2 ring-red-500/30"
)}>
{/* Lyrics panel (expands upward visually by being above controls) */}
{showLyrics && currentLyrics && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{currentLyrics.title}</span>
<Button
size="icon"
variant="ghost"
onClick={handleNewLyrics}
className="size-7 rounded-full"
>
<RefreshCw className="size-3" />
</Button>
</div>
<div className="p-3 rounded-lg bg-muted/50 text-sm leading-relaxed whitespace-pre-line max-h-32 overflow-y-auto">
{currentLyrics.lines.join('\n')}
</div>
</div>
)}
{/* Status row (recording/recorded info) */}
{(isRecording || hasRecording) && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-center gap-2">
{isRecording && (
<>
<div className="size-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-sm font-mono font-medium text-red-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recording...</span>
</>
)}
{hasRecording && !isRecording && (
<>
<Check className="size-4 text-purple-500" />
<span className="text-sm font-mono font-medium text-purple-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recorded</span>
</>
)}
</div>
</div>
)}
{/* Error message */}
{(recordingError || playbackError) && (
<div className="px-3 pt-2">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{recordingError || playbackError?.message}</p>
</div>
</div>
)}
{/* Main controls row */}
<div className="flex items-center justify-between gap-2 p-3">
{/* Left: Lyrics button */}
<Button
size="icon"
variant={showLyrics ? "secondary" : "ghost"}
onClick={handleLyricsToggle}
className="size-10 rounded-full shrink-0"
>
<FileText className="size-4" />
</Button>
{/* Center: Record/Stop button */}
<div className="flex items-center gap-2">
{!isRecording && !hasRecording && (
<Button
onClick={startRecording}
disabled={isPublishing}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-4 mr-2" />
Sing
</Button>
)}
{isRecording && (
<Button
onClick={stopRecording}
variant="destructive"
className="rounded-full px-6"
>
<Square className="size-4 mr-2" />
Stop
</Button>
)}
{hasRecording && !isRecording && (
<>
<Button
onClick={resetRecording}
variant="outline"
size="icon"
className="size-10 rounded-full"
>
<RefreshCw className="size-4" />
</Button>
<Button
onClick={handleConfirm}
disabled={!canConfirm}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
{isPublishing ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Check className="size-4 mr-2" />
)}
{isPublishing ? 'Singing...' : 'Sing for Blobbi'}
</Button>
</>
)}
</div>
{/* Right: Preview button (when recording exists) */}
{hasRecording ? (
<Button
size="icon"
variant="ghost"
onClick={handlePreview}
disabled={isPublishing}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
) : (
/* Close button when no recording */
<Button
size="icon"
variant="ghost"
onClick={handleClose}
className="size-10 rounded-full shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
)}
</div>
{/* Close button row when recording exists */}
{hasRecording && (
<div className="px-3 pb-3 pt-0 flex justify-end">
<Button
size="sm"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3 mr-1" />
Cancel
</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,445 @@
// src/blobbi/actions/components/PlayMusicModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Music, Upload, Play, Pause, Check, Loader2, Volume2, X, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import {
getAllBuiltInTracks,
formatTrackDuration,
type BuiltInTrack,
} from '../lib/blobbi-builtin-tracks';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Audio source for the music player
*/
export type AudioSource =
| { type: 'builtin'; track: BuiltInTrack; url: string }
| { type: 'uploaded'; file: File; url: string };
interface PlayMusicModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with the selected audio source when user confirms */
onConfirm: (source: AudioSource) => void;
isLoading: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function PlayMusicModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: PlayMusicModalProps) {
const [selectedSource, setSelectedSource] = useState<AudioSource | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [builtInError, setBuiltInError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const builtInTracks = getAllBuiltInTracks();
// Cleanup audio on unmount or modal close
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke object URL if it was an uploaded file
if (selectedSource?.type === 'uploaded') {
URL.revokeObjectURL(selectedSource.url);
}
};
}, [selectedSource]);
// Reset state when modal opens
useEffect(() => {
if (open) {
setSelectedSource(null);
setIsPlaying(false);
setUploadError(null);
setBuiltInError(null);
currentAudioUrlRef.current = null;
}
}, [open]);
// Handle selecting a built-in track
const handleSelectBuiltIn = useCallback((track: BuiltInTrack) => {
// Stop current playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
// Revoke previous URL if uploaded
if (selectedSource?.type === 'uploaded') {
URL.revokeObjectURL(selectedSource.url);
}
setSelectedSource({ type: 'builtin', track, url: track.path });
setBuiltInError(null);
}, [selectedSource]);
// Handle file upload
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/mp4'];
if (!validTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|m4a)$/i)) {
setUploadError('Please upload an MP3, WAV, OGG, or M4A file.');
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
setUploadError('File is too large. Maximum size is 10MB.');
return;
}
// Stop current playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
// Revoke previous URL if uploaded
if (selectedSource?.type === 'uploaded') {
URL.revokeObjectURL(selectedSource.url);
}
const url = URL.createObjectURL(file);
setSelectedSource({ type: 'uploaded', file, url });
setUploadError(null);
}, [selectedSource]);
// Track the current audio source URL to detect changes
const currentAudioUrlRef = useRef<string | null>(null);
// Handle play/pause preview
const handleTogglePlay = useCallback(() => {
if (!selectedSource) return;
const audioUrl = selectedSource.type === 'builtin'
? selectedSource.track.path
: selectedSource.url;
// Check if we need to create a new Audio instance (source changed or first time)
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Stop and cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the correct source
audioRef.current = new Audio(audioUrl);
currentAudioUrlRef.current = audioUrl;
audioRef.current.onended = () => setIsPlaying(false);
audioRef.current.onerror = () => {
if (selectedSource.type === 'builtin') {
setBuiltInError('This track is not available yet. Try uploading your own music!');
}
setIsPlaying(false);
};
}
if (isPlaying && !needsNewAudio) {
// Pause current playback
audioRef.current?.pause();
setIsPlaying(false);
} else {
// Start playback (either new source or resuming)
audioRef.current?.play().catch(() => {
if (selectedSource.type === 'builtin') {
setBuiltInError('This track is not available yet. Try uploading your own music!');
}
setIsPlaying(false);
});
setIsPlaying(true);
}
}, [selectedSource, isPlaying]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (!selectedSource) return;
// Stop playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onConfirm(selectedSource);
}, [selectedSource, onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen && audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onOpenChange(isOpen);
}, [onOpenChange]);
const selectedName = selectedSource?.type === 'builtin'
? selectedSource.track.title
: selectedSource?.type === 'uploaded'
? selectedSource.file.name
: null;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-pink-500/20 to-pink-500/5 flex items-center justify-center">
<Music className="size-5 text-pink-500" />
</div>
<div>
<DialogTitle className="text-xl">Play Music</DialogTitle>
<p className="text-sm text-muted-foreground">
Choose a track to play for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<Tabs defaultValue="builtin" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="builtin">Built-in</TabsTrigger>
<TabsTrigger
value="upload"
disabled
className="gap-1.5 data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
>
Upload
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 font-normal">
Soon
</Badge>
</TabsTrigger>
</TabsList>
{/* Built-in Tracks Tab */}
<TabsContent value="builtin" className="mt-4">
<div className="grid gap-2">
{builtInTracks.map((track) => (
<TrackRow
key={track.id}
track={track}
isSelected={selectedSource?.type === 'builtin' && selectedSource.track.id === track.id}
onSelect={() => handleSelectBuiltIn(track)}
/>
))}
</div>
{builtInError && (
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{builtInError}</p>
</div>
</div>
)}
</TabsContent>
{/* Upload Tab */}
<TabsContent value="upload" className="mt-4">
<div className="space-y-4">
{/* Upload Area */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={cn(
"w-full p-8 rounded-xl border-2 border-dashed transition-colors",
"hover:border-primary/50 hover:bg-primary/5",
"flex flex-col items-center justify-center gap-3",
selectedSource?.type === 'uploaded'
? "border-primary/30 bg-primary/5"
: "border-border"
)}
>
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Upload className="size-6 text-muted-foreground" />
</div>
<div className="text-center">
<p className="font-medium">Upload Audio File</p>
<p className="text-sm text-muted-foreground">
MP3, WAV, OGG, M4A (max 10MB)
</p>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileUpload}
className="hidden"
/>
{/* Upload Error */}
{uploadError && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-start gap-2">
<X className="size-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{uploadError}</p>
</div>
</div>
)}
{/* Uploaded File Display */}
{selectedSource?.type === 'uploaded' && (
<div className="p-4 rounded-xl border bg-card/60">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Music className="size-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedSource.file.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedSource.file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<Check className="size-5 text-primary shrink-0" />
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
{/* Preview Controls */}
{selectedSource && (
<div className="mb-4 p-3 rounded-lg bg-card border">
<div className="flex items-center gap-3">
<Button
size="icon"
variant="outline"
onClick={handleTogglePlay}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate text-sm">{selectedName}</p>
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : 'Click to preview'}
</p>
</div>
{isPlaying && (
<Volume2 className="size-4 text-primary animate-pulse shrink-0" />
)}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedSource || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Playing...
</>
) : (
<>
<Music className="size-4 mr-2" />
Play for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Track Row Component ──────────────────────────────────────────────────────
interface TrackRowProps {
track: BuiltInTrack;
isSelected: boolean;
onSelect: () => void;
}
function TrackRow({ track, isSelected, onSelect }: TrackRowProps) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"w-full p-3 rounded-xl text-left transition-all",
"border hover:border-primary/30",
isSelected
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border bg-card/60"
)}
>
<div className="flex items-center gap-3">
<div className={cn(
"size-10 rounded-lg flex items-center justify-center",
isSelected ? "bg-primary/20" : "bg-muted"
)}>
<Music className={cn(
"size-5",
isSelected ? "text-primary" : "text-muted-foreground"
)} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm text-muted-foreground">
{formatTrackDuration(track.durationSeconds)}
</span>
{isSelected && <Check className="size-4 text-primary" />}
</div>
</div>
</button>
);
}
+601
View File
@@ -0,0 +1,601 @@
// src/blobbi/actions/components/SingModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isLoading: boolean;
}
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
/**
* Ordered list of MIME types to try for audio recording.
* The first supported type will be used.
*/
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
/**
* Get the first supported MIME type for MediaRecorder.
* Returns undefined if no explicit MIME type is supported (let browser decide).
*/
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
// No explicit MIME type supported, let browser use default
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function SingModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: SingModalProps) {
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [error, setError] = useState<string | null>(null);
const [playbackError, setPlaybackError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open]);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Stop audio playback
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl]);
const resetRecording = useCallback(() => {
cleanup();
setRecordingState('idle');
setError(null);
setPlaybackError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
currentPlaybackUrlRef.current = null;
actualMimeTypeRef.current = undefined;
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
setCurrentLyrics(lyrics);
setShowLyrics(true);
}, []);
// Check if browser supports media recording
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setError(null);
setPlaybackError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get the first supported MIME type using our helper
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder with or without explicit MIME type
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
// Let browser choose default MIME type
mediaRecorder = new MediaRecorder(stream);
}
// Store the actual MIME type being used (may differ from what we requested)
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
// Create blob from chunks using the actual MIME type used by the recorder
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setError('Recording failed. Please try again.');
setRecordingState('error');
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setRecordingState('recording');
setRecordingDuration(0);
// Start timer
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('Microphone access was denied. Please allow microphone access and try again.');
} else if (err.name === 'NotFoundError') {
setError('No microphone found. Please connect a microphone and try again.');
} else {
setError(`Failed to access microphone: ${err.message}`);
}
} else {
setError('Failed to access microphone. Please try again.');
}
setRecordingState('error');
}
}, []);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
}, []);
// Track the current audio URL to detect changes
const currentPlaybackUrlRef = useRef<string | null>(null);
// Play/pause preview
const togglePlayback = useCallback(() => {
if (!audioUrl) return;
// Clear previous playback error when attempting to play
setPlaybackError(null);
if (recordingState === 'playing') {
if (audioRef.current) {
audioRef.current.pause();
}
setRecordingState('recorded');
} else {
// Check if we need to create a new Audio instance (URL changed or first time)
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the recorded audio URL
audioRef.current = new Audio(audioUrl);
currentPlaybackUrlRef.current = audioUrl;
audioRef.current.onended = () => setRecordingState('recorded');
// Handle playback errors with user-visible message
audioRef.current.onerror = () => {
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
setRecordingState('recorded');
};
}
audioRef.current?.play()
.then(() => {
setRecordingState('playing');
})
.catch((err) => {
console.error('Failed to play recording:', err);
// Provide user-friendly error message
if (err.name === 'NotSupportedError') {
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
} else if (err.name === 'NotAllowedError') {
setPlaybackError('Playback was blocked. Try interacting with the page first.');
} else {
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
}
setRecordingState('recorded');
});
}
}, [audioUrl, recordingState]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
}
onConfirm();
}, [onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen) {
cleanup();
}
onOpenChange(isOpen);
}, [onOpenChange, cleanup]);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
<Mic className="size-5 text-purple-500" />
</div>
<div>
<DialogTitle className="text-xl">Sing</DialogTitle>
<p className="text-sm text-muted-foreground">
Record yourself singing for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 px-6 py-8">
<div className="flex flex-col items-center justify-center gap-6">
{/* Recording Visualization */}
<div className={cn(
"relative size-40 rounded-full flex items-center justify-center transition-all",
recordingState === 'recording' && "animate-pulse",
recordingState === 'recording'
? "bg-red-500/10 ring-4 ring-red-500/30"
: hasRecording
? "bg-purple-500/10 ring-4 ring-purple-500/30"
: "bg-muted"
)}>
{/* Animated rings for recording */}
{recordingState === 'recording' && (
<>
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
</>
)}
{/* Icon */}
<div className={cn(
"relative size-20 rounded-full flex items-center justify-center",
recordingState === 'recording'
? "bg-red-500 text-white"
: hasRecording
? "bg-purple-500 text-white"
: "bg-muted-foreground/20"
)}>
{recordingState === 'requesting' ? (
<Loader2 className="size-8 animate-spin" />
) : recordingState === 'recording' ? (
<Mic className="size-8" />
) : hasRecording ? (
recordingState === 'playing' ? (
<Pause className="size-8" />
) : (
<Play className="size-8 ml-1" />
)
) : (
<MicOff className="size-8 text-muted-foreground" />
)}
</div>
</div>
{/* Duration / Status */}
<div className="text-center">
{recordingState === 'idle' && (
<p className="text-muted-foreground">Tap the button below to start recording</p>
)}
{recordingState === 'requesting' && (
<p className="text-muted-foreground">Requesting microphone access...</p>
)}
{recordingState === 'recording' && (
<>
<p className="text-3xl font-mono font-bold text-red-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
</>
)}
{hasRecording && (
<>
<p className="text-3xl font-mono font-bold text-purple-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
</p>
</>
)}
{recordingState === 'error' && (
<p className="text-destructive">Recording failed</p>
)}
</div>
{/* Error Message */}
{error && (
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
)}
{/* Playback Error Message (non-fatal, recording still works) */}
{playbackError && (
<div className="w-full p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{playbackError}</p>
</div>
</div>
)}
{/* Lyrics Helper */}
<div className="w-full">
{!currentLyrics ? (
<Button
variant="outline"
size="sm"
onClick={handleRandomLyrics}
className="w-full gap-2"
>
<Sparkles className="size-4" />
Need lyrics? Get random lyrics
</Button>
) : (
<div className="rounded-lg border bg-card/60">
<button
type="button"
onClick={() => setShowLyrics(!showLyrics)}
className="w-full flex items-center justify-between p-3 text-left"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-purple-500" />
<span className="font-medium text-sm">{currentLyrics.title}</span>
</div>
{showLyrics ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</button>
{showLyrics && (
<div className="px-3 pb-3 pt-0">
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
{currentLyrics.lines.join('\n')}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRandomLyrics}
className="w-full mt-2 gap-2 text-muted-foreground"
>
<RotateCcw className="size-3" />
Get different lyrics
</Button>
</div>
)}
</div>
)}
</div>
{/* Recording Controls */}
<div className="flex items-center gap-3">
{recordingState === 'idle' || recordingState === 'error' ? (
<Button
size="lg"
onClick={startRecording}
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-5 mr-2" />
Start Recording
</Button>
) : recordingState === 'recording' ? (
<Button
size="lg"
variant="destructive"
onClick={stopRecording}
className="rounded-full px-8"
>
<Square className="size-5 mr-2" />
Stop
</Button>
) : hasRecording ? (
<>
<Button
size="lg"
variant="outline"
onClick={togglePlayback}
className="rounded-full"
>
{recordingState === 'playing' ? (
<>
<Pause className="size-5 mr-2" />
Pause
</>
) : (
<>
<Play className="size-5 mr-2" />
Preview
</>
)}
</Button>
<Button
size="lg"
variant="ghost"
onClick={resetRecording}
className="rounded-full"
>
<RotateCcw className="size-5 mr-2" />
Re-record
</Button>
</>
) : null}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!hasRecording || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Singing...
</>
) : (
<>
<Mic className="size-4 mr-2" />
Sing for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,125 @@
// src/blobbi/actions/components/StartEvolutionDialog.tsx
/**
* Dialog for confirming start of evolution.
*
* Evolution is simpler than incubation:
* - Only baby Blobbis can evolve
* - Shows restart confirmation if already evolving
* - Otherwise shows normal start confirmation
*/
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartEvolutionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start evolving */
companion: BlobbiCompanion | null;
/** Called when confirmed */
onConfirm: () => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartEvolutionDialog({
open,
onOpenChange,
companion,
onConfirm,
isPending,
}: StartEvolutionDialogProps) {
// Check if the current Blobbi is already evolving
const isAlreadyEvolving = companion?.state === 'evolving';
// Determine title and description based on state
const getDialogContent = () => {
if (isAlreadyEvolving) {
return {
title: 'Restart Evolution?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Evolution',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Evolution',
icon: <Sparkles className="size-5 text-primary" />,
description: (
<>
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
Complete all the tasks to evolve your baby Blobbi into an adult!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Evolution',
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,180 @@
// src/blobbi/actions/components/StartIncubationDialog.tsx
/**
* Dialog for confirming start of incubation.
*
* Determines the mode and passes it explicitly to the confirm callback:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating)
* - 'switch': Stop another Blobbi first, then start this one
*
* The mode is determined by UI state, NOT auto-detected by the hook.
* This makes the flow explicit and predictable.
*/
import { useMemo } from 'react';
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartIncubationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start incubating */
companion: BlobbiCompanion | null;
/** All companions in the collection (to check for other incubating Blobbis) */
companions?: BlobbiCompanion[];
/** Called with explicit mode and optional stopOtherD when confirmed */
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartIncubationDialog({
open,
onOpenChange,
companion,
companions = [],
onConfirm,
isPending,
}: StartIncubationDialogProps) {
// Check if the current Blobbi is already in a task state
const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving';
// Check if another Blobbi (not this one) is currently incubating
const otherIncubatingBlobbi = useMemo(() => {
if (!companion) return null;
return companions.find(c =>
c.d !== companion.d &&
c.state === 'incubating' &&
c.stage === 'egg'
) ?? null;
}, [companion, companions]);
// Determine the mode based on current state
const mode: StartIncubationMode = useMemo(() => {
if (isAlreadyInTaskState) return 'restart';
if (otherIncubatingBlobbi) return 'switch';
return 'start';
}, [isAlreadyInTaskState, otherIncubatingBlobbi]);
// Handle confirm with explicit mode
const handleConfirm = () => {
if (mode === 'switch' && otherIncubatingBlobbi) {
onConfirm(mode, otherIncubatingBlobbi.d);
} else {
onConfirm(mode);
}
};
// Determine title and description based on mode
const getDialogContent = () => {
if (mode === 'restart') {
return {
title: 'Restart Incubation?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
Your Blobbi is already {companion?.state}. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Incubation',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
if (mode === 'switch') {
return {
title: 'Switch Incubation?',
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
description: (
<>
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
Only one Blobbi can incubate at a time.
<br /><br />
Starting incubation for <strong>{companion?.name}</strong> will{' '}
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
reset their task progress.
<br /><br />
Do you want to switch?
</>
),
buttonText: 'Switch & Start',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Incubation',
icon: null,
description: (
<>
Starting incubation begins your Blobbi's hatching journey.
Complete all the tasks to hatch your egg into a baby Blobbi!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Incubation',
buttonClass: undefined,
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,262 @@
// src/blobbi/actions/components/TasksPanel.tsx
/**
* Generic UI component for displaying task progress.
* Shows a list of tasks with progress indicators and action buttons.
* Used for both hatch and evolve tasks.
*/
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { HatchTask } from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
interface TasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks the complete button */
onComplete: () => void;
/** Whether completion is in progress */
isCompleting?: boolean;
/** Emoji to show in header */
emoji: string;
/** Title for the tasks panel */
title: string;
/** Description for the tasks panel */
description: string;
/** Label for the complete button */
completeLabel: string;
/** Label while completing */
completingLabel: string;
/** Emoji for complete button */
completeEmoji: string;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const isDynamic = task.type === 'dynamic';
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
window.open(task.actionTarget, '_blank', 'noopener,noreferrer');
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
onOpenPostModal();
}
break;
}
};
const progress = task.required > 1
? Math.round((task.current / task.required) * 100)
: task.completed ? 100 : 0;
return (
<div
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border transition-all overflow-hidden",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: isDynamic
? "bg-amber-500/5 border-amber-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Top row on mobile: Status + Task info */}
<div className="flex items-start sm:items-center gap-3 sm:contents">
{/* Status indicator */}
<div className={cn(
"size-8 sm:size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: isDynamic
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-4 sm:size-5" />
) : isDynamic ? (
<AlertCircle className="size-4 sm:size-5" />
) : task.required > 1 ? (
<span className="text-xs sm:text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-base sm:text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mb-0.5 sm:mb-1">
<h4 className={cn(
"font-medium text-sm sm:text-base break-words",
task.completed && "text-emerald-600 dark:text-emerald-400",
isDynamic && !task.completed && "text-amber-600 dark:text-amber-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs shrink-0">
Complete
</Badge>
)}
{isDynamic && !task.completed && (
<Badge variant="secondary" className="bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs shrink-0">
Live
</Badge>
)}
</div>
<p className="text-xs sm:text-sm text-muted-foreground break-words">
{task.description}
</p>
{/* Progress bar for multi-step tasks (not for dynamic stat tasks) */}
{task.required > 1 && !task.completed && !isDynamic && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
{/* Dynamic task hint */}
{isDynamic && !task.completed && (
<p className="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">
Lowest stat: {task.current}% (need {task.required}%+)
</p>
)}
</div>
</div>
{/* Action button - full width on mobile when present */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2 w-full sm:w-auto mt-1 sm:mt-0"
>
<span className="truncate">{task.actionLabel}</span>
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
</Button>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function TasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onComplete,
isCompleting = false,
emoji,
title,
description,
completeLabel,
completingLabel,
completeEmoji,
}: TasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent overflow-hidden">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-start sm:items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<span className="text-xl sm:text-2xl shrink-0">{emoji}</span>
<span className="break-words">{title}</span>
</CardTitle>
<CardDescription className="text-xs sm:text-sm break-words">
{description}
</CardDescription>
</div>
<Badge variant="outline" className="text-sm sm:text-base px-2 sm:px-3 py-0.5 sm:py-1 shrink-0">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-3 sm:mt-4">
<div className="flex items-center justify-between text-xs sm:text-sm mb-2">
<span className="text-muted-foreground">Overall progress</span>
<span className="font-medium">{overallProgress}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
</CardHeader>
<CardContent className="space-y-2 sm:space-y-3 px-3 sm:px-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{tasks.map(task => (
<TaskRow
key={task.id}
task={task}
onOpenPostModal={onOpenPostModal}
/>
))}
{/* Complete button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<Button
onClick={onComplete}
disabled={isCompleting}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-xl">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,232 @@
// src/blobbi/actions/hooks/useActiveTaskProcess.ts
/**
* Central abstraction for the active task process (hatch or evolve).
*
* This hook consolidates all scattered if/else logic for determining:
* - Which process is active (incubating vs evolving)
* - Which tasks to use (hatch vs evolve)
* - Thresholds and configuration
* - Badge-related computed values
*
* ARCHITECTURE RULES:
* - Computed tasks remain the source of truth
* - Tags are cache only for PERSISTENT tasks
* - Dynamic tasks are NEVER persisted
* - Badge counts ALL incomplete tasks (persistent + dynamic)
*/
import { useMemo } from 'react';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
import type { EvolveTasksResult } from './useEvolveTasks';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The type of task process currently active */
export type TaskProcessType = 'hatch' | 'evolve' | null;
/**
* Configuration for the active task process.
* This provides a unified interface regardless of whether
* the process is hatch or evolve.
*/
export interface TaskProcessConfig {
/** The type of process ('hatch' | 'evolve' | null) */
type: TaskProcessType;
/** Whether there is an active task process */
isActive: boolean;
/** Required interactions threshold for the current process */
interactionThreshold: number;
}
/**
* Result of the active task process hook.
* Provides unified access to all task-related state.
*/
export interface ActiveTaskProcessResult {
/** Configuration for the current process */
config: TaskProcessConfig;
/** All tasks for the current process (empty if no active process) */
tasks: HatchTask[];
/** Whether tasks are still loading */
isLoading: boolean;
/** Whether all tasks (persistent + dynamic) are complete */
allCompleted: boolean;
/** Whether all persistent tasks are complete */
persistentTasksComplete: boolean;
/** Whether the dynamic task is complete */
dynamicTaskComplete: boolean;
/** Refetch function for current tasks */
refetch: () => void;
// ─── Badge-related computed values ───
/**
* Count of ALL remaining incomplete tasks (persistent + dynamic).
* This is used for the badge display.
* Dynamic tasks ARE counted here but are NEVER synced to tags.
*/
remainingTasksCount: number;
/**
* Only persistent tasks that are incomplete.
* Used for sync logic - dynamic tasks must NEVER be synced.
*/
incompletePersistentTasks: HatchTask[];
/**
* Only persistent tasks that are complete.
* Used for sync logic.
*/
completedPersistentTasks: HatchTask[];
/**
* Stable string key of completed persistent task IDs.
* Used for sync anti-loop protection.
*/
completedPersistentTaskIds: string;
/**
* Tasks to sync (persistent only, with completion status).
* Dynamic tasks are excluded.
*/
tasksToSync: Array<{ taskId: string; completed: boolean }>;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Filter tasks to only persistent tasks.
* Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
/**
* Filter tasks to only dynamic tasks.
*/
export function filterDynamicTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'dynamic');
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook that provides a unified interface for the active task process.
*
* Usage:
* ```ts
* const taskProcess = useActiveTaskProcess(companion, hatchTasks, evolveTasks);
*
* // Access unified data
* taskProcess.config.type // 'hatch' | 'evolve' | null
* taskProcess.tasks // current tasks
* taskProcess.remainingTasksCount // for badge (includes dynamic)
* taskProcess.tasksToSync // for sync (excludes dynamic)
* ```
*/
export function useActiveTaskProcess(
companion: BlobbiCompanion | null,
hatchTasks: HatchTasksResult,
evolveTasks: EvolveTasksResult
): ActiveTaskProcessResult {
// Determine which process is active
const processType = useMemo((): TaskProcessType => {
if (!companion) return null;
if (companion.state === 'incubating') return 'hatch';
if (companion.state === 'evolving') return 'evolve';
return null;
}, [companion]);
// Build configuration
const config = useMemo((): TaskProcessConfig => {
const isActive = processType !== null;
const interactionThreshold = processType === 'hatch'
? HATCH_REQUIRED_INTERACTIONS
: processType === 'evolve'
? EVOLVE_REQUIRED_INTERACTIONS
: 0;
return {
type: processType,
isActive,
interactionThreshold,
};
}, [processType]);
// Get the active tasks result based on process type
const activeResult = useMemo(() => {
if (processType === 'hatch') return hatchTasks;
if (processType === 'evolve') return evolveTasks;
return null;
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = activeResult?.tasks ?? [];
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
const dynamicTaskComplete = activeResult?.dynamicTaskComplete ?? false;
const refetch = activeResult?.refetch ?? (() => {});
// Compute persistent task list (dynamic tasks computed for badge count directly from tasks array)
const persistentTasks = useMemo(() => filterPersistentTasks(tasks), [tasks]);
// Compute incomplete tasks (for badge - includes BOTH persistent and dynamic)
const remainingTasksCount = useMemo(() => {
// Count ALL incomplete tasks - persistent AND dynamic
// Dynamic tasks are included in badge count but NEVER synced to tags
return tasks.filter(t => !t.completed).length;
}, [tasks]);
// Compute persistent task lists for sync
const incompletePersistentTasks = useMemo(() =>
persistentTasks.filter(t => !t.completed),
[persistentTasks]
);
const completedPersistentTasks = useMemo(() =>
persistentTasks.filter(t => t.completed),
[persistentTasks]
);
// Compute stable string key for completed persistent tasks (anti-loop)
const completedPersistentTaskIds = useMemo(() => {
if (!completedPersistentTasks.length) return '';
return completedPersistentTasks
.map(t => t.id)
.sort()
.join(',');
}, [completedPersistentTasks]);
// Compute tasks to sync (persistent only)
// CRITICAL: Dynamic tasks must NEVER be included here
const tasksToSync = useMemo(() => {
if (!persistentTasks.length) return [];
return persistentTasks.map(t => ({
taskId: t.id,
completed: t.completed,
}));
}, [persistentTasks]);
return {
config,
tasks,
isLoading,
allCompleted,
persistentTasksComplete,
dynamicTaskComplete,
refetch,
remainingTasksCount,
incompletePersistentTasks,
completedPersistentTasks,
completedPersistentTaskIds,
tasksToSync,
};
}
@@ -0,0 +1,287 @@
// src/blobbi/actions/hooks/useAudioPlayback.ts
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* Audio playback state
* - idle: No audio loaded
* - loading: Audio is being loaded
* - playing: Audio is playing
* - paused: Audio is paused (can resume)
* - stopped: Audio was stopped (must reload to play again)
* - error: An error occurred
*/
export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
/**
* Audio playback error info
*/
export interface PlaybackError {
message: string;
code?: string;
}
/** Default volume level (0-1) */
const DEFAULT_VOLUME = 0.8;
/**
* Options for the useAudioPlayback hook
*/
export interface UseAudioPlaybackOptions {
/** Called when playback ends naturally */
onEnded?: () => void;
/** Called when an error occurs */
onError?: (error: PlaybackError) => void;
/** Initial volume level (0-1), defaults to 0.8 */
initialVolume?: number;
}
/**
* Return type for useAudioPlayback hook
*/
export interface UseAudioPlaybackReturn {
/** Current playback state */
state: PlaybackState;
/** Current error (if any) */
error: PlaybackError | null;
/** Current audio URL being played */
currentUrl: string | null;
/** Load and optionally start playing an audio URL */
load: (url: string, autoplay?: boolean) => void;
/** Play the current audio */
play: () => Promise<void>;
/** Pause the current audio */
pause: () => void;
/** Stop playback and reset */
stop: () => void;
/** Restart playback from the beginning */
restart: () => Promise<void>;
/** Toggle play/pause */
toggle: () => Promise<void>;
/** Whether audio is currently playing */
isPlaying: boolean;
/** Current volume level (0-1) */
volume: number;
/** Set volume level (0-1) */
setVolume: (volume: number) => void;
/** Cleanup function to release resources */
cleanup: () => void;
}
/**
* Reusable hook for audio playback.
* Handles Audio element lifecycle, error handling, and state management.
*/
export function useAudioPlayback(options: UseAudioPlaybackOptions = {}): UseAudioPlaybackReturn {
const { onEnded, onError, initialVolume = DEFAULT_VOLUME } = options;
const [state, setState] = useState<PlaybackState>('idle');
const [error, setError] = useState<PlaybackError | null>(null);
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
const [volume, setVolumeState] = useState<number>(initialVolume);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentUrlRef = useRef<string | null>(null);
const volumeRef = useRef<number>(initialVolume);
// Cleanup audio element
const cleanup = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
audioRef.current = null;
}
currentUrlRef.current = null;
setState('idle');
setCurrentUrl(null);
setError(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Load audio from URL
const load = useCallback((url: string, autoplay = false) => {
// If same URL, don't reload
if (currentUrlRef.current === url && audioRef.current) {
if (autoplay) {
audioRef.current.play().catch(() => {});
}
return;
}
// Cleanup previous audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
}
setState('loading');
setError(null);
setCurrentUrl(url);
currentUrlRef.current = url;
const audio = new Audio(url);
audio.volume = volumeRef.current; // Apply current volume to new audio
audioRef.current = audio;
audio.oncanplay = () => {
if (autoplay) {
audio.play().catch((err) => {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err.name,
};
setError(playbackError);
setState('error');
onError?.(playbackError);
});
} else {
setState('paused');
}
};
audio.onplaying = () => {
setState('playing');
};
audio.onpause = () => {
if (state === 'playing') {
setState('paused');
}
};
audio.onended = () => {
setState('paused');
onEnded?.();
};
audio.onerror = () => {
const playbackError: PlaybackError = {
message: 'Failed to load audio. The format may not be supported.',
code: 'MEDIA_ERR',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
};
// Start loading
audio.load();
}, [onEnded, onError, state]);
// Play current audio
const play = useCallback(async () => {
if (!audioRef.current) return;
try {
setError(null);
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Pause current audio
const pause = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
setState('paused');
}, []);
// Stop playback completely (requires reload to play again)
const stop = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
audioRef.current.currentTime = 0;
// Clear URL ref so next load() will actually reload
currentUrlRef.current = null;
setState('stopped');
}, []);
// Restart playback from the beginning
const restart = useCallback(async () => {
if (!audioRef.current) return;
audioRef.current.currentTime = 0;
try {
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Toggle play/pause
const toggle = useCallback(async () => {
if (state === 'playing') {
pause();
} else {
await play();
}
}, [state, play, pause]);
// Set volume (0-1)
const setVolume = useCallback((newVolume: number) => {
const clampedVolume = Math.max(0, Math.min(1, newVolume));
volumeRef.current = clampedVolume;
setVolumeState(clampedVolume);
if (audioRef.current) {
audioRef.current.volume = clampedVolume;
}
}, []);
return {
state,
error,
currentUrl,
load,
play,
pause,
stop,
restart,
toggle,
isPlaying: state === 'playing',
volume,
setVolume,
cleanup,
};
}
/**
* Get a user-friendly error message for playback errors
*/
function getPlaybackErrorMessage(err: unknown): string {
if (err instanceof Error) {
if (err.name === 'NotSupportedError') {
return 'This audio format is not supported by your browser.';
}
if (err.name === 'NotAllowedError') {
return 'Playback was blocked. Try interacting with the page first.';
}
return err.message;
}
return 'An unknown error occurred during playback.';
}
@@ -0,0 +1,189 @@
/**
* useBlobbiCareActivity - Hook for registering care activity and updating streaks
*
* This hook provides a centralized way to register care activity for a Blobbi companion.
* It handles:
* - Calculating streak updates based on the last activity day
* - Publishing updated Blobbi state to Nostr
* - Updating local cache
*
* Use this hook whenever care activity should count toward the streak:
* - Opening the Blobbi page (page check-in)
* - Performing care actions (feed, clean, play, etc.)
* - Any other care interaction
*
* The streak only increments once per calendar day, regardless of how many
* activities are performed.
*/
import { useCallback, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
export interface CareActivityResult {
/** Whether the streak was updated */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** Description of what happened */
action: StreakUpdateResult['action'];
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to register care activity and update streaks.
*
* Returns a function to register activity and a mutation for the actual update.
* The register function is idempotent - calling it multiple times on the same day
* will only update once.
*/
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiCareActivityParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
// Track if we've already registered activity this session to avoid duplicate calls
// This is a performance optimization - the actual idempotency is handled by day comparison
const lastRegisteredDay = useRef<string | null>(null);
const mutation = useMutation({
mutationFn: async (): Promise<CareActivityResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to register care activity');
}
if (!companion) {
throw new Error('No companion available');
}
const now = new Date();
// Calculate what the streak update should be
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
// If no update needed (same day), return early without publishing
if (!result.wasUpdated) {
return {
wasUpdated: false,
newStreak: result.newStreak,
action: result.action,
};
}
// Get the tag updates
const streakUpdates = getStreakTagUpdates(companion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
tags: updatedTags,
});
// Update local cache
updateCompanionEvent(event);
// Update session tracker
lastRegisteredDay.current = result.newLastDay;
// Log for debugging (dev only)
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: companion.careStreak,
newStreak: result.newStreak,
lastDay: companion.careStreakLastDay,
newDay: result.newLastDay,
});
}
return {
wasUpdated: true,
newStreak: result.newStreak,
action: result.action,
};
},
onSuccess: (result) => {
if (result.wasUpdated) {
invalidateCompanion();
}
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
});
/**
* Register care activity. Call this when care-related activity happens.
* Safe to call multiple times - only updates streak once per day.
*
* @returns Promise with the result of the activity registration
*/
const registerCareActivity = useCallback(async (): Promise<CareActivityResult | null> => {
if (!companion) {
return null;
}
// Quick check if we've already registered for this companion's last day (session cache)
// This is an optimization to avoid unnecessary mutation calls
if (lastRegisteredDay.current === companion.careStreakLastDay) {
// Already processed this day in this session, skip
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
return mutation.mutateAsync();
}, [companion, mutation]);
return {
/** Register care activity - call when page opens or care action happens */
registerCareActivity,
/** Whether an update is currently in progress */
isUpdating: mutation.isPending,
/** The last update result */
lastResult: mutation.data,
/** Any error from the last update attempt */
error: mutation.error,
};
}
@@ -0,0 +1,212 @@
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
import {
clampStat,
applyStat,
DIRECT_ACTION_METADATA,
incrementInteractionTaskTags,
type DirectAction,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Configuration for direct action happiness effects.
* These are the happiness deltas for each direct action.
*/
export const DIRECT_ACTION_HAPPINESS_EFFECTS: Record<DirectAction, number> = {
play_music: 15,
sing: 20,
};
/**
* Request payload for executing a direct action
*/
export interface DirectActionRequest {
action: DirectAction;
}
/**
* Result of executing a direct action
*/
export interface DirectActionResult {
action: DirectAction;
happinessChange: number;
}
/**
* Parameters for the useBlobbiDirectAction hook
*/
export interface UseBlobbiDirectActionParams {
companion: BlobbiCompanion | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration happened) */
invalidateProfile: () => void;
}
/**
* Hook to execute a direct action on a Blobbi companion.
* Direct actions (play_music, sing) don't consume inventory items.
* They directly affect happiness stat.
*
* This hook:
* 1. Validates the companion exists
* 2. Ensures canonical format before action
* 3. Applies accumulated decay
* 4. Applies happiness boost
* 5. Updates Blobbi state (kind 31124)
* 6. Invalidates relevant queries
*/
export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to perform actions');
}
if (!companion) {
throw new Error('No companion selected');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
const statsAfterDecay = decayResult.stats;
// ─── Apply Happiness Effect ───
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
// Build stats update
const isEgg = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {
happiness: newHappiness.toString(),
health: statsAfterDecay.health.toString(),
hygiene: statsAfterDecay.hygiene.toString(),
};
if (isEgg) {
// Eggs have fixed hunger and energy
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
statsUpdate.hunger = clampStat(statsAfterDecay.hunger).toString();
statsUpdate.energy = clampStat(statsAfterDecay.energy).toString();
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
action,
happinessChange: happinessDelta,
};
},
onSuccess: ({ action, happinessChange }) => {
const actionMeta = DIRECT_ACTION_METADATA[action];
toast({
title: `${actionMeta.label} complete!`,
description: `Your Blobbi's happiness increased by ${happinessChange}!`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'sing') dailyActions.push('sing');
if (action === 'play_music') dailyActions.push('play_music');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Action failed',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,939 @@
// src/blobbi/actions/hooks/useBlobbiIncubation.ts
/**
* Hooks for Blobbi incubation task system.
*
* When a user starts incubation:
* 1. Apply accumulated decay from last_decay_at to now
* 2. Set state to 'incubating'
* 3. Add state_started_at timestamp
* 4. Update last_decay_at to the same timestamp
* 5. Clear any previous task progress
*
* Tasks are computed from Nostr events with created_at >= state_started_at
*/
import { useMutation } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mode for starting incubation.
* This makes the intent explicit rather than auto-detecting behavior.
*/
export type StartIncubationMode =
| 'start' // Normal start (no other Blobbi incubating)
| 'restart' // Restart same Blobbi (already incubating)
| 'switch'; // Switch from another incubating Blobbi
/**
* Request to start incubation with explicit mode.
*/
export interface StartIncubationRequest {
/** Explicit mode for this operation */
mode: StartIncubationMode;
/** The d-tag of the other Blobbi to stop (required when mode === 'switch') */
stopOtherD?: string;
}
/**
* Parameters for start incubation hook.
*/
export interface UseStartIncubationParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of starting incubation.
*/
export interface StartIncubationResult {
/** The Blobbi's name */
name: string;
/** Timestamp when incubation started */
stateStartedAt: number;
/** Mode that was used */
mode: StartIncubationMode;
/** Name of other Blobbi that was stopped (if mode === 'switch') */
stoppedOtherName?: string;
}
// ─── Start Incubation Hook ────────────────────────────────────────────────────
/**
* Hook to start the incubation process for an egg.
*
* This sets the Blobbi state to 'incubating' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* IMPORTANT: The mode must be explicitly specified by the caller (UI).
* This hook does NOT auto-detect whether to switch or restart.
* The UI dialog determines the mode and passes it explicitly.
*
* Modes:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating), resets task progress
* - 'switch': Stop another Blobbi first, then start this one
*
* Requirements:
* - Blobbi must be in egg stage
* - User must be logged in
*/
export function useStartIncubation({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (request: StartIncubationRequest): Promise<StartIncubationResult> => {
const { mode, stopOtherD } = request;
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be incubated');
}
// Validate switch mode requires stopOtherD
if (mode === 'switch' && !stopOtherD) {
throw new Error('Switch mode requires stopOtherD parameter');
}
let stoppedOtherName: string | undefined;
// ─── Stop Other Incubating Blobbi (switch mode only) ───
if (mode === 'switch' && stopOtherD) {
// Fetch the current event for the other Blobbi
const [otherEvent] = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [stopOtherD],
limit: 1,
}]);
if (otherEvent) {
// Get name from the event for the result
const nameTag = otherEvent.tags.find(t => t[0] === 'name');
stoppedOtherName = nameTag?.[1] ?? stopOtherD;
// Stop the other Blobbi's incubation
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
// Parse stats from the event
const getTagValue = (tags: string[][], name: string): number =>
parseInt(tags.find(t => t[0] === name)?.[1] ?? '50', 10);
const otherStats = {
hunger: getTagValue(otherEvent.tags, 'hunger'),
happiness: getTagValue(otherEvent.tags, 'happiness'),
health: getTagValue(otherEvent.tags, 'health'),
hygiene: getTagValue(otherEvent.tags, 'hygiene'),
energy: getTagValue(otherEvent.tags, 'energy'),
};
const otherLastDecayAt = getTagValue(otherEvent.tags, 'last_decay_at') || now;
// Apply decay to the other Blobbi
const otherDecayResult = applyBlobbiDecay({
stage: 'egg',
state: 'incubating',
stats: otherStats,
lastDecayAt: otherLastDecayAt,
now,
});
// Remove task tags and state_started_at from the other Blobbi
const otherCleanedTags = otherEvent.tags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
const otherNewTags = updateBlobbiTags(otherCleanedTags, {
health: otherDecayResult.stats.health.toString(),
hygiene: otherDecayResult.stats.hygiene.toString(),
happiness: otherDecayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// Publish the stop event for the other Blobbi
const stopEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: otherEvent.content,
tags: otherNewTags,
});
// Update the cache for the stopped Blobbi
updateCompanionEvent(stopEvent);
}
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for incubation');
}
// ─── Apply Accumulated Decay ───
// CRITICAL: Apply decay from last_decay_at to now before changing state
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh (for all modes)
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'incubating',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
stateStartedAt: now,
mode,
stoppedOtherName,
};
},
onSuccess: ({ name, mode, stoppedOtherName }) => {
if (mode === 'switch' && stoppedOtherName) {
toast({
title: 'Switched incubation!',
description: `Stopped ${stoppedOtherName}, now incubating ${name}.`,
});
} else if (mode === 'restart') {
toast({
title: 'Incubation restarted!',
description: `${name}'s task progress has been reset.`,
});
} else {
toast({
title: 'Incubation started!',
description: `${name} is now incubating. Complete the tasks to hatch!`,
});
}
},
onError: (error: Error) => {
toast({
title: 'Failed to start incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Incubation Hook ─────────────────────────────────────────────────────
/**
* Parameters for stop incubation hook.
*/
export interface UseStopIncubationParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of stopping incubation.
*/
export interface StopIncubationResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the incubation process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart incubation later, but will need to complete tasks again.
*
* When stopping incubation:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in incubating state
* - User must be logged in
*/
export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopIncubationResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'incubating') {
throw new Error('This Blobbi is not incubating');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Incubation stopped',
description: `${name} is no longer incubating. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Start Evolution Hook ─────────────────────────────────────────────────────
/**
* Parameters for start evolution hook.
*/
export interface UseStartEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of starting evolution.
*/
export interface StartEvolutionResult {
/** The Blobbi's name */
name: string;
/** Timestamp when evolution started */
stateStartedAt: number;
}
/**
* Hook to start the evolution process for a baby Blobbi.
*
* This sets the Blobbi state to 'evolving' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* Requirements:
* - Blobbi must be in baby stage
* - Blobbi must not already be evolving
* - User must be logged in
*/
export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StartEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.stage !== 'baby') {
throw new Error('Only baby Blobbis can evolve');
}
if (companion.state === 'evolving') {
throw new Error('This Blobbi is already evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'evolving',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
stateStartedAt: now,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution started!',
description: `${name} is now working towards evolution. Complete the tasks to evolve!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to start evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Evolution Hook ──────────────────────────────────────────────────────
/**
* Parameters for stop evolution hook.
*/
export interface UseStopEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of stopping evolution.
*/
export interface StopEvolutionResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the evolution process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart evolution later, but will need to complete tasks again.
*
* When stopping evolution:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in evolving state
* - User must be logged in
*/
export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'evolving') {
throw new Error('This Blobbi is not evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution stopped',
description: `${name} is no longer evolving. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Sync Task Completions Hook ───────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_TASK_SYNC = import.meta.env.DEV;
/**
* Parameters for syncing task completions (works for both hatch and evolve).
*/
export interface UseSyncTaskCompletionsParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
/**
* Task completions to sync (from useHatchTasks or useEvolveTasks).
*/
export interface TaskCompletionToSync {
taskId: string;
completed: boolean;
}
/**
* Result of sync operation.
*/
export interface SyncTaskCompletionsResult {
/** Task IDs that were synced (empty if nothing needed) */
synced: string[];
/** Whether sync was skipped (no diff) */
skipped: boolean;
/** Reason for skip (for debugging) */
skipReason?: string;
}
/**
* Hook to sync persistent task completions to kind 31124 tags.
* Works for both hatch (incubating) and evolve (evolving) processes.
*
* CRITICAL: This is a cache-only sync. It must be:
* 1. Fully idempotent - calling multiple times with same data = no-op
* 2. Diff-based - only publish when tags would actually change
* 3. Safe - no last_interaction update (this is cache sync, not user action)
* 4. Only sync PERSISTENT tasks - dynamic tasks must NEVER be synced
*
* Source of truth = computed task state from Nostr events.
* Tags = cache layer for faster access.
*/
export function useSyncTaskCompletions({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseSyncTaskCompletionsParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (tasksToSync: TaskCompletionToSync[]): Promise<SyncTaskCompletionsResult> => {
// ─── Early Guards ───
if (!user?.pubkey) {
return { synced: [], skipped: true, skipReason: 'no_user' };
}
if (!companion) {
return { synced: [], skipped: true, skipReason: 'no_companion' };
}
// Must be in an active task process (incubating or evolving)
if (companion.state !== 'incubating' && companion.state !== 'evolving') {
return { synced: [], skipped: true, skipReason: 'not_in_task_process' };
}
// ─── Compute Diff ───
// Get cached completions from companion.tasksCompleted (parsed from tags)
const cachedCompletions = new Set(companion.tasksCompleted);
// Get computed completions from tasks (works for both hatch and evolve)
const computedCompletions = tasksToSync
.filter(t => t.completed)
.map(t => t.taskId);
// Find tasks that are computed as complete but NOT in cache
const missingFromCache = computedCompletions.filter(id => !cachedCompletions.has(id));
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Diff check:', {
cachedCompletions: Array.from(cachedCompletions),
computedCompletions,
missingFromCache,
});
}
// If no diff, skip entirely
if (missingFromCache.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: no diff between computed and cached');
}
return { synced: [], skipped: true, skipReason: 'no_diff' };
}
// ─── Ensure Canonical ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
return { synced: [], skipped: true, skipReason: 'canonical_failed' };
}
// ─── Build Updated Tags ───
// Re-check against canonical.allTags (may have updated since companion was parsed)
const existingCompletionTags = new Set(
canonical.allTags
.filter(tag => tag[0] === 'task_completed')
.map(tag => tag[1])
);
// Filter to only truly missing tags
const tagsToAdd = missingFromCache.filter(id => !existingCompletionTags.has(id));
if (tagsToAdd.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: all tags already exist in canonical');
}
return { synced: [], skipped: true, skipReason: 'tags_already_exist' };
}
// Add only the missing task_completed tags
// CRITICAL: Do NOT update last_interaction - this is cache sync, not user action
const updatedTags = [
...canonical.allTags,
...tagsToAdd.map(id => ['task_completed', id]),
];
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Publishing:', {
tagsToAdd,
totalTags: updatedTags.length,
});
}
// ─── Publish ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: updatedTags,
});
updateCompanionEvent(event);
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Published successfully:', tagsToAdd);
}
return { synced: tagsToAdd, skipped: false };
},
});
}
@@ -0,0 +1,408 @@
// src/blobbi/actions/hooks/useBlobbiStageTransition.ts
/**
* Hooks for Blobbi stage transitions (hatch, evolve).
*
* Both transitions follow the same decay pattern:
* 1. Apply accumulated decay from `last_decay_at` to `now`
* 2. Use decayed stats as the source of truth for the transition
* 3. Publish new event with decayed stats + new stage
* 4. Reset `last_decay_at` to current timestamp
*
* @see docs/blobbi/decay-system.md
*/
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
DEFAULT_EGG_STATS,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/lib/blobbi-tag-schema';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
// ─── Content Helpers ──────────────────────────────────────────────────────────
/**
* Generate the content string for a Blobbi at a given stage.
* Format: "{name} is a {stage} Blobbi."
*
* Uses correct grammar: "an egg" vs "a baby/adult"
*/
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of ensuring canonical companion before action.
* This is the same interface used by useBlobbiUseInventoryItem.
*/
export interface CanonicalActionResult {
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/lib/blobbi').StorageItem[];
}
/**
* Parameters for stage transition hooks.
*/
export interface UseBlobbiStageTransitionParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of a stage transition.
*/
export interface StageTransitionResult {
/** Previous stage before transition */
previousStage: BlobbiStage;
/** New stage after transition */
newStage: BlobbiStage;
/** The Blobbi's name */
name: string;
/** Stats after decay was applied (before any transition bonuses) */
decayedStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
}
// ─── Hatch Hook ───────────────────────────────────────────────────────────────
/**
* Hook to hatch an egg into a baby Blobbi.
*
* Transition: egg -> baby
*
* Requirements:
* - Blobbi must be in egg stage
* - Applies accumulated decay before transition
* - Resets stats to healthy baby defaults (inherits health from egg)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiHatch({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to hatch');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be hatched');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for hatching');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Calculate Baby Stats ───
// Baby inherits the decayed health from the egg
// Other stats start fresh at 100 for the new life stage
const babyStats = {
hunger: DEFAULT_EGG_STATS.hunger, // Start full
happiness: DEFAULT_EGG_STATS.happiness, // Start happy
health: decayResult.stats.health, // Inherit from egg
hygiene: DEFAULT_EGG_STATS.hygiene, // Start clean
energy: DEFAULT_EGG_STATS.energy, // Start energized
};
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Build the updated tags using the central merge function
// Get streak updates (hatching counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'baby',
state: 'active', // Newly hatched babies are awake
hunger: babyStats.hunger.toString(),
happiness: babyStats.happiness.toString(),
health: babyStats.health.toString(),
hygiene: babyStats.hygiene.toString(),
energy: babyStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Hatch] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Baby Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'egg',
newStage: 'baby',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Your egg hatched!',
description: `${name} is now a baby Blobbi! Take good care of them.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to hatch',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Evolve Hook ──────────────────────────────────────────────────────────────
/**
* Hook to evolve a baby Blobbi into an adult.
*
* Transition: baby -> adult
*
* Requirements:
* - Blobbi must be in baby stage
* - Applies accumulated decay before transition
* - Preserves all stats (decay already applied)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiEvolve({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to evolve');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'baby') {
if (companion.stage === 'egg') {
throw new Error('Eggs must hatch before they can evolve');
}
if (companion.stage === 'adult') {
throw new Error('This Blobbi is already fully evolved');
}
throw new Error('Only baby Blobbis can evolve');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Adult Stats ───
// Adult inherits all decayed stats from baby
// No stat reset - evolution preserves current condition
const adultStats = decayResult.stats;
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Get streak updates (evolving counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// Build the updated tags using the central merge function
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'adult',
state: 'active', // Evolution completes with active state
hunger: adultStats.hunger.toString(),
happiness: adultStats.happiness.toString(),
health: adultStats.health.toString(),
hygiene: adultStats.hygiene.toString(),
energy: adultStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Evolve] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Evolve] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Adult Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'baby',
newStage: 'adult',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution complete!',
description: `${name} has evolved into an adult Blobbi!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to evolve',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,359 @@
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
getStageRestrictionMessage,
clampStat,
applyStat,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
incrementInteractionTaskTags,
type InventoryAction,
ACTION_METADATA,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an inventory item
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an inventory item
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
statsChanged: Record<string, number>;
}
/**
* Parameters for the useBlobbiUseInventoryItem hook
*/
export interface UseBlobbiUseInventoryItemParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration (use instead of profile.allTags) */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
profileStorage: import('@/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Update profile event in local cache */
updateProfileEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an inventory item on a Blobbi companion.
*
* This hook:
* 1. Validates the companion stage (eggs can't use items)
* 2. Validates the item exists in storage
* 3. Ensures canonical format before action
* 4. Applies item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
* 6. Decrements item from profile storage (kind 11125)
* 7. Invalidates relevant queries
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
throw new Error(message ?? 'This companion cannot use this item');
}
// Validate item exists in shop catalog
const shopItem = getShopItemById(itemId);
if (!shopItem) {
throw new Error('Item not found in catalog');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
}
// For eggs, validate that items have applicable effects
const isEgg = companion.stage === 'egg';
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
throw new Error('This medicine has no effect on eggs');
}
if (isEgg && action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect)) {
throw new Error('This item has no cleaning effect on eggs');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any user interaction updates stats.
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects ───
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
// won't give more than 100 health total.
// Use canonical companion stage for egg checks
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
// Eggs use the 3-stat model: health, hygiene, happiness
// Medicine with health effect directly affects the egg's health stat
// hunger and energy remain fixed at 100 for eggs
const healthDelta = shopItem.effect.health ?? 0;
// Apply health effect N times in sequence with clamping at each step
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = applyStat(currentHealth, healthDelta);
}
statsUpdate.health = currentHealth.toString();
// Track total actual change (may be less than healthDelta * quantity due to clamping)
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
// Apply decayed values for other egg stats
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
// Egg clean/hygiene handling:
// Hygiene items affect the egg's hygiene stat
// Some hygiene items also give happiness (e.g., bubble bath)
// hunger and energy remain fixed at 100 for eggs
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
// Apply effects N times in sequence
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
}
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
statsUpdate.happiness = currentHappiness.toString();
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (totalHappinessChange !== 0) {
statsChanged.happiness = totalHappinessChange;
}
// Apply decayed health
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
// Apply item effects N times in sequence ON TOP of decayed stats
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
}
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
statsUpdate.energy = clampStat(currentStats.energy).toString();
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
statsUpdate.health = clampStat(currentStats.health).toString();
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
// instead of profile.storage/profile.allTags to avoid restoring
// stale/legacy values after migration
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
invalidateProfile();
return {
itemName: shopItem.name,
action,
quantity,
statsChanged,
};
},
onSuccess: ({ itemName, action, quantity }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName}${quantityText} on your Blobbi.`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Failed to use item',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,242 @@
/**
* useClaimMissionReward - Hook for claiming daily mission rewards
*
* Handles:
* - Persisting coin rewards to kind 11125 Blobbonaut profile
* - Updating localStorage mission state
* - Idempotent claiming (prevents double-credit)
* - Optimistic cache updates
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbonautProfile } from '@/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
} from '@/lib/blobbi';
import {
type DailyMissionsState,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ClaimMissionRequest {
missionId: string;
}
/** Special ID for claiming the bonus mission */
export const BONUS_MISSION_ID = 'bonus_daily_complete';
export interface ClaimMissionResult {
missionId: string;
coinsEarned: number;
newTotalCoins: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useClaimMissionReward] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to claim daily mission rewards.
*
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
* ensuring rewards are stored on-chain rather than just in localStorage.
*
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
* @param updateProfileEvent - Callback to update the profile in the query cache
*/
export function useClaimMissionReward(
currentProfile: BlobbonautProfile | null,
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ missionId }: ClaimMissionRequest): Promise<ClaimMissionResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to claim rewards');
}
if (!currentProfile) {
throw new Error('Profile not found');
}
// Read current missions state from localStorage
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
}
// Handle bonus mission claim
if (missionId === BONUS_MISSION_ID) {
// Check if bonus is available
if (!isBonusMissionAvailable(missionsState!)) {
throw new Error('Bonus mission not available yet');
}
// Check if already claimed
if (isBonusMissionClaimed(missionsState!)) {
throw new Error('Bonus reward already claimed');
}
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
// Publish updated profile event
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache
updateProfileEvent(event);
// Update localStorage to mark bonus as claimed
const updatedState: DailyMissionsState = {
...missionsState!,
bonusClaimed: true,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true, isBonus: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
}
// Handle regular mission claim
const mission = missionsState!.missions.find(m => m.id === missionId);
if (!mission) {
throw new Error('Mission not found');
}
// Check if already claimed (idempotency check)
if (mission.claimed) {
throw new Error('Reward already claimed');
}
// Check if mission is completed
if (!mission.completed) {
throw new Error('Mission not completed yet');
}
const coinsToAdd = mission.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
// Publish updated profile event to kind 11125
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache optimistically
updateProfileEvent(event);
// Now update localStorage to mark mission as claimed
const updatedMissions = missionsState!.missions.map(m =>
m.id === missionId ? { ...m, claimed: true } : m
);
const updatedState: DailyMissionsState = {
...missionsState!,
missions: updatedMissions,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
},
onSuccess: ({ coinsEarned }) => {
// Invalidate profile query to ensure fresh data
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
}
// Show success toast
toast({
title: 'Reward Claimed!',
description: `You earned ${coinsEarned} coins.`,
});
},
onError: (error: Error) => {
// Don't show error for already claimed (user might have double-clicked)
if (error.message === 'Reward already claimed' || error.message === 'Bonus reward already claimed') {
return;
}
toast({
title: 'Failed to Claim Reward',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,201 @@
/**
* useDailyMissions - Hook for managing Blobbi daily missions
*
* Provides:
* - Daily mission state management with localStorage persistence
* - Automatic daily reset
* - Progress tracking functions
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
* - Stage-based filtering (only shows missions user can complete)
* - Bonus mission tracking
*
* Note: Reward claiming should be done via useClaimMissionReward hook,
* which persists coins to the kind 11125 Blobbonaut profile.
*/
import { useMemo, useEffect, useState, useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
areAllMissionsCompleted,
areAllMissionsClaimed,
getTotalPotentialReward,
getTodayClaimedReward,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
getRerollsRemaining,
MAX_DAILY_REROLLS,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseDailyMissionsOptions {
/** Available Blobbi stages the user has (filters eligible missions) */
availableStages?: BlobbiStage[];
}
export interface UseDailyMissionsResult {
/** Current daily missions state */
missions: DailyMission[];
/** Whether all missions are completed */
allCompleted: boolean;
/** Whether all missions are claimed */
allClaimed: boolean;
/** Total potential reward for today (including bonus if available) */
totalPotentialReward: number;
/** Total claimed reward for today */
todayClaimedReward: number;
/** Lifetime total coins earned from daily missions */
lifetimeCoinsEarned: number;
/** Whether the bonus mission is available (all regular missions completed) */
bonusAvailable: boolean;
/** Whether the bonus mission has been claimed */
bonusClaimed: boolean;
/** Bonus mission reward amount */
bonusReward: number;
/** Whether user has no eligible missions (e.g., only eggs) */
noMissionsAvailable: boolean;
/** Number of rerolls remaining for today */
rerollsRemaining: number;
/** Maximum rerolls allowed per day */
maxRerolls: number;
/** Force refresh missions (for testing or manual reset) */
forceReset: () => void;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useDailyMissions] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
const { availableStages } = options;
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
// Read state directly from localStorage, with a version counter to trigger re-reads
const [version, setVersion] = useState(0);
// Read from localStorage on every render when version changes
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
const state = useMemo(() => readMissionsState(), [version]);
// Wrapper to write state and update version
const setState = useCallback((newState: DailyMissionsState) => {
writeMissionsState(newState);
setVersion((v) => v + 1);
}, []);
// Listen for external updates from mutations (reroll, claim, progress tracking)
// This re-reads localStorage when other hooks modify it directly
useEffect(() => {
const handleExternalUpdate = () => {
// Bump version to trigger a re-read from localStorage
setVersion((v) => v + 1);
};
window.addEventListener('daily-missions-updated', handleExternalUpdate);
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
}, []);
// Stable key for availableStages to use in dependencies
const stagesKey = availableStages?.sort().join(',') ?? '';
// Ensure we have valid state for today
const currentState = useMemo(() => {
// Check if we need to reset for a new day
if (needsDailyReset(state)) {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
// Persist the reset state (this will trigger version bump via setState)
writeMissionsState(newState);
return newState;
}
// Migration: ensure rerollsRemaining is set for old state
if (state && state.rerollsRemaining === undefined) {
const migratedState = {
...state,
rerollsRemaining: MAX_DAILY_REROLLS,
};
writeMissionsState(migratedState);
return migratedState;
}
return state!;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, pubkey, stagesKey]);
// Force reset missions (for testing)
const forceReset = () => {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
setState(newState);
};
// Computed values
const missions = currentState.missions;
const allCompleted = areAllMissionsCompleted(currentState);
const allClaimed = areAllMissionsClaimed(currentState);
const bonusAvailable = isBonusMissionAvailable(currentState);
const bonusClaimed = isBonusMissionClaimed(currentState);
const bonusReward = BONUS_MISSION_DEFINITION.reward;
const noMissionsAvailable = missions.length === 0;
const rerollsRemaining = getRerollsRemaining(currentState);
const maxRerolls = MAX_DAILY_REROLLS;
// Total potential includes bonus if regular missions exist
const basePotentialReward = getTotalPotentialReward(currentState);
const totalPotentialReward = missions.length > 0
? basePotentialReward + bonusReward
: 0;
// Today's claimed includes bonus if claimed
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
return {
missions,
allCompleted,
allClaimed,
totalPotentialReward,
todayClaimedReward,
lifetimeCoinsEarned,
bonusAvailable,
bonusClaimed,
bonusReward,
noMissionsAvailable,
rerollsRemaining,
maxRerolls,
forceReset,
};
}
+362
View File
@@ -0,0 +1,362 @@
// src/blobbi/actions/hooks/useEvolveTasks.ts
/**
* Hook to compute evolve task progress from Nostr events and current stats.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/lib/blobbi';
import {
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
KIND_PROFILE_METADATA,
KIND_SHORT_TEXT_NOTE,
BLOBBI_POST_REQUIRED_HASHTAGS,
sanitizeToHashtag,
type HatchTask,
type TaskType,
} from './useHatchTasks';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for wall edit events */
export const KIND_WALL_EDIT = 16769;
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
/** Required color moments for evolve task */
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
/** Required posts for evolve task (lighter than hatch - just 1 evolve-specific post) */
export const EVOLVE_REQUIRED_POSTS = 1;
/** Required interactions for evolve task */
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
/** Prefix text for Blobbi evolve post */
export const BLOBBI_EVOLVE_POST_PREFIX = 'Hello Nostr! Posting to evolve';
/** Stat threshold for evolve dynamic task (all stats >= 80) */
export const EVOLVE_STAT_THRESHOLD = 80;
// ─── Types ────────────────────────────────────────────────────────────────────
// Re-export task types for convenience
export type { HatchTask as EvolveTask, TaskType };
/**
* Result of computing evolve tasks.
*/
export interface EvolveTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to evolve */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Check if a post is a valid Blobbi evolve post.
* Must contain the evolve prefix and all required hashtags including the Blobbi name.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
*/
export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with evolve prefix
if (!event.content.startsWith(BLOBBI_EVOLVE_POST_PREFIX)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (!hasRequiredHashtags) {
return false;
}
// Blobbi name hashtag must also be present
const blobbiHashtag = sanitizeToHashtag(blobbiName);
return hashtags.includes(blobbiHashtag);
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute evolve task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create 3 Themes (kind 36767)
* 2. Create 3 Color Moments (kind 3367)
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
* 4. Interact 21 times (tracked via companion.tasks cache)
* 5. Edit Wall once (kind 16769)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 6. Maintain All Stats >= 80
*
* @param companion - The Blobbi companion (must be in evolving state)
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useEvolveTasks(
companion: BlobbiCompanion | null,
interactionCount?: number
): EvolveTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isEvolving = companion?.state === 'evolving';
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['evolve-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid evolve posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Only need 1 valid evolve post
},
// Wall edits after start
{
kinds: [KIND_WALL_EDIT],
authors: [pubkey],
since: stateStartedAt,
limit: 1, // Only need 1
},
// Profile metadata after start (for Blobbi shape check)
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
const wallEditEvents = events.filter(e =>
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
);
// Get latest profile after start
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents,
colorMomentEvents,
postEvents,
wallEditEvents,
profileAfter,
};
},
enabled: !!pubkey && !!stateStartedAt && isEvolving,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create 3 Themes (PERSISTENT)
const themeCount = data?.themeEvents?.length ?? 0;
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
tasks.push({
id: 'create_themes',
name: 'Create Themes',
description: `Create ${EVOLVE_REQUIRED_THEMES} custom themes`,
current: Math.min(themeCount, EVOLVE_REQUIRED_THEMES),
required: EVOLVE_REQUIRED_THEMES,
completed: themesCompleted,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// 2. Create 3 Color Moments (PERSISTENT)
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
tasks.push({
id: 'color_moments',
name: 'Color Moments',
description: `Share ${EVOLVE_REQUIRED_COLOR_MOMENTS} color moments on espy`,
current: Math.min(colorMomentCount, EVOLVE_REQUIRED_COLOR_MOMENTS),
required: EVOLVE_REQUIRED_COLOR_MOMENTS,
completed: colorMomentsCompleted,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create 1 Evolve Post (PERSISTENT) - lighter than hatch
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidEvolvePost(e, blobbiName)) ?? [];
const postCount = validPosts.length;
const postsCompleted = postCount >= EVOLVE_REQUIRED_POSTS;
tasks.push({
id: 'create_posts',
name: 'Share Evolution',
description: 'Post about your Blobbi evolving',
current: Math.min(postCount, EVOLVE_REQUIRED_POSTS),
required: EVOLVE_REQUIRED_POSTS,
completed: postsCompleted,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 4. Interact 21 times (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${EVOLVE_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, EVOLVE_REQUIRED_INTERACTIONS),
required: EVOLVE_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// 5. Edit Wall once (PERSISTENT)
const wallEditCount = data?.wallEditEvents?.length ?? 0;
const hasWallEdit = wallEditCount >= 1;
tasks.push({
id: 'edit_wall',
name: 'Edit Your Wall',
description: 'Customize your profile wall',
current: hasWallEdit ? 1 : 0,
required: 1,
completed: hasWallEdit,
type: 'persistent',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Wall',
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
// 7. Maintain All Stats >= 80
const stats = companion?.stats ?? {};
const hunger = stats.hunger ?? 0;
const happiness = stats.happiness ?? 0;
const health = stats.health ?? 0;
const hygiene = stats.hygiene ?? 0;
const energy = stats.energy ?? 0;
const statsOk =
hunger >= EVOLVE_STAT_THRESHOLD &&
happiness >= EVOLVE_STAT_THRESHOLD &&
health >= EVOLVE_STAT_THRESHOLD &&
hygiene >= EVOLVE_STAT_THRESHOLD &&
energy >= EVOLVE_STAT_THRESHOLD;
// Calculate minimum stat for progress display
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
tasks.push({
id: 'maintain_stats',
name: 'Peak Condition',
description: `Keep all stats above ${EVOLVE_STAT_THRESHOLD}`,
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
required: EVOLVE_STAT_THRESHOLD,
completed: statsOk,
type: 'dynamic', // CRITICAL: Never persist this task
// No action - just care for your Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Get the current interaction count for evolve from companion task cache.
*/
export function getEvolveInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
+386
View File
@@ -0,0 +1,386 @@
// src/blobbi/actions/hooks/useHatchTasks.ts
/**
* Hook to compute hatch task progress from Nostr events.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/lib/blobbi';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for theme definition events */
export const KIND_THEME_DEFINITION = 36767;
/** Kind for color moment events (espy.you) */
export const KIND_COLOR_MOMENT = 3367;
/** Kind for profile metadata */
export const KIND_PROFILE_METADATA = 0;
/** Kind for short text notes */
export const KIND_SHORT_TEXT_NOTE = 1;
/** Required interactions to complete the hatch interactions task */
export const HATCH_REQUIRED_INTERACTIONS = 7;
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
/** Prefix text for Blobbi hatch post */
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
/** Stat threshold for hatch dynamic task (health, hygiene, happiness >= 70) */
export const HATCH_STAT_THRESHOLD = 70;
// Legacy export for backwards compatibility
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
/**
* Sanitize a name into a valid hashtag format.
* Must match the implementation in BlobbiPostModal.tsx.
*/
export function sanitizeToHashtag(name: string): string {
return name
.toLowerCase()
// Remove emojis and special characters, keep letters, numbers, underscores
.replace(/[^\p{L}\p{N}_]/gu, '')
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
.replace(/^(\d)/, 'blobbi$1')
// Limit length
.slice(0, 30)
// Fallback if empty
|| 'myblobbi';
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Task type classification.
* - persistent: Based on Nostr events, can be cached in tags
* - dynamic: Based on current stats, NEVER stored in tags
*/
export type TaskType = 'persistent' | 'dynamic';
/**
* Individual task definition.
*/
export interface HatchTask {
id: string;
name: string;
description: string;
/** Current progress value */
current: number;
/** Required value for completion */
required: number;
/** Whether the task is complete */
completed: boolean;
/** Task type - persistent (event-based) or dynamic (stat-based) */
type: TaskType;
/** Action to perform (if applicable) */
action?: 'navigate' | 'open_modal' | 'external_link';
/** Target for the action */
actionTarget?: string;
/** Button label */
actionLabel?: string;
}
/**
* Result of computing hatch tasks.
*/
export interface HatchTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to hatch */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Check if a post is a valid Blobbi hatch post.
* Must contain the required prefix and all required hashtags including the Blobbi name.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
*/
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with prefix
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (!hasRequiredHashtags) {
return false;
}
// Blobbi name hashtag must also be present
const blobbiHashtag = sanitizeToHashtag(blobbiName);
return hashtags.includes(blobbiHashtag);
}
// Legacy function name for backwards compatibility
export const isValidBlobbiPost = isValidHatchPost;
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute hatch task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create Theme (kind 36767) - ≥1 event after start
* 2. Color Moment (kind 3367) - ≥1 event after start
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
* 4. Interactions - 7 total (tracked via companion.tasks cache)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 5. Maintain Stats - health >= 70, hygiene >= 70, happiness >= 70
*
* @param companion - The Blobbi companion (must be incubating)
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useHatchTasks(
companion: BlobbiCompanion | null,
interactionCount?: number
): HatchTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isIncubating = companion?.state === 'incubating';
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['hatch-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid Blobbi posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Reasonable limit
},
// Profile metadata - need both before and after start
// Get latest before start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
until: stateStartedAt,
limit: 1,
},
// Get latest after start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
// Separate profile events into before and after
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileBefore = profileEvents
.filter(e => e.created_at < stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents,
colorMomentEvents,
postEvents,
profileBefore,
profileAfter,
};
},
enabled: !!pubkey && !!stateStartedAt && isIncubating,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create Theme (PERSISTENT)
const hasTheme = (data?.themeEvents?.length ?? 0) >= 1;
tasks.push({
id: 'create_theme',
name: 'Create Theme',
description: 'Create a custom theme for your profile',
current: hasTheme ? 1 : 0,
required: 1,
completed: hasTheme,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// 2. Color Moment (PERSISTENT)
const hasColorMoment = (data?.colorMomentEvents?.length ?? 0) >= 1;
tasks.push({
id: 'color_moment',
name: 'Color Moment',
description: 'Share a color moment on espy',
current: hasColorMoment ? 1 : 0,
required: 1,
completed: hasColorMoment,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create Post (PERSISTENT)
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
const hasValidPost = validPosts.length >= 1;
tasks.push({
id: 'create_post',
name: 'Create Post',
description: 'Share a post about hatching your Blobbi',
current: hasValidPost ? 1 : 0,
required: 1,
completed: hasValidPost,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 5. Interactions (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= HATCH_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${HATCH_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, HATCH_REQUIRED_INTERACTIONS),
required: HATCH_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
// 6. Maintain Stats - health >= 70, hygiene >= 70, happiness >= 70
const stats = companion?.stats ?? {};
const health = stats.health ?? 0;
const hygiene = stats.hygiene ?? 0;
const happiness = stats.happiness ?? 0;
const statsOk =
health >= HATCH_STAT_THRESHOLD &&
hygiene >= HATCH_STAT_THRESHOLD &&
happiness >= HATCH_STAT_THRESHOLD;
// Calculate minimum stat for progress display
const minStat = Math.min(health, hygiene, happiness);
tasks.push({
id: 'maintain_stats',
name: 'Keep Egg Healthy',
description: `Keep health, hygiene & happiness above ${HATCH_STAT_THRESHOLD}`,
current: statsOk ? HATCH_STAT_THRESHOLD : minStat,
required: HATCH_STAT_THRESHOLD,
completed: statsOk,
type: 'dynamic', // CRITICAL: Never persist this task
// No action - just care for your Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Get the current interaction count from companion task cache.
*/
export function getInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
/**
* Filter tasks to only persistent tasks (for tag sync).
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
@@ -0,0 +1,159 @@
/**
* useRerollMission - Hook for rerolling daily missions
*
* Handles:
* - Replacing a mission with a new one from the pool
* - Tracking reroll usage (max 3 per day)
* - Respecting stage-based mission filtering
* - Persisting state to localStorage
*/
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
rerollMission,
canRerollMission,
getRerollsRemaining,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface RerollMissionRequest {
missionId: string;
availableStages?: BlobbiStage[];
}
export interface RerollMissionResult {
oldMissionId: string;
newMission: DailyMission;
rerollsRemaining: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const state = JSON.parse(stored) as DailyMissionsState;
// Migration: ensure rerollsRemaining is set for old state
if (state.rerollsRemaining === undefined) {
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
}
return state;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useRerollMission] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to reroll a daily mission.
*
* Replaces the specified mission with a new one from the pool,
* respecting stage-based filtering and avoiding duplicates.
*/
export function useRerollMission() {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to reroll missions');
}
// Read current missions state from localStorage
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
}
// Check if reroll is allowed
if (!canRerollMission(missionsState!, missionId)) {
const rerollsLeft = getRerollsRemaining(missionsState!);
if (rerollsLeft <= 0) {
throw new Error('No rerolls remaining today');
}
const mission = missionsState!.missions.find(m => m.id === missionId);
if (mission?.completed || mission?.claimed) {
throw new Error('Cannot reroll completed or claimed missions');
}
throw new Error('Cannot reroll this mission');
}
// Perform the reroll
const result = rerollMission(missionsState!, missionId, availableStages);
if (!result) {
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
}
// Persist the updated state
writeMissionsState(result.state);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: {
missionId,
rerolled: true,
newMissionId: result.newMission.id,
}
}));
return {
oldMissionId: missionId,
newMission: result.newMission,
rerollsRemaining: getRerollsRemaining(result.state),
};
},
onSuccess: ({ newMission, rerollsRemaining }) => {
const rerollText = rerollsRemaining === 1
? '1 reroll left'
: rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} rerolls left`;
toast({
title: 'Mission Replaced',
description: `New mission: ${newMission.title}. ${rerollText}.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to Reroll',
description: error.message,
variant: 'destructive',
});
},
});
}
+192
View File
@@ -0,0 +1,192 @@
// src/blobbi/actions/index.ts
// Components
export { BlobbiActionsModal } from './components/BlobbiActionsModal';
export { BlobbiActionInventoryModal } from './components/BlobbiActionInventoryModal';
export { PlayMusicModal } from './components/PlayMusicModal';
export { SingModal } from './components/SingModal';
export { InlineMusicPlayer } from './components/InlineMusicPlayer';
export { InlineSingCard } from './components/InlineSingCard';
export { HatchTasksPanel } from './components/HatchTasksPanel';
export { TasksPanel } from './components/TasksPanel';
export { BlobbiPostModal } from './components/BlobbiPostModal';
export { StartIncubationDialog } from './components/StartIncubationDialog';
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
export type { AudioSource } from './components/PlayMusicModal';
// Hooks
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
export type { UseItemRequest, UseItemResult, UseBlobbiUseInventoryItemParams } from './hooks/useBlobbiUseInventoryItem';
export { useBlobbiHatch, useBlobbiEvolve } from './hooks/useBlobbiStageTransition';
export type {
UseBlobbiStageTransitionParams,
StageTransitionResult,
CanonicalActionResult,
} from './hooks/useBlobbiStageTransition';
export {
useStartIncubation,
useStopIncubation,
useStartEvolution,
useStopEvolution,
useSyncTaskCompletions,
} from './hooks/useBlobbiIncubation';
export type {
StartIncubationMode,
StartIncubationRequest,
UseStartIncubationParams,
StartIncubationResult,
UseStopIncubationParams,
StopIncubationResult,
UseStartEvolutionParams,
StartEvolutionResult,
UseStopEvolutionParams,
StopEvolutionResult,
UseSyncTaskCompletionsParams,
TaskCompletionToSync,
} from './hooks/useBlobbiIncubation';
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from './hooks/useActiveTaskProcess';
export {
useHatchTasks,
getInteractionCount,
filterPersistentTasks,
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
HATCH_STAT_THRESHOLD,
REQUIRED_INTERACTIONS, // Legacy export
BLOBBI_POST_PREFIX,
BLOBBI_POST_REQUIRED_HASHTAGS,
} from './hooks/useHatchTasks';
export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTasks';
export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_WALL_EDIT,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_STAT_THRESHOLD,
BLOBBI_EVOLVE_POST_PREFIX,
} from './hooks/useEvolveTasks';
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
export { useBlobbiDirectAction, DIRECT_ACTION_HAPPINESS_EFFECTS } from './hooks/useBlobbiDirectAction';
export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionParams } from './hooks/useBlobbiDirectAction';
export { useAudioPlayback } from './hooks/useAudioPlayback';
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
// Built-in tracks
export {
BLOBBI_BUILTIN_TRACKS,
getAllBuiltInTracks,
getBuiltInTrackById,
formatTrackDuration,
type BuiltInTrack,
} from './lib/blobbi-builtin-tracks';
// Activity state
export {
createMusicActivity,
createSingActivity,
createNoActivity,
type InlineActivityType,
type InlineActivityState,
type MusicActivityState,
type SingActivityState,
type NoActivityState,
type BlobbiReactionState,
type MusicTrackSource,
} from './lib/blobbi-activity-state';
// Re-export stat bounds from canonical source
export { STAT_MIN, STAT_MAX } from '@/lib/blobbi';
// Utilities
export {
// Types
type InventoryAction,
type DirectAction,
type BlobbiAction,
type ResolvedInventoryItem,
type EggStatPreview,
type ItemUsabilityResult,
type IncrementInteractionResult,
// Constants
ACTION_TO_ITEM_TYPE,
ACTION_METADATA,
DIRECT_ACTION_METADATA,
ALL_ACTION_METADATA,
GENERAL_ITEM_USABLE_STAGES,
EGG_ALLOWED_ACTIONS,
EGG_ALLOWED_INVENTORY_ACTIONS,
EGG_ALLOWED_DIRECT_ACTIONS,
EGG_VISIBLE_INVENTORY_ACTIONS,
EGG_VISIBLE_ACTIONS,
SHELL_REPAIR_KIT_ID,
// Functions
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
isActionVisibleForStage,
canUseInventoryItems,
getStageRestrictionMessage,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
canUseItemForStage,
getActionForItem,
incrementInteractionTaskTags,
} from './lib/blobbi-action-utils';
// Daily Missions
export { useDailyMissions } from './hooks/useDailyMissions';
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export {
trackDailyMissionProgress,
trackMultipleDailyMissionActions,
} from './lib/daily-mission-tracker';
export type {
DailyMission,
DailyMissionAction,
DailyMissionDefinition,
DailyMissionsState,
} from './lib/daily-missions';
// Streak tracking
export {
calculateStreakUpdate,
getStreakTagUpdates,
needsStreakUpdate,
getStreakStatus,
} from './lib/blobbi-streak';
export type {
StreakUpdateResult,
StreakTagUpdates,
} from './lib/blobbi-streak';
export { useBlobbiCareActivity } from './hooks/useBlobbiCareActivity';
export type {
UseBlobbiCareActivityParams,
CareActivityResult,
} from './hooks/useBlobbiCareActivity';
@@ -0,0 +1,639 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Actions that consume inventory items
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Non-inventory actions that don't consume items
* These actions affect stats directly without using shop items.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (inventory + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
/**
* Mapping from action type to allowed item categories
*/
export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
feed: 'food',
play: 'toy',
clean: 'hygiene',
medicine: 'medicine',
};
/**
* Action metadata for UI display (inventory actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
label: 'Feed',
description: 'Feed your Blobbi',
icon: '🍎',
},
play: {
label: 'Play',
description: 'Play with your Blobbi',
icon: '⚽',
},
clean: {
label: 'Clean',
description: 'Clean your Blobbi',
icon: '🧼',
},
medicine: {
label: 'Medicine',
description: 'Heal your Blobbi',
icon: '💊',
},
};
/**
* Action metadata for direct actions (non-inventory)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
label: 'Play Music',
description: 'Play music for your Blobbi',
icon: '🎵',
},
sing: {
label: 'Sing',
description: 'Sing to your Blobbi',
icon: '🎤',
},
};
/**
* Combined action metadata for all action types
*/
export const ALL_ACTION_METADATA: Record<BlobbiAction, { label: string; description: string; icon: string }> = {
...ACTION_METADATA,
...DIRECT_ACTION_METADATA,
};
// ─── Stat Helpers ─────────────────────────────────────────────────────────────
// STAT_MIN and STAT_MAX are imported from @/lib/blobbi (single source of truth)
/**
* Clamp a stat value between STAT_MIN (1) and STAT_MAX (100).
* Safe for undefined values (returns STAT_MIN).
*
* The minimum of 1 (instead of 0) ensures:
* - Blobbi is never in an unrecoverable state
* - Visual feedback shows critical state without being "dead"
* - Recovery is always possible with any healing item
*/
export function clampStat(value: number | undefined): number {
if (value === undefined) return STAT_MIN;
return Math.max(STAT_MIN, Math.min(STAT_MAX, Math.round(value)));
}
/**
* Apply a delta to a stat, clamping the result to STAT_MIN-STAT_MAX.
*/
export function applyStat(current: number | undefined, delta: number): number {
const currentValue = current ?? STAT_MIN;
return clampStat(currentValue + delta);
}
/**
* Apply item effects to current stats.
* Returns a new partial stats object with all affected stats clamped.
* Only modifies stats that have corresponding effects.
*/
export function applyItemEffects(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect
): Partial<BlobbiStats> {
const newStats: Partial<BlobbiStats> = { ...currentStats };
if (effects.hunger !== undefined) {
newStats.hunger = applyStat(currentStats.hunger, effects.hunger);
}
if (effects.happiness !== undefined) {
newStats.happiness = applyStat(currentStats.happiness, effects.happiness);
}
if (effects.energy !== undefined) {
newStats.energy = applyStat(currentStats.energy, effects.energy);
}
if (effects.hygiene !== undefined) {
newStats.hygiene = applyStat(currentStats.hygiene, effects.hygiene);
}
if (effects.health !== undefined) {
newStats.health = applyStat(currentStats.health, effects.health);
}
return newStats;
}
// ─── Egg-Specific Item Helpers ────────────────────────────────────────────────
/**
* The Shell Repair Kit is a special medicine item only usable by eggs.
*/
export const SHELL_REPAIR_KIT_ID = 'med_shell_repair';
/**
* Result of checking if an item can be used by a specific Blobbi stage.
*/
export interface ItemUsabilityResult {
canUse: boolean;
reason?: string;
}
/**
* Check if a specific item can be used by a companion at the given stage.
*
* This is the centralized item usability logic:
* - Shell Repair Kit: Only usable by eggs
* - Food items: Only usable by baby/adult (not eggs)
* - Toy items: Only usable by baby/adult (not eggs)
* - Medicine items (except Shell Repair Kit): Usable by all stages with health effect
* - Hygiene items: Usable by all stages
*
* @param itemId - The shop item ID
* @param stage - The companion's life stage
* @returns Object with canUse boolean and optional reason string
*/
export function canUseItemForStage(
itemId: string,
stage: 'egg' | 'baby' | 'adult'
): ItemUsabilityResult {
const shopItem = getShopItemById(itemId);
if (!shopItem) {
return { canUse: false, reason: 'Item not found' };
}
const isEgg = stage === 'egg';
// Shell Repair Kit special case: only for eggs
if (itemId === SHELL_REPAIR_KIT_ID) {
if (!isEgg) {
return { canUse: false, reason: 'Only usable for eggs' };
}
return { canUse: true };
}
// Food items: not usable by eggs
if (shopItem.type === 'food') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot eat food' };
}
return { canUse: true };
}
// Toy items: not usable by eggs
if (shopItem.type === 'toy') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot use toys' };
}
return { canUse: true };
}
// Medicine items (except Shell Repair Kit): check for health effect
if (shopItem.type === 'medicine') {
if (!hasMedicineEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This medicine has no effect' };
}
return { canUse: true };
}
// Hygiene items: all stages can use
if (shopItem.type === 'hygiene') {
if (!hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This item has no cleaning effect' };
}
return { canUse: true };
}
// Accessories are disabled
if (shopItem.type === 'accessory') {
return { canUse: false, reason: 'Accessories are not usable yet' };
}
return { canUse: true };
}
/**
* Get the action type for a given item.
*/
export function getActionForItem(itemId: string): InventoryAction | null {
const shopItem = getShopItemById(itemId);
if (!shopItem) return null;
const typeToAction: Record<string, InventoryAction> = {
food: 'feed',
toy: 'play',
hygiene: 'clean',
medicine: 'medicine',
};
return typeToAction[shopItem.type] ?? null;
}
/**
* Check if a medicine item has any effect on an egg.
*
* Eggs use the standard 3-stat model:
* - health
* - hygiene
* - happiness
*
* Medicine with a health effect will directly affect the egg's health stat.
*/
export function hasMedicineEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.health !== undefined && effects.health !== 0;
}
/**
* Check if a hygiene item has any effect on an egg.
* Hygiene items with a hygiene effect will directly affect the egg's hygiene stat.
*/
export function hasHygieneEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.hygiene !== undefined && effects.hygiene !== 0;
}
/**
* Check if an item has a happiness effect for an egg.
* Some items (like bubble bath) give happiness bonus in addition to primary effects.
*/
export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Inventory Helpers ────────────────────────────────────────────────────────
/**
* Resolved inventory item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
quantity: number;
name: string;
icon: string;
type: ShopItemCategory;
effect?: ItemEffect;
}
/**
* Options for filtering inventory by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
stage?: 'egg' | 'baby' | 'adult';
}
/**
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
*
* Filtering rules:
* - Only items matching the action's item type are included
* - Shell Repair Kit only appears in medicine modal for eggs
* - For eggs: only items with egg-compatible effects are returned
* - medicine action: only items with health effect
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
if (shopItem.type !== allowedType) continue;
if (storageItem.quantity <= 0) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
// For eggs, filter items by egg-compatible effects
if (isEgg) {
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
continue; // Skip medicine without health effect
}
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
continue; // Skip hygiene items without hygiene or happiness effect
}
}
result.push({
itemId: storageItem.itemId,
quantity: storageItem.quantity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
effect: shopItem.effect,
});
}
return result;
}
/**
* Decrement item quantity in storage array.
* If quantity becomes 0, removes the item entirely.
* Returns a new storage array (immutable).
*/
export function decrementStorageItem(
storage: StorageItem[],
itemId: string,
amount = 1
): StorageItem[] {
const result: StorageItem[] = [];
for (const item of storage) {
if (item.itemId !== itemId) {
result.push(item);
continue;
}
const newQuantity = item.quantity - amount;
if (newQuantity > 0) {
result.push({ ...item, quantity: newQuantity });
}
// If newQuantity <= 0, we don't add it (remove item)
}
return result;
}
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general inventory items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
/**
* Inventory actions that are allowed for eggs.
* Eggs can use: medicine (health), clean (hygiene)
*/
export const EGG_ALLOWED_INVENTORY_ACTIONS: InventoryAction[] = ['medicine', 'clean'];
/**
* Direct actions that are allowed for eggs.
* All direct actions work on eggs.
*/
export const EGG_ALLOWED_DIRECT_ACTIONS: DirectAction[] = ['play_music', 'sing'];
/**
* Inventory actions visible in the egg UI.
* Note: feed, play, sleep are hidden in the UI for eggs but not hard-blocked.
*/
export const EGG_VISIBLE_INVENTORY_ACTIONS: InventoryAction[] = ['clean', 'medicine'];
/**
* All actions visible in the egg UI.
*/
export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_music', 'sing'];
/**
* @deprecated Use EGG_ALLOWED_INVENTORY_ACTIONS instead
*/
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific inventory action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all inventory actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
/**
* Check if a companion can use a specific direct action.
* Direct actions (play_music, sing) are available for all stages.
*/
export function canUseDirectAction(_companion: BlobbiCompanion, _action: DirectAction): boolean {
// All stages can use direct actions
return true;
}
/**
* Check if an action should be visible in the UI for a given stage.
* This is for UI filtering only - some actions are hidden but not blocked.
*/
export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action: BlobbiAction): boolean {
if (stage === 'egg') {
return EGG_VISIBLE_ACTIONS.includes(action);
}
return true; // baby and adult see all actions
}
/**
* Check if a companion can use general inventory items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
export function canUseInventoryItems(companion: BlobbiCompanion): boolean {
return GENERAL_ITEM_USABLE_STAGES.includes(companion.stage as typeof GENERAL_ITEM_USABLE_STAGES[number]);
}
/**
* Get a user-friendly message explaining why an action can't be used.
*/
export function getStageRestrictionMessage(companion: BlobbiCompanion, action?: InventoryAction): string | null {
if (companion.stage === 'egg') {
if (action && EGG_ALLOWED_INVENTORY_ACTIONS.includes(action)) {
return null; // Medicine and clean are allowed for eggs
}
return 'Eggs cannot use this item. Wait for your Blobbi to hatch!';
}
return null;
}
// ─── Stats Preview ────────────────────────────────────────────────────────────
/**
* Preview stats after applying an item's effects.
* Useful for showing the user what will happen before confirming.
*/
export function previewStatChanges(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect | undefined
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
if (!effects) return [];
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'energy', 'hygiene', 'health'];
for (const stat of statKeys) {
const delta = effects[stat];
if (delta !== undefined && delta !== 0) {
const current = currentStats[stat] ?? 0;
const after = clampStat(current + delta);
changes.push({ stat, current, after, delta });
}
}
return changes;
}
/**
* Preview stat change for an egg.
* Eggs use the 3-stat model: health, hygiene, happiness.
*/
export type EggStatPreview = { stat: 'health' | 'hygiene' | 'happiness'; current: number; after: number; delta: number };
/**
* Preview medicine effects for an egg.
* Medicine directly affects the egg's health stat.
*/
export function previewMedicineForEgg(
currentHealth: number | undefined,
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects || effects.health === undefined || effects.health === 0) {
return [];
}
const current = currentHealth ?? 100;
const delta = effects.health;
const after = clampStat(current + delta);
return [{ stat: 'health', current, after, delta }];
}
/**
* Preview clean (hygiene) effects for an egg.
* Hygiene items directly affect the egg's hygiene stat.
* May also include happiness bonus if the item has one.
*/
export function previewCleanForEgg(
currentStats: { hygiene?: number; happiness?: number },
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects) return [];
const results: EggStatPreview[] = [];
// Hygiene effect
if (effects.hygiene !== undefined && effects.hygiene !== 0) {
const current = currentStats.hygiene ?? 100;
const delta = effects.hygiene;
const after = clampStat(current + delta);
results.push({ stat: 'hygiene', current, after, delta });
}
// Happiness bonus (some hygiene items like bubble bath give happiness)
if (effects.happiness !== undefined && effects.happiness !== 0) {
const current = currentStats.happiness ?? 100;
const delta = effects.happiness;
const after = clampStat(current + delta);
results.push({ stat: 'happiness', current, after, delta });
}
return results;
}
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_INTERACTION_TASK = import.meta.env.DEV;
/**
* Result of incrementing interaction task tags
*/
export interface IncrementInteractionResult {
/** Updated tags array */
updatedTags: string[][];
/** New interaction count after increment */
newCount: number;
/** Whether the task is now complete */
isCompleted: boolean;
/** Previous count before increment */
previousCount: number;
}
/**
* Increment the interaction task counter in the tags array.
*
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
* to track progress on interaction tasks for both hatch and evolve.
*
* CRITICAL: This function is called during actual user actions (not retroactive sync).
* It always increments by 1 because each call represents a real interaction.
*
* Tag format:
* - Progress: ["task", "interactions:N"]
* - Completion: ["task_completed", "interactions"]
*
* Idempotency notes:
* - This is NOT idempotent by design - each call = one interaction
* - Duplicate task_completed tags are prevented by filtering before add
* - Multiple task:interactions tags are prevented by filtering before add
*
* @param currentTags - Current tags array from the Blobbi state
* @param requiredInteractions - Threshold for completion (7 for hatch, 21 for evolve)
* @returns Updated tags array with incremented interaction count
*/
export function incrementInteractionTaskTags(
currentTags: string[][],
requiredInteractions: number
): IncrementInteractionResult {
// Get current interaction count from task tags
const interactionTag = currentTags.find(tag =>
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
);
const previousCount = interactionTag
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
: 0;
const newCount = previousCount + 1;
// Check if already completed (task_completed tag exists)
const alreadyCompleted = currentTags.some(tag =>
tag[0] === 'task_completed' && tag[1] === 'interactions'
);
// Remove old interaction task tag (prevent duplicates) and add new one
let updatedTags = currentTags.filter(tag =>
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
);
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
// Mark as completed if reached required count AND not already marked
const isCompleted = newCount >= requiredInteractions;
if (isCompleted && !alreadyCompleted) {
// Only add if not already present (handled by filter, but double-check)
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
}
if (DEBUG_INTERACTION_TASK) {
console.log('[InteractionTask] Increment:', {
previousCount,
newCount,
requiredInteractions,
isCompleted,
alreadyCompleted,
addedCompletionTag: isCompleted && !alreadyCompleted,
});
}
return { updatedTags, newCount, isCompleted, previousCount };
}
@@ -0,0 +1,81 @@
// src/blobbi/actions/lib/blobbi-activity-state.ts
import type { AudioSource } from '../components/PlayMusicModal';
/**
* Types of inline activities that can be displayed in BlobbiPage
*/
export type InlineActivityType = 'none' | 'music' | 'sing';
// Re-export for convenience
export type { AudioSource as MusicTrackSource } from '../components/PlayMusicModal';
/**
* State for the music inline activity
*/
export interface MusicActivityState {
type: 'music';
source: AudioSource;
isPublished: boolean;
}
/**
* State for the sing inline activity
*/
export interface SingActivityState {
type: 'sing';
}
/**
* No active inline activity
*/
export interface NoActivityState {
type: 'none';
}
/**
* Union type for all inline activity states
*/
export type InlineActivityState =
| NoActivityState
| MusicActivityState
| SingActivityState;
/**
* Blobbi reaction state - indicates how Blobbi should visually react
*/
export type BlobbiReactionState =
| 'idle' // No special reaction
| 'listening' // Music is playing, Blobbi is listening
| 'swaying' // Blobbi is swaying to music
| 'singing' // User is singing, Blobbi is engaged
| 'happy'; // General happy reaction
/**
* Helper to create a music activity state
*/
export function createMusicActivity(source: AudioSource): MusicActivityState {
return {
type: 'music',
source,
isPublished: false,
};
}
/**
* Helper to create a sing activity state
*/
export function createSingActivity(): SingActivityState {
return {
type: 'sing',
};
}
/**
* Helper to create no activity state
*/
export function createNoActivity(): NoActivityState {
return {
type: 'none',
};
}
@@ -0,0 +1,121 @@
// src/blobbi/actions/lib/blobbi-builtin-tracks.ts
/**
* Built-in music tracks for the Blobbi "Play Music" action.
*
* ## Asset Location
*
* Audio files live in: `public/blobbi/audio/`
*
* In Vite, files in `public/` are served at root paths, so:
* - `public/blobbi/audio/foo.mp3` → accessible at `/blobbi/audio/foo.mp3`
*
* ## Adding New Tracks
*
* 1. Place the MP3 file in `public/blobbi/audio/`
* 2. Add a new entry to `BLOBBI_BUILTIN_TRACKS` below
* 3. Set `path` to `/blobbi/audio/<filename>.mp3`
* 4. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
*
* ## Supported Formats
*
* MP3 is recommended for maximum browser compatibility.
* WAV, OGG, and M4A may work but are browser-dependent.
*/
export interface BuiltInTrack {
/** Unique identifier for the track (used in state/events) */
id: string;
/** Display title shown in the UI */
title: string;
/** Artist or source attribution */
artist: string;
/** Path to audio file (relative to public directory root) */
path: string;
/** Duration in seconds (for display, get via ffprobe) */
durationSeconds: number;
/** Optional cover art path (relative to public directory root) */
coverArt?: string;
/** Optional tags for categorization/filtering */
tags?: string[];
}
/**
* Built-in track catalog for Blobbi music player.
*
* All tracks are royalty-free/Creative Commons licensed.
* Audio files located at: public/blobbi/audio/
*/
export const BLOBBI_BUILTIN_TRACKS: BuiltInTrack[] = [
{
id: 'nap_in_the_meadow',
title: 'Nap in the Meadow',
artist: 'Chilltape FM',
path: '/blobbi/audio/chilltapefm-nap-in-the-meadow.mp3',
durationSeconds: 240, // 4:00
tags: ['relaxing', 'nature'],
},
{
id: 'happy_kids',
title: 'Happy Kids',
artist: 'Dmitrii Kolesnikov',
path: '/blobbi/audio/happy-kids.mp3',
durationSeconds: 129, // 2:09
tags: ['upbeat', 'fun'],
},
{
id: 'soft_piano',
title: 'Soft Piano',
artist: 'Dmitrii Kolesnikov',
path: '/blobbi/audio/soft-piano.mp3',
durationSeconds: 124, // 2:04
tags: ['calming', 'sleep'],
},
{
id: 'epic_sacred_light',
title: 'Epic Sacred Light',
artist: 'Ura Megis',
path: '/blobbi/audio/epic-sacred-light.mp3',
durationSeconds: 223, // 3:43
tags: ['energetic', 'adventure'],
},
{
id: 'split_memmories',
title: 'Split Memmories',
artist: 'ido berg',
path: '/blobbi/audio/split-memmories.mp3',
durationSeconds: 153, // 2:33
tags: ['ambient', 'relaxing'],
},
{
id: 'minhas_mensagens',
title: 'Minhas Mensagens',
artist: 'PReis',
path: '/blobbi/audio/minhas-mensagens-preis.mp3',
durationSeconds: 248, // 4:08
tags: ['ambient', 'relaxing'],
},
];
/**
* Get a built-in track by ID
*/
export function getBuiltInTrackById(id: string): BuiltInTrack | undefined {
return BLOBBI_BUILTIN_TRACKS.find(track => track.id === id);
}
/**
* Get all built-in tracks
*/
export function getAllBuiltInTracks(): BuiltInTrack[] {
return BLOBBI_BUILTIN_TRACKS;
}
/**
* Format duration in seconds to MM:SS string
*/
export function formatTrackDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
@@ -0,0 +1,121 @@
// src/blobbi/actions/lib/blobbi-random-lyrics.ts
/**
* Random lyrics for the Sing action.
* These are fun, simple lyrics that users can sing to their Blobbi.
*/
export interface LyricsEntry {
id: string;
title: string;
lines: string[];
}
/**
* Collection of placeholder lyrics for singing to a Blobbi.
* Simple, fun, and appropriate for all ages.
*/
export const BLOBBI_LYRICS: LyricsEntry[] = [
{
id: 'lullaby-1',
title: 'Blobbi Lullaby',
lines: [
'Little Blobbi, close your eyes,',
'Dream of stars up in the skies.',
'Safe and warm, you drift away,',
"We'll play again another day.",
],
},
{
id: 'happy-song-1',
title: 'Happy Blobbi Song',
lines: [
'Blobbi, Blobbi, jump around!',
"You're the happiest friend I've found!",
'Dancing, playing, full of cheer,',
"I'm so glad that you are here!",
],
},
{
id: 'adventure-1',
title: 'Adventure Time',
lines: [
"Let's go on an adventure today,",
'Through the clouds and far away!',
'Mountains high and valleys deep,',
'Memories to always keep.',
],
},
{
id: 'breakfast-song',
title: 'Breakfast Song',
lines: [
'Wake up, wake up, sleepy head,',
"Time to get out of your bed!",
"Breakfast's waiting, fresh and yummy,",
'Food to fill your happy tummy!',
],
},
{
id: 'rainy-day',
title: 'Rainy Day',
lines: [
'Pitter patter on the roof,',
'Rainy days can be so nice.',
"We'll stay cozy, me and you,",
'Watching raindrops, one by two.',
],
},
{
id: 'sunshine-song',
title: 'Sunshine Song',
lines: [
'Good morning, sunshine, bright and warm,',
'A brand new day is being born!',
'Blue sky smiling down on me,',
'Happy as can be, so free!',
],
},
{
id: 'bedtime-1',
title: 'Bedtime Blues',
lines: [
'The moon is up, the stars are bright,',
'Time to say a soft goodnight.',
'Snuggle up and close your eyes,',
'Sweet dreams under starry skies.',
],
},
{
id: 'play-time',
title: 'Play Time',
lines: [
"Bounce and jump and run around,",
"Spin and twirl without a sound!",
"Playing games is so much fun,",
"Laughing underneath the sun!",
],
},
];
/**
* Get a random lyrics entry.
*/
export function getRandomLyrics(): LyricsEntry {
const index = Math.floor(Math.random() * BLOBBI_LYRICS.length);
return BLOBBI_LYRICS[index];
}
/**
* Get all available lyrics entries.
*/
export function getAllLyrics(): LyricsEntry[] {
return BLOBBI_LYRICS;
}
/**
* Format lyrics for display (joined with newlines).
*/
export function formatLyrics(lyrics: LyricsEntry): string {
return lyrics.lines.join('\n');
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Blobbi Care Streak Management
*
* This module provides centralized logic for tracking care streaks on Blobbi companions.
* A streak represents consecutive days of care activity (opening Blobbi page, performing
* care actions, etc.).
*
* Streak Rules:
* - Starts at 1 on first activity
* - Increments when activity happens on the NEXT local calendar day
* - Same-day activity does not increment (at most once per day)
* - Missing 2+ days resets streak to 1
*
* Tags managed:
* - care_streak: The current streak count (positive integer)
* - care_streak_last_at: Unix timestamp (seconds) of last streak update
* - care_streak_last_day: Local calendar day string (YYYY-MM-DD) of last update
*/
import {
getLocalDayString,
getDaysDifference,
type BlobbiCompanion,
} from '@/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of calculating a streak update.
*/
export interface StreakUpdateResult {
/** Whether the streak was updated (incremented or reset) */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** The new timestamp for care_streak_last_at */
newLastAt: number;
/** The new day string for care_streak_last_day */
newLastDay: string;
/** Description of what happened (for debugging/logging) */
action: 'initialized' | 'incremented' | 'reset' | 'same_day';
}
/**
* Tag updates to apply to the Blobbi event.
* Only present if wasUpdated is true.
* Uses index signature for compatibility with updateBlobbiTags.
*/
export interface StreakTagUpdates {
care_streak: string;
care_streak_last_at: string;
care_streak_last_day: string;
[key: string]: string;
}
// ─── Core Logic ───────────────────────────────────────────────────────────────
/**
* Calculate what the streak should be updated to based on current state and activity.
*
* This is a pure function that calculates the new streak state without side effects.
* Use this to determine if/how the streak should be updated.
*
* @param currentStreak - Current streak value (0 or undefined means no streak yet)
* @param lastDay - The last day string (YYYY-MM-DD) when streak was updated, or undefined
* @param now - Current timestamp (defaults to now)
* @returns StreakUpdateResult describing the update
*/
export function calculateStreakUpdate(
currentStreak: number | undefined,
lastDay: string | undefined,
now: Date = new Date()
): StreakUpdateResult {
const nowTimestamp = Math.floor(now.getTime() / 1000);
const todayString = getLocalDayString(now);
// Case 1: No existing streak - initialize to 1
if (currentStreak === undefined || currentStreak === 0 || !lastDay) {
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'initialized',
};
}
// Case 2: Activity on the same day - no update needed
if (lastDay === todayString) {
return {
wasUpdated: false,
newStreak: currentStreak,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'same_day',
};
}
// Calculate days since last activity
const daysMissed = getDaysDifference(lastDay, todayString);
// Case 3: Next day (1 day difference) - increment streak
if (daysMissed === 1) {
return {
wasUpdated: true,
newStreak: currentStreak + 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'incremented',
};
}
// Case 4: Missed 2+ days - reset to 1
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'reset',
};
}
/**
* Get the tag updates to apply to a Blobbi event for a streak update.
* Returns undefined if no update is needed (same day activity).
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns Tag updates to apply, or undefined if no update needed
*/
export function getStreakTagUpdates(
companion: BlobbiCompanion,
now: Date = new Date()
): StreakTagUpdates | undefined {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
if (!result.wasUpdated) {
return undefined;
}
return {
care_streak: result.newStreak.toString(),
care_streak_last_at: result.newLastAt.toString(),
care_streak_last_day: result.newLastDay,
};
}
/**
* Check if a streak update is needed for the companion.
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns true if the streak should be updated
*/
export function needsStreakUpdate(
companion: BlobbiCompanion,
now: Date = new Date()
): boolean {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
return result.wasUpdated;
}
/**
* Get the current streak status for display purposes.
*
* @param companion - The current Blobbi companion state
* @returns Object with streak info for UI display
*/
export function getStreakStatus(companion: BlobbiCompanion): {
streak: number;
lastDay: string | undefined;
isActive: boolean;
daysSinceLastActivity: number | undefined;
} {
const streak = companion.careStreak ?? 0;
const lastDay = companion.careStreakLastDay;
const today = getLocalDayString();
let daysSinceLastActivity: number | undefined;
let isActive = false;
if (lastDay) {
daysSinceLastActivity = getDaysDifference(lastDay, today);
// Streak is "active" if we've had activity today or yesterday
isActive = daysSinceLastActivity <= 1;
}
return {
streak,
lastDay,
isActive,
daysSinceLastActivity,
};
}
@@ -0,0 +1,109 @@
/**
* Daily Mission Tracker - Standalone progress tracking utility
*
* This module provides a simple way to track daily mission progress
* without requiring React hooks or context. It directly manipulates
* localStorage for immediate persistence.
*
* This approach allows action hooks (which may be called outside of
* the daily missions hook context) to record progress.
*/
import {
type DailyMissionsState,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
updateMissionProgress,
} from './daily-missions';
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
/**
* Read the current daily missions state from localStorage
*/
function readState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
/**
* Write the daily missions state to localStorage
*/
function writeState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[DailyMissionTracker] Failed to write state:', error);
}
}
/**
* Ensure we have a valid state for today, creating one if necessary
*/
function ensureCurrentState(pubkey?: string): DailyMissionsState {
const current = readState();
if (needsDailyReset(current)) {
const previousCoins = current?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
writeState(newState);
return newState;
}
return current!;
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Record progress for a daily mission action.
* This function can be called from anywhere (hooks, event handlers, etc.)
* and will immediately persist to localStorage.
*
* @param action - The action type that was performed
* @param count - Number of times the action was performed (default: 1)
* @param pubkey - Optional user pubkey for personalized mission selection
*/
export function trackDailyMissionProgress(
action: DailyMissionAction,
count: number = 1,
pubkey?: string
): void {
const current = ensureCurrentState(pubkey);
const updated = updateMissionProgress(current, action, count);
writeState(updated);
// Dispatch a custom event so React components can re-render if needed
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
}
/**
* Convenience function to track multiple actions at once.
* Useful when an action should count toward multiple missions.
*
* @param actions - Array of actions to track
* @param pubkey - Optional user pubkey
*/
export function trackMultipleDailyMissionActions(
actions: DailyMissionAction[],
pubkey?: string
): void {
let current = ensureCurrentState(pubkey);
for (const action of actions) {
current = updateMissionProgress(current, action, 1);
}
writeState(current);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
}
+708
View File
@@ -0,0 +1,708 @@
/**
* Daily Missions System for Blobbi
*
* This module defines the daily mission pool, selection logic, and types.
* Daily missions are separate from hatch/evolve missions and provide
* daily engagement loops with coin rewards.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mission action types that can trigger progress
*/
export type DailyMissionAction =
| 'interact' // Any interaction (feed, clean, play, etc.)
| 'feed' // Feeding action specifically
| 'clean' // Cleaning action specifically
| 'sing' // Sing direct action
| 'play_music' // Play music direct action
| 'sleep' // Put Blobbi to sleep
| 'take_photo' // Take a photo of Blobbi
| 'medicine'; // Give medicine to Blobbi
/**
* Blobbi stage type for filtering missions
*/
export type BlobbiStage = 'egg' | 'baby' | 'adult';
/**
* Definition of a daily mission in the pool
*/
export interface DailyMissionDefinition {
/** Unique identifier for this mission type */
id: string;
/** Display title */
title: string;
/** Description of what to do */
description: string;
/** Action that triggers progress */
action: DailyMissionAction;
/** Number of times the action must be performed */
requiredCount: number;
/** Coin reward for completing this mission */
reward: number;
/** Selection weight (higher = more likely to be selected) */
weight: number;
/** Required stages to show this mission (if empty/undefined, requires baby or adult) */
requiredStages?: BlobbiStage[];
}
/**
* A daily mission instance with progress tracking
*/
export interface DailyMission extends DailyMissionDefinition {
/** Current progress (how many times the action has been performed today) */
currentCount: number;
/** Whether the mission has been completed */
completed: boolean;
/** Whether the reward has been claimed */
claimed: boolean;
}
/**
* Stored state for daily missions (persisted in localStorage)
*/
export interface DailyMissionsState {
/** The date string (YYYY-MM-DD) when these missions were generated */
date: string;
/** The selected missions for this day */
missions: DailyMission[];
/** Total coins earned from daily missions (lifetime) */
totalCoinsEarned: number;
/** Whether the bonus mission has been claimed today */
bonusClaimed?: boolean;
/** Number of rerolls remaining for today (resets daily, max 3) */
rerollsRemaining?: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
/** Maximum number of mission rerolls allowed per day */
export const MAX_DAILY_REROLLS = 3;
// ─── Mission Pool ─────────────────────────────────────────────────────────────
/**
* The pool of available daily missions.
* Weights determine selection frequency:
* - High weight (10): Common missions (interact, feed, clean)
* - Medium weight (6): Regular missions (sing, play music, sleep)
* - Low weight (2): Uncommon missions (change shape)
* - Rare weight (1): Rare missions (take photo)
*/
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
// ═══════════════════════════════════════════════════════════════════════════
// BABY/ADULT ONLY MISSIONS
// These actions are NOT available for eggs
// ═══════════════════════════════════════════════════════════════════════════
// ─── Interact Missions (Baby/Adult only) ───────────────────────────────────
{
id: 'interact_3',
title: 'Quick Care',
description: 'Interact with your Blobbi 3 times',
action: 'interact',
requiredCount: 3,
reward: 30,
weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'interact_6',
title: 'Attentive Caretaker',
description: 'Interact with your Blobbi 6 times',
action: 'interact',
requiredCount: 6,
reward: 50,
weight: 8,
requiredStages: ['baby', 'adult'],
},
// ─── Feed Missions (Baby/Adult only) ───────────────────────────────────────
{
id: 'feed_1',
title: 'Snack Time',
description: 'Feed your Blobbi once',
action: 'feed',
requiredCount: 1,
reward: 25,
weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_2',
title: 'Hungry Blobbi',
description: 'Feed your Blobbi 2 times',
action: 'feed',
requiredCount: 2,
reward: 45,
weight: 8,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_3',
title: 'Feast Day',
description: 'Feed your Blobbi 3 times',
action: 'feed',
requiredCount: 3,
reward: 60,
weight: 5,
requiredStages: ['baby', 'adult'],
},
// ─── Sleep Missions (Baby/Adult only) ──────────────────────────────────────
{
id: 'sleep_1',
title: 'Nap Time',
description: 'Put your Blobbi to sleep',
action: 'sleep',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['baby', 'adult'],
},
// ─── Photo Missions (Baby/Adult only) ──────────────────────────────────────
{
id: 'take_photo_1',
title: 'Snapshot',
description: 'Take a polaroid photo of your Blobbi',
action: 'take_photo',
requiredCount: 1,
reward: 55,
weight: 4,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_2',
title: 'Photo Album',
description: 'Take 2 photos of your Blobbi',
action: 'take_photo',
requiredCount: 2,
reward: 70,
weight: 2,
requiredStages: ['baby', 'adult'],
},
// ═══════════════════════════════════════════════════════════════════════════
// EGG + BABY + ADULT MISSIONS
// These actions are available for ALL stages including eggs
// ═══════════════════════════════════════════════════════════════════════════
// ─── Clean Missions (All stages) ───────────────────────────────────────────
{
id: 'clean_1',
title: 'Quick Cleanup',
description: 'Clean your Blobbi once',
action: 'clean',
requiredCount: 1,
reward: 25,
weight: 10,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'clean_2',
title: 'Squeaky Clean',
description: 'Clean your Blobbi 2 times',
action: 'clean',
requiredCount: 2,
reward: 45,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Sing Missions (All stages) ────────────────────────────────────────────
{
id: 'sing_1',
title: 'Sing Along',
description: 'Sing a song to your Blobbi',
action: 'sing',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'sing_2',
title: 'Karaoke Session',
description: 'Sing 2 songs to your Blobbi',
action: 'sing',
requiredCount: 2,
reward: 50,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Play Music Missions (All stages) ──────────────────────────────────────
{
id: 'play_music_1',
title: 'DJ Time',
description: 'Play a song for your Blobbi',
action: 'play_music',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'play_music_2',
title: 'Music Marathon',
description: 'Play 2 songs for your Blobbi',
action: 'play_music',
requiredCount: 2,
reward: 50,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Medicine Missions (All stages) ────────────────────────────────────────
// Medicine rewards are higher since medicine costs coins to use
{
id: 'medicine_1',
title: 'Health Check',
description: 'Give medicine to your Blobbi',
action: 'medicine',
requiredCount: 1,
reward: 60,
weight: 5,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'medicine_2',
title: 'Doctor Visit',
description: 'Give medicine to your Blobbi 2 times',
action: 'medicine',
requiredCount: 2,
reward: 70,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
];
// ─── Utility Functions ────────────────────────────────────────────────────────
/**
* Get the current date string in YYYY-MM-DD format (local timezone)
*/
export function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/**
* Generate a seed number from a date string and optional user pubkey.
* Used for deterministic daily mission selection.
*/
function generateDailySeed(dateString: string, pubkey?: string): number {
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Seeded random number generator (Mulberry32)
*/
function seededRandom(seed: number): () => number {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/**
* Check if a mission is available for the given stages.
* Missions with no requiredStages default to requiring baby or adult.
*/
function isMissionAvailableForStages(
mission: DailyMissionDefinition,
availableStages: BlobbiStage[]
): boolean {
const requiredStages = mission.requiredStages ?? ['baby', 'adult'];
return requiredStages.some((stage) => availableStages.includes(stage));
}
/**
* Select N missions from the pool using weighted random selection.
* Uses a seeded random generator for deterministic daily selection.
*
* @param count - Number of missions to select
* @param dateString - Date string for seeding (YYYY-MM-DD)
* @param pubkey - Optional user pubkey for seeding
* @param availableStages - Stages the user has available (filters eligible missions)
*/
export function selectDailyMissions(
count: number,
dateString: string,
pubkey?: string,
availableStages?: BlobbiStage[]
): DailyMissionDefinition[] {
const seed = generateDailySeed(dateString, pubkey);
const random = seededRandom(seed);
// Filter pool by available stages (default to baby/adult if not specified)
const stagesToCheck = availableStages ?? ['baby', 'adult'];
const eligibleMissions = DAILY_MISSION_POOL.filter((m) =>
isMissionAvailableForStages(m, stagesToCheck)
);
// If no missions are available for the user's stages, return empty
if (eligibleMissions.length === 0) {
return [];
}
// Create a copy of the eligible pool
const available = [...eligibleMissions];
const selected: DailyMissionDefinition[] = [];
while (selected.length < count && available.length > 0) {
// Calculate total weight of remaining missions
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
// Pick a random value in [0, totalWeight)
let pick = random() * totalWeight;
// Find the mission that corresponds to this pick
let selectedIndex = 0;
for (let i = 0; i < available.length; i++) {
pick -= available[i].weight;
if (pick <= 0) {
selectedIndex = i;
break;
}
}
// Add to selected and remove from available
selected.push(available[selectedIndex]);
available.splice(selectedIndex, 1);
}
return selected;
}
/**
* Create a fresh DailyMission from a definition
*/
export function createMissionFromDefinition(def: DailyMissionDefinition): DailyMission {
return {
...def,
currentCount: 0,
completed: false,
claimed: false,
};
}
/**
* Create the initial daily missions state for a new day
*/
export function createDailyMissionsState(
dateString: string,
pubkey?: string,
previousTotalCoins: number = 0,
availableStages?: BlobbiStage[]
): DailyMissionsState {
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
return {
date: dateString,
missions: definitions.map(createMissionFromDefinition),
totalCoinsEarned: previousTotalCoins,
rerollsRemaining: MAX_DAILY_REROLLS,
};
}
/**
* Check if the daily missions need to be reset (new day)
*/
export function needsDailyReset(state: DailyMissionsState | null): boolean {
if (!state) return true;
return state.date !== getTodayDateString();
}
/**
* Update mission progress for a given action
*/
export function updateMissionProgress(
state: DailyMissionsState,
action: DailyMissionAction,
incrementBy: number = 1
): DailyMissionsState {
const updatedMissions = state.missions.map((mission) => {
// Skip if not the matching action or already completed
if (mission.action !== action || mission.completed) {
return mission;
}
const newCount = Math.min(mission.currentCount + incrementBy, mission.requiredCount);
const nowCompleted = newCount >= mission.requiredCount;
return {
...mission,
currentCount: newCount,
completed: nowCompleted,
};
});
return {
...state,
missions: updatedMissions,
};
}
/**
* Claim reward for a completed mission
*/
export function claimMissionReward(
state: DailyMissionsState,
missionId: string
): { state: DailyMissionsState; coinsEarned: number } {
let coinsEarned = 0;
const updatedMissions = state.missions.map((mission) => {
if (mission.id !== missionId) return mission;
// Can only claim if completed and not yet claimed
if (!mission.completed || mission.claimed) return mission;
coinsEarned = mission.reward;
return {
...mission,
claimed: true,
};
});
return {
state: {
...state,
missions: updatedMissions,
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
},
coinsEarned,
};
}
/**
* Get the total potential reward for all daily missions
*/
export function getTotalPotentialReward(state: DailyMissionsState): number {
return state.missions.reduce((sum, m) => sum + m.reward, 0);
}
/**
* Get the total claimed reward for today
*/
export function getTodayClaimedReward(state: DailyMissionsState): number {
return state.missions
.filter((m) => m.claimed)
.reduce((sum, m) => sum + m.reward, 0);
}
/**
* Check if all daily missions are completed
*/
export function areAllMissionsCompleted(state: DailyMissionsState): boolean {
return state.missions.every((m) => m.completed);
}
/**
* Check if all daily missions are claimed
*/
export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
return state.missions.every((m) => m.claimed);
}
// ─── Bonus Mission ────────────────────────────────────────────────────────────
/**
* The bonus mission that becomes available after completing all regular missions.
* This is a special mission that rewards extra coins for daily completion.
*/
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
id: 'bonus_daily_complete',
title: 'Daily Champion',
description: 'Complete all daily missions to claim this bonus reward',
action: 'interact', // Not actually used - bonus is auto-completed
requiredCount: 1,
reward: 80,
weight: 0, // Not part of random selection
};
/**
* Check if the bonus mission is available (all regular missions completed)
*/
export function isBonusMissionAvailable(state: DailyMissionsState): boolean {
// Bonus is available if there are regular missions and all are completed
return state.missions.length > 0 && areAllMissionsCompleted(state);
}
/**
* Check if the bonus mission has been claimed today
*/
export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
return state.bonusClaimed ?? false;
}
/**
* Claim the bonus mission reward
*/
export function claimBonusMissionReward(
state: DailyMissionsState
): { state: DailyMissionsState; coinsEarned: number } {
// Can only claim if bonus is available and not yet claimed
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
return { state, coinsEarned: 0 };
}
return {
state: {
...state,
bonusClaimed: true,
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
},
coinsEarned: BONUS_MISSION_DEFINITION.reward,
};
}
// ─── Mission Reroll ───────────────────────────────────────────────────────────
/**
* Get the number of rerolls remaining for today.
* Returns MAX_DAILY_REROLLS if not set (for backward compatibility with old state).
*/
export function getRerollsRemaining(state: DailyMissionsState): number {
// If rerollsRemaining is not set (old state), default to max
if (state.rerollsRemaining === undefined || state.rerollsRemaining === null) {
return MAX_DAILY_REROLLS;
}
return state.rerollsRemaining;
}
/**
* Check if the user can reroll a mission
*/
export function canRerollMission(state: DailyMissionsState, missionId: string): boolean {
const rerollsRemaining = getRerollsRemaining(state);
if (rerollsRemaining <= 0) return false;
// Find the mission
const mission = state.missions.find((m) => m.id === missionId);
if (!mission) return false;
// Cannot reroll completed or claimed missions
if (mission.completed || mission.claimed) return false;
return true;
}
/**
* Select a replacement mission that:
* - Is not already in the current mission list
* - Is not the mission being replaced (avoid immediately giving back the same)
* - Respects the user's available stages
*
* Uses weighted random selection from eligible missions.
*/
export function selectReplacementMission(
currentMissions: DailyMission[],
missionToReplace: DailyMission,
availableStages?: BlobbiStage[]
): DailyMissionDefinition | null {
// Default to baby/adult if no stages provided (most common case)
const stagesToCheck = availableStages && availableStages.length > 0
? availableStages
: ['baby', 'adult'] as BlobbiStage[];
// Get IDs of missions that cannot be selected (current active missions)
const excludedIds = new Set<string>();
// Exclude all current missions EXCEPT the one being replaced
for (const m of currentMissions) {
if (m.id !== missionToReplace.id) {
excludedIds.add(m.id);
}
}
// Filter pool to eligible missions
const eligibleMissions = DAILY_MISSION_POOL.filter((m) => {
// Must not be an already-active mission (except the one being replaced)
if (excludedIds.has(m.id)) return false;
// Must not be the same mission being replaced
if (m.id === missionToReplace.id) return false;
// Must be available for user's stages
if (!isMissionAvailableForStages(m, stagesToCheck)) return false;
return true;
});
// If no eligible missions, return null
if (eligibleMissions.length === 0) {
return null;
}
// Use Math.random() for non-deterministic selection (rerolls should feel random)
const totalWeight = eligibleMissions.reduce((sum, m) => sum + m.weight, 0);
let pick = Math.random() * totalWeight;
for (const mission of eligibleMissions) {
pick -= mission.weight;
if (pick <= 0) {
return mission;
}
}
// Fallback to first eligible (shouldn't happen)
return eligibleMissions[0];
}
/**
* Reroll a mission, replacing it with a new one from the pool.
* Returns the updated state and the new mission, or null if reroll failed.
*/
export function rerollMission(
state: DailyMissionsState,
missionId: string,
availableStages?: BlobbiStage[]
): { state: DailyMissionsState; newMission: DailyMission } | null {
// Check if reroll is allowed
if (!canRerollMission(state, missionId)) {
return null;
}
// Find the mission index
const missionIndex = state.missions.findIndex((m) => m.id === missionId);
if (missionIndex === -1) {
return null;
}
const oldMission = state.missions[missionIndex];
// Select a replacement
const replacement = selectReplacementMission(state.missions, oldMission, availableStages);
if (!replacement) {
return null;
}
// Create the new mission instance
const newMission = createMissionFromDefinition(replacement);
// Update the missions array
const updatedMissions = [...state.missions];
updatedMissions[missionIndex] = newMission;
// Decrement rerolls remaining
const newRerollsRemaining = getRerollsRemaining(state) - 1;
return {
state: {
...state,
missions: updatedMissions,
rerollsRemaining: newRerollsRemaining,
},
newMission,
};
}
@@ -0,0 +1,100 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Grupo das pétalas com rotação -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="10s"
repeatCount="indefinite" />
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
</g>
<!-- Grupo das partículas giratórias -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="20s"
repeatCount="indefinite" />
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.8" />
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.6" />
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.7" />
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.5" />
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.9" />
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="88" cy="105" r="8" fill="white" />
<circle cx="112" cy="105" r="8" fill="white" />
<!-- Pupils (pupil + highlights) -->
<circle cx="88" cy="105" r="5" fill="#1f2937" />
<circle cx="112" cy="105" r="5" fill="#1f2937" />
<circle cx="90" cy="103" r="2" fill="white" />
<circle cx="114" cy="103" r="2" fill="white" />
<!-- Mouth -->
<path d="M 90 120 Q 100 128 110 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Bochechas -->
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<!-- Gradientes -->
<defs>
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fed7d7" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#e0e7ff" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dcfce7" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dbeafe" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,99 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Grupo das pétalas com rotação mais lenta (ou pode ser removido completamente) -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="20s"
repeatCount="indefinite" />
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
</g>
<!-- Grupo das partículas giratórias -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="30s"
repeatCount="indefinite" />
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.4" />
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.3" />
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.3" />
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.2" />
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.4" />
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Olhos dormindo -->
<path d="M 80 105 Q 88 108 96 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 104 105 Q 112 108 120 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Boca calma -->
<circle cx="100" cy="120" r="2" fill="#1f2937" />
<!-- Bochechas -->
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<!-- "Zzz" dormindo -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<!-- Gradientes -->
<defs>
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fed7d7" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#e0e7ff" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dcfce7" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dbeafe" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,100 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main leaf body - classic leaf shape -->
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
fill="url(#breezyBody)" />
<!-- Leaf veins - central vein -->
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
<!-- Side veins -->
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<!-- Inner leaf highlight -->
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
fill="url(#breezyInner)" opacity="0.6" />
<!-- Eyes (white base) -->
<circle cx="85" cy="90" r="10" fill="white" />
<circle cx="115" cy="90" r="10" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="85" cy="90" r="6" fill="#1f2937" />
<circle cx="115" cy="90" r="6" fill="#1f2937" />
<circle cx="87" cy="88" r="3" fill="white" />
<circle cx="117" cy="88" r="3" fill="white" />
<!-- Mouth -->
<path d="M 85 110 Q 100 120 115 110" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Little arms -->
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
<!-- Little legs -->
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
<!-- Floating leaves with rotation groups -->
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.8" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.6" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.7" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.5" />
</g>
</g>
<defs>
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#86efac" />
<stop offset="30%" stop-color="#4ade80" />
<stop offset="70%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#bbf7d0" />
<stop offset="100%" stop-color="#86efac" />
</radialGradient>
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#15803d" />
<stop offset="50%" stop-color="#16a34a" />
<stop offset="100%" stop-color="#15803d" />
</linearGradient>
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#86efac" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,95 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main leaf body -->
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
fill="url(#breezyBody)" />
<!-- Leaf veins -->
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<!-- Inner leaf highlight -->
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
fill="url(#breezyInner)" opacity="0.6" />
<!-- Olhos dormindo -->
<path d="M 75 90 Q 85 93 95 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 105 90 Q 115 93 125 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Boca tranquila -->
<circle cx="100" cy="110" r="2" fill="#1f2937" />
<!-- Little arms -->
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
<!-- Little legs -->
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
<!-- Floating leaves -->
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.6" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.5" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.5" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.4" />
</g>
</g>
<!-- "Zzz" dormindo -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<!-- Gradientes -->
<defs>
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#86efac" />
<stop offset="30%" stop-color="#4ade80" />
<stop offset="70%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#bbf7d0" />
<stop offset="100%" stop-color="#86efac" />
</radialGradient>
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#15803d" />
<stop offset="50%" stop-color="#16a34a" />
<stop offset="100%" stop-color="#15803d" />
</linearGradient>
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#86efac" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,75 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main cactus body -->
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" />
<!-- Cactus arms -->
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
<rect x="120" y="110" width="20" height="35" rx="10" fill="url(#cactiArm)" />
<!-- Cactus ridges -->
<line x1="92" y1="85" x2="92" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<line x1="100" y1="85" x2="100" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<line x1="108" y1="85" x2="108" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<!-- Eyes (white base) -->
<circle cx="90" cy="105" r="8" fill="white" />
<circle cx="110" cy="105" r="8" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="90" cy="105" r="5" fill="#1f2937" />
<circle cx="110" cy="105" r="5" fill="#1f2937" />
<circle cx="92" cy="103" r="2" fill="white" />
<circle cx="112" cy="103" r="2" fill="white" />
<!-- Mouth -->
<path d="M 92 120 Q 100 126 108 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Tiny spines -->
<circle cx="88" cy="90" r="1" fill="#65a30d" />
<circle cx="95" cy="95" r="1" fill="#65a30d" />
<circle cx="105" cy="92" r="1" fill="#65a30d" />
<circle cx="112" cy="88" r="1" fill="#65a30d" />
<circle cx="65" cy="110" r="1" fill="#65a30d" />
<circle cx="70" cy="120" r="1" fill="#65a30d" />
<circle cx="125" cy="115" r="1" fill="#65a30d" />
<circle cx="130" cy="125" r="1" fill="#65a30d" />
<!-- Little legs in pot -->
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#cactiPot)" />
<rect x="75" y="160" width="50" height="5" fill="url(#cactiPotRim)" rx="2" />
<!-- Blooming flower -->
<circle cx="100" cy="75" r="12" fill="url(#cactiFlower)" />
<circle cx="100" cy="75" r="8" fill="url(#cactiFlowerCenter)" />
<circle cx="100" cy="75" r="4" fill="#fbbf24" />
<defs>
<radialGradient id="cactiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a3e635" />
<stop offset="30%" stop-color="#84cc16" />
<stop offset="70%" stop-color="#65a30d" />
<stop offset="100%" stop-color="#4d7c0f" />
</radialGradient>
<radialGradient id="cactiArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#84cc16" />
<stop offset="100%" stop-color="#65a30d" />
</radialGradient>
<radialGradient id="cactiPot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#991b1b" />
</radialGradient>
<radialGradient id="cactiPotRim" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="cactiFlower" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="cactiFlowerCenter" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,74 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main cactus body -->
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" />
<!-- Cactus arms -->
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
<rect x="120" y="110" width="20" height="35" rx="10" fill="url(#cactiArm)" />
<!-- Cactus ridges -->
<line x1="92" y1="85" x2="92" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<line x1="100" y1="85" x2="100" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<line x1="108" y1="85" x2="108" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<!-- Sleeping eyes -->
<path d="M 82 105 Q 90 108 98 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 105 Q 110 108 118 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="120" r="2" fill="#1f2937" />
<!-- Tiny spines -->
<circle cx="88" cy="90" r="1" fill="#65a30d" />
<circle cx="95" cy="95" r="1" fill="#65a30d" />
<circle cx="105" cy="92" r="1" fill="#65a30d" />
<circle cx="112" cy="88" r="1" fill="#65a30d" />
<circle cx="65" cy="110" r="1" fill="#65a30d" />
<circle cx="70" cy="120" r="1" fill="#65a30d" />
<circle cx="125" cy="115" r="1" fill="#65a30d" />
<circle cx="130" cy="125" r="1" fill="#65a30d" />
<!-- Little legs in pot -->
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#cactiPot)" />
<rect x="75" y="160" width="50" height="5" fill="url(#cactiPotRim)" rx="2" />
<!-- Blooming flower -->
<circle cx="100" cy="75" r="12" fill="url(#cactiFlower)" />
<circle cx="100" cy="75" r="8" fill="url(#cactiFlowerCenter)" />
<circle cx="100" cy="75" r="4" fill="#fbbf24" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="cactiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a3e635" />
<stop offset="30%" stop-color="#84cc16" />
<stop offset="70%" stop-color="#65a30d" />
<stop offset="100%" stop-color="#4d7c0f" />
</radialGradient>
<radialGradient id="cactiArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#84cc16" />
<stop offset="100%" stop-color="#65a30d" />
</radialGradient>
<radialGradient id="cactiPot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#991b1b" />
</radialGradient>
<radialGradient id="cactiPotRim" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="cactiFlower" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="cactiFlowerCenter" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="cattiBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
<stop offset="40%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEar3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEarInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f3f4f6;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiNose3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#be185d;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiNoseHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fce7f3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiTailHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fed7aa;stop-opacity:1" />
</radialGradient>
<linearGradient id="cattiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Oval upright body -->
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
<!-- Triangle ears -->
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
<path d="M 132 72 L 142 48 L 118 62 Z" fill="url(#cattiEar3D)" />
<path d="M 70 62 L 64 52 L 76 58 Z" fill="url(#cattiEarInner)" />
<path d="M 130 62 L 136 52 L 124 58 Z" fill="url(#cattiEarInner)" />
<!-- Eyes (white/base eye shapes) -->
<ellipse cx="85" cy="100" rx="12" ry="16" fill="url(#cattiEyeWhite3D)" />
<ellipse cx="115" cy="100" rx="12" ry="16" fill="url(#cattiEyeWhite3D)" />
<!-- Pupils (pupil + highlights) -->
<ellipse cx="85" cy="100" rx="8" ry="12" fill="url(#cattiPupil3D)" />
<ellipse cx="115" cy="100" rx="8" ry="12" fill="url(#cattiPupil3D)" />
<ellipse cx="87" cy="97" rx="3" ry="4" fill="white" opacity="0.9" />
<ellipse cx="117" cy="97" rx="3" ry="4" fill="white" opacity="0.9" />
<!-- Nose -->
<path d="M 94 115 L 100 122 L 106 115 Z" fill="url(#cattiNose3D)" />
<path d="M 96 116 L 100 120 L 104 116 Z" fill="url(#cattiNoseHighlight)" />
<!-- Mouth -->
<path d="M 100 122 Q 88 128 82 122" stroke="url(#cattiMouth3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 100 122 Q 112 128 118 122" stroke="url(#cattiMouth3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Enhanced curved tail -->
<path d="M 145 140 Q 165 115 158 95 Q 148 75 165 65" stroke="url(#cattiTail3D)" stroke-width="22" fill="none" stroke-linecap="round" />
<path d="M 145 140 Q 163 117 156 97 Q 148 79 163 69" stroke="url(#cattiTailHighlight)" stroke-width="16" fill="none" stroke-linecap="round" />
<!-- Enhanced whiskers -->
<path d="M 48 108 Q 58 110 72 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 48 118 Q 58 120 72 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 128 108 Q 138 110 152 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 128 118 Q 138 120 152 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<!-- Soft fur texture details -->
<ellipse cx="75" cy="135" rx="3" ry="2" fill="rgba(255,255,255,0.2)" />
<ellipse cx="125" cy="130" rx="2.5" ry="2" fill="rgba(255,255,255,0.2)" />
<ellipse cx="90" cy="150" rx="2" ry="1.5" fill="rgba(255,255,255,0.15)" />
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="cattiBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
<stop offset="40%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEar3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEarInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f3f4f6;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiNose3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#be185d;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiNoseHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fce7f3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiTailHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fed7aa;stop-opacity:1" />
</radialGradient>
<linearGradient id="cattiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Oval upright body -->
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
<!-- Triangle ears -->
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
<path d="M 132 72 L 142 48 L 118 62 Z" fill="url(#cattiEar3D)" />
<path d="M 70 62 L 64 52 L 76 58 Z" fill="url(#cattiEarInner)" />
<path d="M 130 62 L 136 52 L 124 58 Z" fill="url(#cattiEarInner)" />
<!-- Sleeping eyes -->
<path d="M 73 100 Q 85 103 97 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 103 100 Q 115 103 127 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Enhanced cat nose -->
<path d="M 94 115 L 100 122 L 106 115 Z" fill="url(#cattiNose3D)" />
<path d="M 96 116 L 100 120 L 104 116 Z" fill="url(#cattiNoseHighlight)" />
<!-- Peaceful mouth -->
<circle cx="100" cy="125" r="2" fill="#1f2937" />
<!-- Enhanced curved tail -->
<path d="M 145 140 Q 165 115 158 95 Q 148 75 165 65" stroke="url(#cattiTail3D)" stroke-width="22" fill="none" stroke-linecap="round" />
<path d="M 145 140 Q 163 117 156 97 Q 148 79 163 69" stroke="url(#cattiTailHighlight)" stroke-width="16" fill="none" stroke-linecap="round" />
<!-- Enhanced whiskers -->
<path d="M 48 108 Q 58 110 72 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 48 118 Q 58 120 72 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 128 108 Q 138 110 152 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 128 118 Q 138 120 152 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<!-- Soft fur texture details -->
<ellipse cx="75" cy="135" rx="3" ry="2" fill="rgba(255,255,255,0.2)" />
<ellipse cx="125" cy="130" rx="2.5" ry="2" fill="rgba(255,255,255,0.2)" />
<ellipse cx="90" cy="150" rx="2" ry="1.5" fill="rgba(255,255,255,0.15)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,49 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main cloud body - multiple overlapping circles -->
<circle cx="100" cy="120" r="45" fill="url(#cloudiBody)" />
<circle cx="75" cy="110" r="35" fill="url(#cloudiBody)" />
<circle cx="125" cy="110" r="35" fill="url(#cloudiBody)" />
<circle cx="85" cy="95" r="25" fill="url(#cloudiBody)" />
<circle cx="115" cy="95" r="25" fill="url(#cloudiBody)" />
<circle cx="100" cy="85" r="30" fill="url(#cloudiBody)" />
<!-- Fluffy highlights -->
<circle cx="90" cy="100" r="20" fill="url(#cloudiHighlight)" opacity="0.6" />
<circle cx="110" cy="105" r="18" fill="url(#cloudiHighlight)" opacity="0.5" />
<circle cx="100" cy="90" r="15" fill="url(#cloudiHighlight)" opacity="0.7" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="88" cy="100" r="8" fill="white" />
<circle cx="112" cy="100" r="8" fill="white" />
<!-- Pupils (pupil + highlights) -->
<circle cx="88" cy="100" r="5" fill="#64748b" />
<circle cx="112" cy="100" r="5" fill="#64748b" />
<circle cx="90" cy="98" r="2" fill="white" />
<circle cx="114" cy="98" r="2" fill="white" />
<!-- Mouth -->
<path d="M 92 115 Q 100 122 108 115" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Floating raindrops -->
<circle cx="70" cy="140" r="3" fill="url(#cloudiRain)" opacity="0.8" />
<circle cx="130" cy="145" r="2.5" fill="url(#cloudiRain)" opacity="0.6" />
<circle cx="85" cy="155" r="2" fill="url(#cloudiRain)" opacity="0.7" />
<circle cx="115" cy="150" r="2.5" fill="url(#cloudiRain)" opacity="0.5" />
<defs>
<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="50%" stop-color="#f1f5f9" />
<stop offset="100%" stop-color="#e2e8f0" />
</radialGradient>
<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.5)" />
</radialGradient>
<radialGradient id="cloudiRain" cx="0.5" cy="0.3">
<stop offset="0%" stop-color="#60a5fa" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,51 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main cloud body - multiple overlapping circles -->
<circle cx="100" cy="120" r="45" fill="url(#cloudiBody)" />
<circle cx="75" cy="110" r="35" fill="url(#cloudiBody)" />
<circle cx="125" cy="110" r="35" fill="url(#cloudiBody)" />
<circle cx="85" cy="95" r="25" fill="url(#cloudiBody)" />
<circle cx="115" cy="95" r="25" fill="url(#cloudiBody)" />
<circle cx="100" cy="85" r="30" fill="url(#cloudiBody)" />
<!-- Fluffy highlights -->
<circle cx="90" cy="100" r="20" fill="url(#cloudiHighlight)" opacity="0.6" />
<circle cx="110" cy="105" r="18" fill="url(#cloudiHighlight)" opacity="0.5" />
<circle cx="100" cy="90" r="15" fill="url(#cloudiHighlight)" opacity="0.7" />
<!-- Sleeping eyes -->
<path d="M 80 100 Q 88 103 96 100" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 104 100 Q 112 103 120 100" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#64748b" />
<!-- Floating raindrops with gentle animation -->
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 0,5; 0,0" dur="3s" repeatCount="indefinite" />
<circle cx="70" cy="140" r="3" fill="url(#cloudiRain)" opacity="0.6" />
<circle cx="130" cy="145" r="2.5" fill="url(#cloudiRain)" opacity="0.4" />
<circle cx="85" cy="155" r="2" fill="url(#cloudiRain)" opacity="0.5" />
<circle cx="115" cy="150" r="2.5" fill="url(#cloudiRain)" opacity="0.3" />
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="50%" stop-color="#f1f5f9" />
<stop offset="100%" stop-color="#e2e8f0" />
</radialGradient>
<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.5)" />
</radialGradient>
<radialGradient id="cloudiRain" cx="0.5" cy="0.3">
<stop offset="0%" stop-color="#60a5fa" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Crystal gradients -->
<radialGradient id="crystiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
<stop offset="30%" style="stop-color:#e9d5ff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</radialGradient>
<radialGradient id="crystiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</radialGradient>
<linearGradient id="crystiFacet1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet2" x1="1" y1="0" x2="0" y2="1">
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet4" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet5" x1="1" y1="1" x2="0" y2="0">
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet6" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fb7185;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e11d48;stop-opacity:1" />
</linearGradient>
<radialGradient id="crystiEye" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
</radialGradient>
<linearGradient id="crystiSmile" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="50%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main crystal body - rounded hexagon shape -->
<path d="M 100 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" />
<path d="M 100 55 L 135 82 L 135 128 L 100 155 L 65 128 L 65 82 Z" fill="url(#crystiInner)" opacity="0.7" />
<!-- Crystal segments with rounded edges -->
<path d="M 100 50 L 125 70 L 100 105 L 75 70 Z" fill="url(#crystiFacet1)" opacity="0.8" />
<path d="M 75 70 L 100 105 L 60 80 L 60 105 Z" fill="url(#crystiFacet2)" opacity="0.7" />
<path d="M 125 70 L 140 80 L 140 105 L 100 105 Z" fill="url(#crystiFacet3)" opacity="0.7" />
<path d="M 60 105 L 100 105 L 75 140 L 60 130 Z" fill="url(#crystiFacet4)" opacity="0.6" />
<path d="M 100 105 L 140 105 L 125 140 L 100 105 Z" fill="url(#crystiFacet5)" opacity="0.6" />
<path d="M 75 140 L 100 105 L 125 140 L 100 160 Z" fill="url(#crystiFacet6)" opacity="0.8" />
<!-- Eyes (white base) -->
<circle cx="88" cy="95" r="10" fill="url(#crystiEye)" />
<circle cx="112" cy="95" r="10" fill="url(#crystiEye)" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="88" cy="95" r="6" fill="#1e1b4b" />
<circle cx="112" cy="95" r="6" fill="#1e1b4b" />
<circle cx="90" cy="93" r="3" fill="white" />
<circle cx="114" cy="93" r="3" fill="white" />
<!-- Mouth -->
<path d="M 90 115 Q 100 123 110 115" stroke="url(#crystiSmile)" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Floating sparkles -->
<circle cx="65" cy="65" r="2" fill="#fbbf24" opacity="0.9" />
<circle cx="135" cy="70" r="1.5" fill="#f472b6" opacity="0.8" />
<circle cx="70" cy="140" r="1" fill="#06b6d4" opacity="0.7" />
<circle cx="130" cy="135" r="2" fill="#fbbf24" opacity="0.6" />
<circle cx="50" cy="105" r="1.5" fill="#f472b6" opacity="0.8" />
<circle cx="150" cy="110" r="1" fill="#06b6d4" opacity="0.9" />
<circle cx="100" cy="40" r="1.5" fill="#fbbf24" opacity="0.7" />
<circle cx="100" cy="170" r="1" fill="#f472b6" opacity="0.8" />
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Crystal gradients -->
<radialGradient id="crystiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
<stop offset="30%" style="stop-color:#e9d5ff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</radialGradient>
<radialGradient id="crystiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</radialGradient>
<linearGradient id="crystiFacet1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet2" x1="1" y1="0" x2="0" y2="1">
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet4" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet5" x1="1" y1="1" x2="0" y2="0">
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet6" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fb7185;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e11d48;stop-opacity:1" />
</linearGradient>
<radialGradient id="crystiEye" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
</radialGradient>
<linearGradient id="crystiSmile" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="50%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main crystal body - rounded hexagon shape -->
<path d="M 100 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" />
<path d="M 100 55 L 135 82 L 135 128 L 100 155 L 65 128 L 65 82 Z" fill="url(#crystiInner)" opacity="0.7" />
<!-- Crystal segments with rounded edges -->
<path d="M 100 50 L 125 70 L 100 105 L 75 70 Z" fill="url(#crystiFacet1)" opacity="0.6" />
<path d="M 75 70 L 100 105 L 60 80 L 60 105 Z" fill="url(#crystiFacet2)" opacity="0.5" />
<path d="M 125 70 L 140 80 L 140 105 L 100 105 Z" fill="url(#crystiFacet3)" opacity="0.5" />
<path d="M 60 105 L 100 105 L 75 140 L 60 130 Z" fill="url(#crystiFacet4)" opacity="0.4" />
<path d="M 100 105 L 140 105 L 125 140 L 100 105 Z" fill="url(#crystiFacet5)" opacity="0.4" />
<path d="M 75 140 L 100 105 L 125 140 L 100 160 Z" fill="url(#crystiFacet6)" opacity="0.6" />
<!-- Sleeping eyes -->
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 95 Q 112 98 122 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#8b5cf6" />
<!-- Floating sparkles with gentle animation -->
<g>
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="360 100 100" dur="15s" repeatCount="indefinite" />
<circle cx="65" cy="65" r="2" fill="#fbbf24" opacity="0.6" />
<circle cx="135" cy="70" r="1.5" fill="#f472b6" opacity="0.5" />
<circle cx="70" cy="140" r="1" fill="#06b6d4" opacity="0.4" />
<circle cx="130" cy="135" r="2" fill="#fbbf24" opacity="0.3" />
</g>
<g>
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="-360 100 100" dur="20s" repeatCount="indefinite" />
<circle cx="50" cy="105" r="1.5" fill="#f472b6" opacity="0.5" />
<circle cx="150" cy="110" r="1" fill="#06b6d4" opacity="0.6" />
<circle cx="100" cy="40" r="1.5" fill="#fbbf24" opacity="0.4" />
<circle cx="100" cy="170" r="1" fill="#f472b6" opacity="0.5" />
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,89 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Water gradients -->
<radialGradient id="droppiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#67e8f9;stop-opacity:1" />
<stop offset="30%" style="stop-color:#22d3ee;stop-opacity:1" />
<stop offset="70%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#e0f2fe;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7dd3fc;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiHighlight" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiArm" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiLeg" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiDroplet" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#7dd3fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Main water drop body -->
<path d="M 100 40 Q 100 30 100 40 Q 135 60 140 110 Q 140 150 100 165 Q 60 150 60 110 Q 65 60 100 40"
fill="url(#droppiBody)" />
<!-- Inner water reflection -->
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
<ellipse cx="90" cy="80" rx="20" ry="25" fill="url(#droppiHighlight)" opacity="0.7" />
<!-- Eyes (white base) -->
<circle cx="85" cy="95" r="12" fill="white" />
<circle cx="115" cy="95" r="12" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="85" cy="95" r="8" fill="#0891b2" />
<circle cx="115" cy="95" r="8" fill="#0891b2" />
<circle cx="88" cy="92" r="4" fill="white" />
<circle cx="118" cy="92" r="4" fill="white" />
<!-- Mouth -->
<path d="M 88 115 Q 100 123 112 115" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Little arms -->
<ellipse cx="60" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(-25 60 110)" />
<ellipse cx="140" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(25 140 110)" />
<!-- Little legs -->
<ellipse cx="85" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
<ellipse cx="115" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
<!-- Water droplets floating around - grouped with rotation -->
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
<circle cx="-45" cy="-35" r="3" fill="url(#droppiDroplet)" opacity="0.8" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
<circle cx="45" cy="-30" r="2.5" fill="url(#droppiDroplet)" opacity="0.6" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
<circle cx="-50" cy="25" r="2" fill="url(#droppiDroplet)" opacity="0.7" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
<circle cx="50" cy="20" r="2.5" fill="url(#droppiDroplet)" opacity="0.5" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,88 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Water gradients -->
<radialGradient id="droppiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#67e8f9;stop-opacity:1" />
<stop offset="30%" style="stop-color:#22d3ee;stop-opacity:1" />
<stop offset="70%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#e0f2fe;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7dd3fc;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiHighlight" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiArm" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiLeg" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiDroplet" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#7dd3fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Main water drop body -->
<path d="M 100 40 Q 100 30 100 40 Q 135 60 140 110 Q 140 150 100 165 Q 60 150 60 110 Q 65 60 100 40"
fill="url(#droppiBody)" />
<!-- Inner water reflection -->
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
<ellipse cx="90" cy="80" rx="20" ry="25" fill="url(#droppiHighlight)" opacity="0.7" />
<!-- Sleeping eyes -->
<path d="M 73 95 Q 85 98 97 95" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 103 95 Q 115 98 127 95" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#0891b2" />
<!-- Little arms -->
<ellipse cx="60" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(-25 60 110)" />
<ellipse cx="140" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(25 140 110)" />
<!-- Little legs -->
<ellipse cx="85" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
<ellipse cx="115" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
<!-- Water droplets floating around - grouped with slower rotation -->
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="12s" repeatCount="indefinite" />
<circle cx="-45" cy="-35" r="3" fill="url(#droppiDroplet)" opacity="0.5" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="15s" repeatCount="indefinite" />
<circle cx="45" cy="-30" r="2.5" fill="url(#droppiDroplet)" opacity="0.4" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="10s" repeatCount="indefinite" />
<circle cx="-50" cy="25" r="2" fill="url(#droppiDroplet)" opacity="0.4" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="18s" repeatCount="indefinite" />
<circle cx="50" cy="20" r="2.5" fill="url(#droppiDroplet)" opacity="0.3" />
</g>
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,76 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<!-- Flame gradients -->
<radialGradient id="flammiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fbbf24" />
<stop offset="30%" stop-color="#f97316" />
<stop offset="70%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="flammiInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fde047" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f97316" />
</radialGradient>
<radialGradient id="flammiCore" cx="0.5" cy="0.4">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fde047" />
</radialGradient>
<radialGradient id="flammiArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#f97316" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="flammiLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#b91c1c" />
</radialGradient>
<radialGradient id="flammiEmber" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fde047" />
<stop offset="100%" stop-color="#f97316" />
</radialGradient>
</defs>
<!-- Larger rotating flames -->
<g transform="rotate(0 100 110)">
<path d="M45 80 Q50 65 55 80 Q50 90 45 80 Z" fill="url(#flammiEmber)" opacity="0.8" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="4s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M155 85 Q160 70 165 85 Q160 95 155 85 Z" fill="url(#flammiEmber)" opacity="0.6" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="6s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M40 130 Q45 115 50 130 Q45 140 40 130 Z" fill="url(#flammiEmber)" opacity="0.7" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="7s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M160 125 Q165 110 170 125 Q165 135 160 125 Z" fill="url(#flammiEmber)" opacity="0.5" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="5s" repeatCount="indefinite" />
</g>
<!-- Flammy Body -->
<path d="M 100 160 Q 60 140 50 110 Q 45 80 70 60 Q 80 40 100 25 Q 120 40 130 60 Q 155 80 150 110 Q 140 140 100 160 Z" fill="url(#flammiBody)" />
<path d="M 100 155 Q 65 138 58 115 Q 55 90 75 70 Q 82 50 100 35 Q 118 50 125 70 Q 145 90 142 115 Q 135 138 100 155 Z" fill="url(#flammiInner)" opacity="0.8" />
<path d="M 100 145 Q 70 130 65 110 Q 62 95 80 80 Q 85 65 100 55 Q 115 65 120 80 Q 138 95 135 110 Q 130 130 100 145 Z" fill="url(#flammiCore)" opacity="0.9" />
<!-- Eyes (white base) -->
<circle cx="88" cy="100" r="10" fill="white" />
<circle cx="112" cy="100" r="10" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="88" cy="100" r="6" fill="#1f2937" />
<circle cx="112" cy="100" r="6" fill="#1f2937" />
<circle cx="90" cy="98" r="3" fill="white" />
<circle cx="114" cy="98" r="3" fill="white" />
<!-- Mouth -->
<path d="M 88 115 Q 100 125 112 115" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Arms -->
<ellipse cx="55" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(-30 55 110)" />
<ellipse cx="145" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(30 145 110)" />
<!-- Legs -->
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,75 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<!-- Flame gradients -->
<radialGradient id="flammiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fbbf24" />
<stop offset="30%" stop-color="#f97316" />
<stop offset="70%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="flammiInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fde047" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f97316" />
</radialGradient>
<radialGradient id="flammiCore" cx="0.5" cy="0.4">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fde047" />
</radialGradient>
<radialGradient id="flammiArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#f97316" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="flammiLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#b91c1c" />
</radialGradient>
<radialGradient id="flammiEmber" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fde047" />
<stop offset="100%" stop-color="#f97316" />
</radialGradient>
</defs>
<!-- Slower rotating flames for sleeping state -->
<g transform="rotate(0 100 110)">
<path d="M45 80 Q50 65 55 80 Q50 90 45 80 Z" fill="url(#flammiEmber)" opacity="0.5" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="8s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M155 85 Q160 70 165 85 Q160 95 155 85 Z" fill="url(#flammiEmber)" opacity="0.4" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="12s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M40 130 Q45 115 50 130 Q45 140 40 130 Z" fill="url(#flammiEmber)" opacity="0.4" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="14s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M160 125 Q165 110 170 125 Q165 135 160 125 Z" fill="url(#flammiEmber)" opacity="0.3" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="10s" repeatCount="indefinite" />
</g>
<!-- Flammy Body -->
<path d="M 100 160 Q 60 140 50 110 Q 45 80 70 60 Q 80 40 100 25 Q 120 40 130 60 Q 155 80 150 110 Q 140 140 100 160 Z" fill="url(#flammiBody)" />
<path d="M 100 155 Q 65 138 58 115 Q 55 90 75 70 Q 82 50 100 35 Q 118 50 125 70 Q 145 90 142 115 Q 135 138 100 155 Z" fill="url(#flammiInner)" opacity="0.8" />
<path d="M 100 145 Q 70 130 65 110 Q 62 95 80 80 Q 85 65 100 55 Q 115 65 120 80 Q 138 95 135 110 Q 130 130 100 145 Z" fill="url(#flammiCore)" opacity="0.9" />
<!-- Sleeping eyes -->
<path d="M 78 100 Q 88 103 98 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 100 Q 112 103 122 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#1f2937" />
<!-- Arms -->
<ellipse cx="55" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(-30 55 110)" />
<ellipse cx="145" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(30 145 110)" />
<!-- Legs -->
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="froggiBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
<stop offset="40%" style="stop-color:#22c55e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#15803d;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiEyeBase3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<linearGradient id="froggiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
<linearGradient id="froggiMouthHighlight" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
<stop offset="50%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</linearGradient>
<radialGradient id="froggiNostril3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiNostrilHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiFeet3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiFeetHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4ade80;stop-opacity:1" />
</radialGradient>
<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Flattened oval body -->
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
<!-- Big circular pop-out eyes -->
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
<circle cx="130" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="70" cy="80" r="22" fill="url(#froggiEyeWhite3D)" />
<circle cx="130" cy="80" r="22" fill="url(#froggiEyeWhite3D)" />
<!-- Pupils (pupil + highlights) -->
<circle cx="70" cy="80" r="16" fill="url(#froggiPupil3D)" />
<circle cx="130" cy="80" r="16" fill="url(#froggiPupil3D)" />
<circle cx="74" cy="76" r="6" fill="white" opacity="0.9" />
<circle cx="134" cy="76" r="6" fill="white" opacity="0.9" />
<!-- Mouth -->
<path d="M 45 120 Q 100 145 155 120" stroke="url(#froggiMouth3D)" stroke-width="5" fill="none" stroke-linecap="round" />
<path d="M 50 122 Q 100 142 150 122" stroke="url(#froggiMouthHighlight)" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Enhanced nostrils -->
<ellipse cx="90" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
<ellipse cx="110" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
<ellipse cx="90" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
<ellipse cx="110" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
<!-- Enhanced webbed feet -->
<ellipse cx="60" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
<ellipse cx="140" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
<ellipse cx="60" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
<ellipse cx="140" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
<!-- Enhanced webbed toes -->
<path d="M 43 160 Q 47 155 52 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 53 160 Q 57 155 62 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 63 160 Q 67 155 72 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 123 160 Q 127 155 132 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 133 160 Q 137 155 142 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 143 160 Q 147 155 152 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Soft skin texture details -->
<ellipse cx="75" cy="135" rx="4" ry="3" fill="rgba(255,255,255,0.2)" />
<ellipse cx="125" cy="130" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.2)" />
<ellipse cx="85" cy="145" rx="3" ry="2" fill="rgba(255,255,255,0.15)" />
<ellipse cx="115" cy="140" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.15)" />
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="froggiBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
<stop offset="40%" style="stop-color:#22c55e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#15803d;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiEyeBase3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<linearGradient id="froggiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
<linearGradient id="froggiMouthHighlight" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
<stop offset="50%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</linearGradient>
<radialGradient id="froggiNostril3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiNostrilHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiFeet3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiFeetHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4ade80;stop-opacity:1" />
</radialGradient>
<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Flattened oval body -->
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
<!-- Big circular pop-out eyes -->
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
<circle cx="130" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
<!-- Sleeping eyes -->
<path d="M 54 80 Q 70 83 86 80" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 114 80 Q 130 83 146 80" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="125" r="2" fill="#1e293b" />
<!-- Enhanced nostrils -->
<ellipse cx="90" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
<ellipse cx="110" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
<ellipse cx="90" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
<ellipse cx="110" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
<!-- Enhanced webbed feet -->
<ellipse cx="60" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
<ellipse cx="140" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
<ellipse cx="60" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
<ellipse cx="140" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
<!-- Enhanced webbed toes -->
<path d="M 43 160 Q 47 155 52 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 53 160 Q 57 155 62 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 63 160 Q 67 155 72 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 123 160 Q 127 155 132 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 133 160 Q 137 155 142 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 143 160 Q 147 155 152 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Soft skin texture details -->
<ellipse cx="75" cy="135" rx="4" ry="3" fill="rgba(255,255,255,0.2)" />
<ellipse cx="125" cy="130" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.2)" />
<ellipse cx="85" cy="145" rx="3" ry="2" fill="rgba(255,255,255,0.15)" />
<ellipse cx="115" cy="140" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.15)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1,116 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Sunflower stem -->
<rect x="96" y="120" width="8" height="55" fill="url(#leafyStem)" rx="4" />
<rect x="98" y="125" width="4" height="50" fill="url(#leafyStemHighlight)" rx="2" opacity="0.6" />
<!-- Stem leaves -->
<ellipse cx="85" cy="140" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(-30 85 140)" />
<ellipse cx="115" cy="150" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(30 115 150)" />
<ellipse cx="87" cy="140" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(-30 87 140)" opacity="0.7" />
<ellipse cx="113" cy="150" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(30 113 150)" opacity="0.7" />
<!-- Sunflower petals - outer ring -->
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(0 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(22.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(45 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(67.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(90 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(112.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(135 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(157.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(180 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(202.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(225 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(247.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(270 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(292.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(315 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(337.5 100 85)" />
<!-- Sunflower center - outer ring -->
<circle cx="100" cy="85" r="30" fill="url(#leafyCenter)" />
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
<!-- Eyes (white base) -->
<circle cx="90" cy="82" r="8" fill="white" />
<circle cx="110" cy="82" r="8" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="90" cy="82" r="5" fill="#1f2937" />
<circle cx="110" cy="82" r="5" fill="#1f2937" />
<circle cx="92" cy="80" r="2" fill="white" />
<circle cx="112" cy="80" r="2" fill="white" />
<!-- Mouth -->
<path d="M 88 92 Q 100 100 112 92" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Floating pollen -->
<circle cx="55" cy="60" r="2" fill="url(#leafyPollen)" opacity="0.8" />
<circle cx="145" cy="65" r="1.5" fill="url(#leafyPollen)" opacity="0.6" />
<circle cx="50" cy="110" r="1" fill="url(#leafyPollen)" opacity="0.7" />
<circle cx="150" cy="105" r="2" fill="url(#leafyPollen)" opacity="0.5" />
<circle cx="75" cy="45" r="1.5" fill="url(#leafyPollen)" opacity="0.9" />
<circle cx="125" cy="50" r="1" fill="url(#leafyPollen)" opacity="0.8" />
<!-- Leavy pot -->
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#leafyPot)" />
<rect x="75" y="160" width="50" height="5" fill="url(#leafyPotRim)" rx="2" />
<defs>
<radialGradient id="leafyStem" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="leafyStemHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="leafyStemLeaf" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="leafyStemLeafHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#86efac" />
<stop offset="100%" stop-color="#4ade80" />
</radialGradient>
<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
<stop offset="100%" stop-color="#eab308" />
<!-- <stop offset="70%" stop-color="#eab308" /> -->
<stop offset="30%" stop-color="#fde047" />
<stop offset="0%" stop-color="#ffce09" />
</radialGradient>
<radialGradient id="leafyCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a16207" />
<stop offset="50%" stop-color="#92400e" />
<stop offset="100%" stop-color="#78350f" />
</radialGradient>
<radialGradient id="leafyCenterInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#d97706" />
<stop offset="100%" stop-color="#a16207" />
</radialGradient>
<radialGradient id="leafySeed" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#451a03" />
<stop offset="100%" stop-color="#292524" />
</radialGradient>
<radialGradient id="leafyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="leafyRoot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a16207" />
<stop offset="100%" stop-color="#78350f" />
</radialGradient>
<radialGradient id="leafyPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fde047" />
</radialGradient>
<radialGradient id="leafyPot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#991b1b" />
</radialGradient>
<radialGradient id="leafyPotRim" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,113 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Sunflower stem -->
<rect x="96" y="120" width="8" height="55" fill="url(#leafyStem)" rx="4" />
<rect x="98" y="125" width="4" height="50" fill="url(#leafyStemHighlight)" rx="2" opacity="0.6" />
<!-- Stem leaves -->
<ellipse cx="85" cy="140" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(-30 85 140)" />
<ellipse cx="115" cy="150" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(30 115 150)" />
<ellipse cx="87" cy="140" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(-30 87 140)" opacity="0.7" />
<ellipse cx="113" cy="150" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(30 113 150)" opacity="0.7" />
<!-- Sunflower petals - outer ring -->
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(0 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(22.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(45 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(67.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(90 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(112.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(135 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(157.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(180 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(202.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(225 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(247.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(270 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(292.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(315 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(337.5 100 85)" />
<!-- Sunflower center - outer ring -->
<circle cx="100" cy="85" r="30" fill="url(#leafyCenter)" />
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
<!-- Sleeping eyes -->
<path d="M 82 82 Q 90 85 98 82" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 82 Q 110 85 118 82" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="92" r="2" fill="#1f2937" />
<!-- Little arms - small leaves -->
<ellipse cx="60" cy="85" rx="12" ry="6" fill="url(#leafyArm)" transform="rotate(-20 60 85)" />
<ellipse cx="140" cy="85" rx="12" ry="6" fill="url(#leafyArm)" transform="rotate(20 140 85)" />
<!-- Base/roots -->
<ellipse cx="95" cy="170" rx="8" ry="6" fill="url(#leafyRoot)" />
<ellipse cx="105" cy="170" rx="8" ry="6" fill="url(#leafyRoot)" />
<!-- Floating pollen with gentle animation -->
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 2,3; 0,0" dur="4s" repeatCount="indefinite" />
<circle cx="55" cy="60" r="2" fill="url(#leafyPollen)" opacity="0.5" />
<circle cx="145" cy="65" r="1.5" fill="url(#leafyPollen)" opacity="0.4" />
<circle cx="50" cy="110" r="1" fill="url(#leafyPollen)" opacity="0.4" />
<circle cx="150" cy="105" r="2" fill="url(#leafyPollen)" opacity="0.3" />
<circle cx="75" cy="45" r="1.5" fill="url(#leafyPollen)" opacity="0.6" />
<circle cx="125" cy="50" r="1" fill="url(#leafyPollen)" opacity="0.5" />
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="leafyStem" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="leafyStemHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="leafyStemLeaf" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="leafyStemLeafHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#86efac" />
<stop offset="100%" stop-color="#4ade80" />
</radialGradient>
<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="30%" stop-color="#fde047" />
<stop offset="100%" stop-color="#eab308" />
</radialGradient>
<radialGradient id="leafyCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a16207" />
<stop offset="50%" stop-color="#92400e" />
<stop offset="100%" stop-color="#78350f" />
</radialGradient>
<radialGradient id="leafyCenterInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#d97706" />
<stop offset="100%" stop-color="#a16207" />
</radialGradient>
<radialGradient id="leafySeed" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#451a03" />
<stop offset="100%" stop-color="#292524" />
</radialGradient>
<radialGradient id="leafyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="leafyRoot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a16207" />
<stop offset="100%" stop-color="#78350f" />
</radialGradient>
<radialGradient id="leafyPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fde047" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,72 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Mushroom stem -->
<ellipse cx="100" cy="140" rx="25" ry="40" fill="url(#mushieStem)" />
<ellipse cx="100" cy="135" rx="20" ry="35" fill="url(#mushieStemHighlight)" opacity="0.6" />
<!-- Mushroom cap -->
<path d="M 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" />
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
<!-- Cap spots -->
<circle cx="80" cy="85" r="8" fill="white" opacity="0.8" />
<circle cx="120" cy="80" r="10" fill="white" opacity="0.8" />
<circle cx="100" cy="95" r="6" fill="white" opacity="0.7" />
<circle cx="130" cy="100" r="5" fill="white" opacity="0.6" />
<circle cx="70" cy="100" r="5" fill="white" opacity="0.6" />
<!-- Eyes (white base) -->
<circle cx="88" cy="130" r="8" fill="white" />
<circle cx="112" cy="130" r="8" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="88" cy="130" r="5" fill="#1f2937" />
<circle cx="112" cy="130" r="5" fill="#1f2937" />
<circle cx="90" cy="128" r="2" fill="white" />
<circle cx="114" cy="128" r="2" fill="white" />
<!-- Mouth -->
<path d="M 88 145 Q 100 153 112 145" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Little arms -->
<ellipse cx="70" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(-20 70 140)" />
<ellipse cx="130" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(20 130 140)" />
<!-- Floating spores -->
<circle cx="55" cy="120" r="2" fill="url(#mushieSpore)" opacity="0.8" />
<circle cx="145" cy="115" r="1.5" fill="url(#mushieSpore)" opacity="0.6" />
<circle cx="50" cy="90" r="1" fill="url(#mushieSpore)" opacity="0.7" />
<circle cx="150" cy="95" r="2" fill="url(#mushieSpore)" opacity="0.5" />
<circle cx="65" cy="70" r="1.5" fill="url(#mushieSpore)" opacity="0.6" />
<circle cx="135" cy="65" r="1" fill="url(#mushieSpore)" opacity="0.8" />
<defs>
<radialGradient id="mushieStem" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="30%" stop-color="#fde68a" />
<stop offset="70%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="mushieStemHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="mushieCap" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#f87171" />
<stop offset="30%" stop-color="#ef4444" />
<stop offset="70%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#b91c1c" />
</radialGradient>
<radialGradient id="mushieCapHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fca5a5" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="mushieArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fde68a" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="mushieSpore" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#c084fc" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,74 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Mushroom stem -->
<ellipse cx="100" cy="140" rx="25" ry="40" fill="url(#mushieStem)" />
<ellipse cx="100" cy="135" rx="20" ry="35" fill="url(#mushieStemHighlight)" opacity="0.6" />
<!-- Mushroom cap -->
<path d="M 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" />
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
<!-- Cap spots -->
<circle cx="80" cy="85" r="8" fill="white" opacity="0.8" />
<circle cx="120" cy="80" r="10" fill="white" opacity="0.8" />
<circle cx="100" cy="95" r="6" fill="white" opacity="0.7" />
<circle cx="130" cy="100" r="5" fill="white" opacity="0.6" />
<circle cx="70" cy="100" r="5" fill="white" opacity="0.6" />
<!-- Sleeping eyes on stem -->
<path d="M 80 130 Q 88 133 96 130" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 104 130 Q 112 133 120 130" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="145" r="2" fill="#1f2937" />
<!-- Little arms -->
<ellipse cx="70" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(-20 70 140)" />
<ellipse cx="130" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(20 130 140)" />
<!-- Floating spores with gentle animation -->
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 1,2; 0,0" dur="5s" repeatCount="indefinite" />
<circle cx="55" cy="120" r="2" fill="url(#mushieSpore)" opacity="0.5" />
<circle cx="145" cy="115" r="1.5" fill="url(#mushieSpore)" opacity="0.4" />
<circle cx="50" cy="90" r="1" fill="url(#mushieSpore)" opacity="0.4" />
<circle cx="150" cy="95" r="2" fill="url(#mushieSpore)" opacity="0.3" />
<circle cx="65" cy="70" r="1.5" fill="url(#mushieSpore)" opacity="0.4" />
<circle cx="135" cy="65" r="1" fill="url(#mushieSpore)" opacity="0.5" />
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="mushieStem" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="30%" stop-color="#fde68a" />
<stop offset="70%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="mushieStemHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="mushieCap" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#f87171" />
<stop offset="30%" stop-color="#ef4444" />
<stop offset="70%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#b91c1c" />
</radialGradient>
<radialGradient id="mushieCapHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fca5a5" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="mushieArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fde68a" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="mushieSpore" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#c084fc" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="owliBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
<stop offset="40%" style="stop-color:#a8a29e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#78716c;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliEar3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliEarInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliBeak3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliBeakHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliWing3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliWingHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a8a29e;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Round body -->
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" />
<!-- Triangle ears -->
<path d="M 60 70 L 70 48 L 82 70 Z" fill="url(#owliEar3D)" />
<path d="M 118 70 L 130 48 L 140 70 Z" fill="url(#owliEar3D)" />
<path d="M 65 65 L 70 52 L 77 65 Z" fill="url(#owliEarInner)" />
<path d="M 123 65 L 130 52 L 135 65 Z" fill="url(#owliEarInner)" />
<!-- Eyes (white base) -->
<circle cx="80" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
<circle cx="120" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="80" cy="100" r="14" fill="url(#owliPupil3D)" />
<circle cx="120" cy="100" r="14" fill="url(#owliPupil3D)" />
<circle cx="84" cy="96" r="5" fill="white" opacity="0.9" />
<circle cx="124" cy="96" r="5" fill="white" opacity="0.9" />
<!-- Enhanced beak -->
<path d="M 100 112 L 94 122 L 100 128 L 106 122 Z" fill="url(#owliBeak3D)" />
<path d="M 100 114 L 96 120 L 100 124 L 104 120 Z" fill="url(#owliBeakHighlight)" />
<!-- Wing details -->
<ellipse cx="48" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(-20 48 110)" />
<ellipse cx="152" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(20 152 110)" />
<ellipse cx="50" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(-20 50 108)" />
<ellipse cx="150" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(20 150 108)" />
<!-- Soft feather texture details -->
<circle cx="70" cy="130" r="3" fill="rgba(255,255,255,0.2)" />
<circle cx="130" cy="125" r="2.5" fill="rgba(255,255,255,0.2)" />
<circle cx="85" cy="140" r="2" fill="rgba(255,255,255,0.15)" />
<circle cx="115" cy="135" r="2.5" fill="rgba(255,255,255,0.15)" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="owliBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
<stop offset="40%" style="stop-color:#a8a29e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#78716c;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliEar3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliEarInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliBeak3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliBeakHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliWing3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#a8a29e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#57534e;stop-opacity:1" />
</radialGradient>
<radialGradient id="owliWingHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#d6d3d1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a8a29e;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Round body -->
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" />
<!-- Triangle ears -->
<path d="M 60 70 L 70 48 L 82 70 Z" fill="url(#owliEar3D)" />
<path d="M 118 70 L 130 48 L 140 70 Z" fill="url(#owliEar3D)" />
<path d="M 65 65 L 70 52 L 77 65 Z" fill="url(#owliEarInner)" />
<path d="M 123 65 L 130 52 L 135 65 Z" fill="url(#owliEarInner)" />
<!-- Large expressive eyes -->
<circle cx="80" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
<circle cx="120" cy="100" r="22" fill="url(#owliEyeWhite3D)" />
<!-- Sleeping eyes -->
<path d="M 58 100 Q 80 103 102 100" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 98 100 Q 120 103 142 100" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Enhanced beak -->
<path d="M 100 112 L 94 122 L 100 128 L 106 122 Z" fill="url(#owliBeak3D)" />
<path d="M 100 114 L 96 120 L 100 124 L 104 120 Z" fill="url(#owliBeakHighlight)" />
<!-- Wing details -->
<ellipse cx="48" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(-20 48 110)" />
<ellipse cx="152" cy="110" rx="16" ry="32" fill="url(#owliWing3D)" transform="rotate(20 152 110)" />
<ellipse cx="50" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(-20 50 108)" />
<ellipse cx="150" cy="108" rx="12" ry="25" fill="url(#owliWingHighlight)" transform="rotate(20 150 108)" />
<!-- Soft feather texture details -->
<circle cx="70" cy="130" r="3" fill="rgba(255,255,255,0.2)" />
<circle cx="130" cy="125" r="2.5" fill="rgba(255,255,255,0.2)" />
<circle cx="85" cy="140" r="2" fill="rgba(255,255,255,0.15)" />
<circle cx="115" cy="135" r="2.5" fill="rgba(255,255,255,0.15)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="pandiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
</radialGradient>
<radialGradient id="pandiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<radialGradient id="pandiNose3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</radialGradient>
<linearGradient id="pandiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
<radialGradient id="pandiArm3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</radialGradient>
<radialGradient id="pandiLeg3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Main body - perfect circle -->
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
<!-- Head - perfect circle -->
<circle cx="100" cy="85" r="45" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
<!-- Black ear patches -->
<circle cx="70" cy="45" r="18" fill="#1f2937" />
<circle cx="130" cy="45" r="18" fill="#1f2937" />
<!-- Inner ears -->
<circle cx="70" cy="45" r="12" fill="#374151" />
<circle cx="130" cy="45" r="12" fill="#374151" />
<!-- Eyes (black patches + white base) -->
<circle cx="85" cy="82" r="20" fill="#1f2937" />
<circle cx="115" cy="82" r="20" fill="#1f2937" />
<circle cx="85" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
<circle cx="115" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="85" cy="82" r="8" fill="url(#pandiPupil3D)" />
<circle cx="115" cy="82" r="8" fill="url(#pandiPupil3D)" />
<circle cx="88" cy="79" r="3" fill="white" />
<circle cx="118" cy="79" r="3" fill="white" />
<!-- Nose -->
<path d="M 100 95 L 95 105 L 105 105 Z" fill="url(#pandiNose3D)" />
<!-- Mouth -->
<path d="M 90 110 Q 100 118 110 110" stroke="url(#pandiMouth3D)" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Arms -->
<circle cx="45" cy="120" r="15" fill="url(#pandiArm3D)" />
<circle cx="155" cy="120" r="15" fill="url(#pandiArm3D)" />
<!-- Legs -->
<circle cx="80" cy="165" r="18" fill="url(#pandiLeg3D)" />
<circle cx="120" cy="165" r="18" fill="url(#pandiLeg3D)" />
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="pandiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
</radialGradient>
<radialGradient id="pandiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<radialGradient id="pandiNose3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</radialGradient>
<linearGradient id="pandiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
<radialGradient id="pandiArm3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</radialGradient>
<radialGradient id="pandiLeg3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Main body - perfect circle -->
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
<!-- Head - perfect circle -->
<circle cx="100" cy="85" r="45" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
<!-- Black ear patches -->
<circle cx="70" cy="45" r="18" fill="#1f2937" />
<circle cx="130" cy="45" r="18" fill="#1f2937" />
<!-- Inner ears -->
<circle cx="70" cy="45" r="12" fill="#374151" />
<circle cx="130" cy="45" r="12" fill="#374151" />
<!-- Eyes -->
<circle cx="85" cy="82" r="20" fill="#1f2937" />
<circle cx="115" cy="82" r="20" fill="#1f2937" />
<circle cx="85" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
<circle cx="115" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
<path d="M 73 85 Q 85 88 97 85" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 103 85 Q 115 88 127 85" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Nose -->
<path d="M 100 95 L 95 105 L 105 105 Z" fill="url(#pandiNose3D)" />
<!-- Peaceful mouth -->
<circle cx="100" cy="110" r="2" fill="#1e293b" />
<!-- Arms -->
<circle cx="55" cy="120" r="15" fill="url(#pandiArm3D)" />
<circle cx="145" cy="120" r="15" fill="url(#pandiArm3D)" />
<!-- Legs -->
<circle cx="80" cy="165" r="18" fill="url(#pandiLeg3D)" />
<circle cx="120" cy="165" r="18" fill="url(#pandiLeg3D)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,100 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Bolinha 1 - sentido horário -->
<g transform="rotate(0 100 110)">
<circle cx="50" cy="80" r="4" fill="url(#rockyPebble)" opacity="0.8" />
<animateTransform attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="5s"
repeatCount="indefinite" />
</g>
<!-- Bolinha 2 - sentido anti-horário -->
<g transform="rotate(0 100 110)">
<circle cx="150" cy="85" r="3" fill="url(#rockyPebble)" opacity="0.6" />
<animateTransform attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="6s"
repeatCount="indefinite" />
</g>
<!-- Bolinha 3 - sentido horário (mais lento) -->
<g transform="rotate(0 100 110)">
<circle cx="45" cy="140" r="2.5" fill="url(#rockyPebble)" opacity="0.7" />
<animateTransform attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="8s"
repeatCount="indefinite" />
</g>
<!-- Bolinha 4 - sentido anti-horário (mais rápido) -->
<g transform="rotate(0 100 110)">
<circle cx="155" cy="135" r="3.5" fill="url(#rockyPebble)" opacity="0.5" />
<animateTransform attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="4s"
repeatCount="indefinite" />
</g>
<!-- Rocky's body -->
<path d="M 100 50 L 130 70 L 140 110 L 130 150 L 100 165 L 70 150 L 60 110 L 70 70 Z" fill="url(#rockyBody)" />
<path d="M 100 55 L 125 72 L 135 108 L 125 145 L 100 158 L 75 145 L 65 108 L 75 72 Z" fill="url(#rockyInner)" opacity="0.8" />
<!-- Texture -->
<path d="M 75 80 L 125 85" stroke="#57534e" stroke-width="2" opacity="0.5" />
<path d="M 70 110 L 130 115" stroke="#57534e" stroke-width="2" opacity="0.5" />
<path d="M 80 140 L 120 135" stroke="#57534e" stroke-width="2" opacity="0.5" />
<!-- Eyes (white base) -->
<circle cx="85" cy="95" r="12" fill="white" />
<circle cx="115" cy="95" r="12" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="85" cy="95" r="8" fill="#1f2937" />
<circle cx="115" cy="95" r="8" fill="#1f2937" />
<circle cx="88" cy="92" r="4" fill="white" />
<circle cx="118" cy="92" r="4" fill="white" />
<!-- Mouth -->
<path d="M 88 115 Q 100 123 112 115" stroke="#1f2937" stroke-width="4" fill="none" stroke-linecap="round" />
<!-- Arms -->
<ellipse cx="55" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(-15 55 110)" />
<ellipse cx="145" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(15 145 110)" />
<!-- Legs -->
<ellipse cx="85" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
<ellipse cx="115" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
<defs>
<radialGradient id="rockyBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a8a29e" />
<stop offset="30%" stop-color="#78716c" />
<stop offset="70%" stop-color="#57534e" />
<stop offset="100%" stop-color="#44403c" />
</radialGradient>
<radialGradient id="rockyInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#d6d3d1" />
<stop offset="100%" stop-color="#a8a29e" />
</radialGradient>
<radialGradient id="rockyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#78716c" />
<stop offset="100%" stop-color="#44403c" />
</radialGradient>
<radialGradient id="rockyLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#57534e" />
<stop offset="100%" stop-color="#292524" />
</radialGradient>
<radialGradient id="rockyPebble" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#a8a29e" />
<stop offset="100%" stop-color="#57534e" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,104 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Sombra -->
<ellipse cx="105" cy="185" rx="50" ry="8" fill="rgba(0,0,0,0.2)" />
<!-- Bolinha 1 - sentido horário (mais lento) -->
<g transform="rotate(0 100 110)">
<circle cx="50" cy="80" r="4" fill="url(#rockyPebble)" opacity="0.5" />
<animateTransform attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="10s"
repeatCount="indefinite" />
</g>
<!-- Bolinha 2 - sentido anti-horário (mais lento) -->
<g transform="rotate(0 100 110)">
<circle cx="150" cy="85" r="3" fill="url(#rockyPebble)" opacity="0.4" />
<animateTransform attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="12s"
repeatCount="indefinite" />
</g>
<!-- Bolinha 3 - sentido horário (mais lento) -->
<g transform="rotate(0 100 110)">
<circle cx="45" cy="140" r="2.5" fill="url(#rockyPebble)" opacity="0.4" />
<animateTransform attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="16s"
repeatCount="indefinite" />
</g>
<!-- Bolinha 4 - sentido anti-horário (mais lento) -->
<g transform="rotate(0 100 110)">
<circle cx="155" cy="135" r="3.5" fill="url(#rockyPebble)" opacity="0.3" />
<animateTransform attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="8s"
repeatCount="indefinite" />
</g>
<!-- Corpo do Rocky -->
<path d="M 100 50 L 130 70 L 140 110 L 130 150 L 100 165 L 70 150 L 60 110 L 70 70 Z"
fill="url(#rockyBody)" />
<path d="M 100 55 L 125 72 L 135 108 L 125 145 L 100 158 L 75 145 L 65 108 L 75 72 Z"
fill="url(#rockyInner)" opacity="0.8" />
<!-- Textura -->
<path d="M 75 80 L 125 85" stroke="#57534e" stroke-width="2" opacity="0.5" />
<path d="M 70 110 L 130 115" stroke="#57534e" stroke-width="2" opacity="0.5" />
<path d="M 80 140 L 120 135" stroke="#57534e" stroke-width="2" opacity="0.5" />
<!-- Sleeping eyes -->
<path d="M 73 95 Q 85 98 97 95" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 103 95 Q 115 98 127 95" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#1f2937" />
<!-- Braços -->
<ellipse cx="55" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(-15 55 110)" />
<ellipse cx="145" cy="110" rx="12" ry="8" fill="url(#rockyArm)" transform="rotate(15 145 110)" />
<!-- Pernas -->
<ellipse cx="85" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
<ellipse cx="115" cy="160" rx="15" ry="10" fill="url(#rockyLeg)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="rockyBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a8a29e" />
<stop offset="30%" stop-color="#78716c" />
<stop offset="70%" stop-color="#57534e" />
<stop offset="100%" stop-color="#44403c" />
</radialGradient>
<radialGradient id="rockyInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#d6d3d1" />
<stop offset="100%" stop-color="#a8a29e" />
</radialGradient>
<radialGradient id="rockyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#78716c" />
<stop offset="100%" stop-color="#44403c" />
</radialGradient>
<radialGradient id="rockyLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#57534e" />
<stop offset="100%" stop-color="#292524" />
</radialGradient>
<radialGradient id="rockyPebble" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#a8a29e" />
<stop offset="100%" stop-color="#57534e" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,94 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Rose stem -->
<rect x="98" y="120" width="4" height="50" fill="url(#roseyStem)" rx="2" />
<!-- Thorns -->
<path d="M 98 130 L 94 128" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
<path d="M 102 145 L 106 143" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
<!-- Leaves -->
<ellipse cx="85" cy="145" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(-30 85 140)" />
<ellipse cx="110" cy="150" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(30 115 150)" />
<!-- Rose petals - layered -->
<circle cx="100" cy="90" r="35" fill="url(#roseyPetal1)" />
<path d="M 100 60 Q 120 70 125 90 Q 120 110 100 120 Q 80 110 75 90 Q 80 70 100 60" fill="url(#roseyPetal2)" />
<path d="M 100 65 Q 115 73 118 90 Q 115 107 100 115 Q 85 107 82 90 Q 85 73 100 65" fill="url(#roseyPetal3)" />
<circle cx="100" cy="90" r="20" fill="url(#roseyCenter)" />
<!-- Eyes (white base) -->
<circle cx="90" cy="85" r="8" fill="white" />
<circle cx="110" cy="85" r="8" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="90" cy="85" r="5" fill="#1f2937" />
<circle cx="110" cy="85" r="5" fill="#1f2937" />
<circle cx="92" cy="83" r="2" fill="white" />
<circle cx="112" cy="83" r="2" fill="white" />
<!-- Mouth -->
<path d="M 92 100 Q 100 106 108 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Rosy cheeks -->
<circle cx="75" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
<circle cx="125" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
<!-- Floating petals -->
<ellipse cx="55" cy="70" rx="8" ry="5" fill="url(#roseyFloatingPetal)" opacity="0.8" transform="rotate(45 55 70)" />
<ellipse cx="145" cy="75" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.6" transform="rotate(-30 145 75)" />
<ellipse cx="50" cy="120" rx="6" ry="3.5" fill="url(#roseyFloatingPetal)" opacity="0.7" transform="rotate(60 50 120)" />
<ellipse cx="150" cy="115" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.5" transform="rotate(-45 150 115)" />
<!-- Rosey pot -->
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#leafyPot)" />
<rect x="75" y="160" width="50" height="5" fill="url(#leafyPotRim)" rx="2" />
<defs>
<radialGradient id="roseyStem" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="roseyLeaf" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="roseyPetal1" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="30%" stop-color="#f9a8d4" />
<stop offset="70%" stop-color="#f472b6" />
<stop offset="100%" stop-color="#ec4899" />
</radialGradient>
<radialGradient id="roseyPetal2" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fbcfe8" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="roseyPetal3" cx="0.5" cy="0.4">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f9a8d4" />
</radialGradient>
<radialGradient id="roseyCenter" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#f9a8d4" />
<stop offset="100%" stop-color="#ec4899" />
</radialGradient>
<radialGradient id="roseyBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="roseyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#f9a8d4" />
<stop offset="100%" stop-color="#ec4899" />
</radialGradient>
<radialGradient id="roseyFloatingPetal" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fbcfe8" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="leafyPot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#991b1b" />
</radialGradient>
<radialGradient id="leafyPotRim" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,88 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Rose stem -->
<rect x="98" y="120" width="4" height="50" fill="url(#roseyStem)" rx="2" />
<!-- Thorns -->
<path d="M 98 130 L 94 128" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
<path d="M 102 145 L 106 143" stroke="#15803d" stroke-width="2" stroke-linecap="round" />
<!-- Leaves -->
<ellipse cx="85" cy="140" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(-30 85 140)" />
<ellipse cx="115" cy="150" rx="12" ry="8" fill="url(#roseyLeaf)" transform="rotate(30 115 150)" />
<!-- Rose petals - layered -->
<circle cx="100" cy="90" r="35" fill="url(#roseyPetal1)" />
<path d="M 100 60 Q 120 70 125 90 Q 120 110 100 120 Q 80 110 75 90 Q 80 70 100 60" fill="url(#roseyPetal2)" />
<path d="M 100 65 Q 115 73 118 90 Q 115 107 100 115 Q 85 107 82 90 Q 85 73 100 65" fill="url(#roseyPetal3)" />
<circle cx="100" cy="90" r="20" fill="url(#roseyCenter)" />
<!-- Sleeping eyes -->
<path d="M 82 85 Q 90 88 98 85" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 85 Q 110 88 118 85" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="100" r="2" fill="#1f2937" />
<!-- Rosy cheeks -->
<circle cx="75" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
<circle cx="125" cy="95" r="6" fill="url(#roseyBlush)" opacity="0.6" />
<!-- Little arms from center -->
<ellipse cx="70" cy="90" rx="8" ry="12" fill="url(#roseyArm)" transform="rotate(-30 70 90)" />
<ellipse cx="130" cy="90" rx="8" ry="12" fill="url(#roseyArm)" transform="rotate(30 130 90)" />
<!-- Floating petals with gentle animation -->
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 2,3; 0,0" dur="6s" repeatCount="indefinite" />
<ellipse cx="55" cy="70" rx="8" ry="5" fill="url(#roseyFloatingPetal)" opacity="0.5" transform="rotate(45 55 70)" />
<ellipse cx="145" cy="75" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.4" transform="rotate(-30 145 75)" />
<ellipse cx="50" cy="120" rx="6" ry="3.5" fill="url(#roseyFloatingPetal)" opacity="0.4" transform="rotate(60 50 120)" />
<ellipse cx="150" cy="115" rx="7" ry="4" fill="url(#roseyFloatingPetal)" opacity="0.3" transform="rotate(-45 150 115)" />
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="roseyStem" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="roseyLeaf" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="roseyPetal1" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="30%" stop-color="#f9a8d4" />
<stop offset="70%" stop-color="#f472b6" />
<stop offset="100%" stop-color="#ec4899" />
</radialGradient>
<radialGradient id="roseyPetal2" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fbcfe8" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="roseyPetal3" cx="0.5" cy="0.4">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f9a8d4" />
</radialGradient>
<radialGradient id="roseyCenter" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#f9a8d4" />
<stop offset="100%" stop-color="#ec4899" />
</radialGradient>
<radialGradient id="roseyBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="roseyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#f9a8d4" />
<stop offset="100%" stop-color="#ec4899" />
</radialGradient>
<radialGradient id="roseyFloatingPetal" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fbcfe8" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Star gradients -->
<radialGradient id="starriBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4c1d95;stop-opacity:1" />
<stop offset="30%" style="stop-color:#3730a3;stop-opacity:1" />
<stop offset="70%" style="stop-color:#1e1b4b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
</radialGradient>
<radialGradient id="starriInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
</radialGradient>
<radialGradient id="starriEye" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
</radialGradient>
<linearGradient id="starriSmile" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
</linearGradient>
<radialGradient id="starriDust1" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</radialGradient>
<radialGradient id="starriDust2" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
</radialGradient>
<radialGradient id="starriDust3" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</radialGradient>
<linearGradient id="starriConstellation" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c084fc;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main star body - larger 5-pointed star shape -->
<path d="M 100 25 L 115 75 L 165 75 L 125 110 L 140 160 L 100 130 L 60 160 L 75 110 L 35 75 L 85 75 Z" fill="url(#starriBody)" />
<path d="M 100 35 L 112 70 L 150 70 L 120 95 L 132 135 L 100 115 L 68 135 L 80 95 L 50 70 L 88 70 Z" fill="url(#starriInner)" opacity="0.8" />
<!-- Eyes (white base) -->
<circle cx="88" cy="95" r="10" fill="url(#starriEye)" />
<circle cx="112" cy="95" r="10" fill="url(#starriEye)" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="88" cy="95" r="6" fill="#1e1b4b" />
<circle cx="112" cy="95" r="6" fill="#1e1b4b" />
<circle cx="90" cy="93" r="3" fill="white" />
<circle cx="114" cy="93" r="3" fill="white" />
<!-- Mouth -->
<path d="M 88 115 Q 100 125 112 115" stroke="url(#starriSmile)" stroke-width="4" fill="none" stroke-linecap="round" />
<!-- Floating stardust -->
<circle cx="55" cy="60" r="2" fill="url(#starriDust1)" opacity="0.9" />
<circle cx="145" cy="65" r="1.5" fill="url(#starriDust2)" opacity="0.8" />
<circle cx="60" cy="140" r="2.5" fill="url(#starriDust3)" opacity="0.7" />
<circle cx="140" cy="135" r="2" fill="url(#starriDust1)" opacity="0.6" />
<circle cx="40" cy="100" r="1.5" fill="url(#starriDust2)" opacity="0.8" />
<circle cx="160" cy="105" r="2" fill="url(#starriDust3)" opacity="0.9" />
<!-- Constellation lines -->
<path d="M 55 60 L 70 45 L 130 50 L 145 65" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.5" />
<path d="M 40 100 L 60 140 L 140 135 L 160 105" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.5" />
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Star gradients -->
<radialGradient id="starriBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4c1d95;stop-opacity:1" />
<stop offset="30%" style="stop-color:#3730a3;stop-opacity:1" />
<stop offset="70%" style="stop-color:#1e1b4b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
</radialGradient>
<radialGradient id="starriInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
</radialGradient>
<radialGradient id="starriEye" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
</radialGradient>
<linearGradient id="starriSmile" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
</linearGradient>
<radialGradient id="starriDust1" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</radialGradient>
<radialGradient id="starriDust2" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
</radialGradient>
<radialGradient id="starriDust3" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</radialGradient>
<linearGradient id="starriConstellation" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c084fc;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main star body - larger 5-pointed star shape -->
<path d="M 100 25 L 115 75 L 165 75 L 125 110 L 140 160 L 100 130 L 60 160 L 75 110 L 35 75 L 85 75 Z"
fill="url(#starriBody)" />
<path d="M 100 35 L 112 70 L 150 70 L 120 95 L 132 135 L 100 115 L 68 135 L 80 95 L 50 70 L 88 70 Z"
fill="url(#starriInner)" opacity="0.8" />
<!-- Twinkling eyes -->
<circle cx="88" cy="95" r="10" fill="url(#starriEye)" />
<circle cx="112" cy="95" r="10" fill="url(#starriEye)" />
<!-- Sleeping eyes -->
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 95 Q 112 98 122 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#f59e0b" />
<!-- Floating stardust with gentle animation -->
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 1,2; 0,0" dur="4s" repeatCount="indefinite" />
<circle cx="55" cy="60" r="2" fill="url(#starriDust1)" opacity="0.6" />
<circle cx="145" cy="65" r="1.5" fill="url(#starriDust2)" opacity="0.5" />
<circle cx="60" cy="140" r="2.5" fill="url(#starriDust3)" opacity="0.4" />
<circle cx="140" cy="135" r="2" fill="url(#starriDust1)" opacity="0.4" />
<circle cx="40" cy="100" r="1.5" fill="url(#starriDust2)" opacity="0.5" />
<circle cx="160" cy="105" r="2" fill="url(#starriDust3)" opacity="0.6" />
</g>
<!-- Constellation lines -->
<path d="M 55 60 L 70 45 L 130 50 L 145 65" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.3" />
<path d="M 40 100 L 60 140 L 140 135 L 160 105" stroke="url(#starriConstellation)" stroke-width="1.5" opacity="0.3" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+46
View File
@@ -0,0 +1,46 @@
/**
* Adult Blobbi Module
*
* Self-contained module for adult stage Blobbi visuals and customization.
* This module includes:
* - Adult SVG assets (awake and sleeping variants for each form)
* - SVG resolution and loading utilities
* - Color and customization utilities
* - Type definitions
*
* This module is designed to be portable and can be moved to other projects.
*/
// Types
export type {
AdultForm,
AdultVariant,
AdultSvgCustomization,
AdultSvgResolverOptions,
} from './types/adult.types';
export {
ADULT_FORMS,
extractAdultCustomization,
isValidAdultForm,
getDefaultAdultForm,
resolveAdultForm,
deriveAdultFormFromSeed,
} from './types/adult.types';
// SVG Resolution
export {
getAdultBaseSvg,
getAdultSleepingSvg,
getAdultSvgByVariant,
resolveAdultSvg,
resolveAdultSvgWithForm,
getAvailableAdultForms,
preloadAdultSvgs,
} from './lib/adult-svg-resolver';
// SVG Customization
export {
customizeAdultSvg,
customizeAdultSvgFromBlobbi,
} from './lib/adult-svg-customizer';
@@ -0,0 +1,764 @@
/**
* Adult Blobbi SVG Customizer
*
* Handles applying colors and customizations to adult SVG content.
* Each adult form has different gradient IDs that need color mapping.
*
* IMPORTANT: Gradients must be preserved for 3D shading effects.
* We replace gradient colors, not the gradient structure.
*/
import type { Blobbi } from '@/types/blobbi';
import type { AdultForm, AdultSvgCustomization } from '../types/adult.types';
// ─── Color Utilities ──────────────────────────────────────────────────────────
/**
* Lighten a hex color by a percentage
*/
function lightenColor(color: string, percent: number): string {
if (color.startsWith('#')) {
const num = parseInt(color.slice(1), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) + amt;
const G = (num >> 8 & 0x00FF) + amt;
const B = (num & 0x0000FF) + amt;
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1).toUpperCase();
}
return color;
}
/**
* Darken a hex color by a percentage
*/
function darkenColor(color: string, percent: number): string {
if (color.startsWith('#')) {
const num = parseInt(color.slice(1), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) - amt;
const G = (num >> 8 & 0x00FF) - amt;
const B = (num & 0x0000FF) - amt;
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1).toUpperCase();
}
return color;
}
// ─── Gradient Builders ────────────────────────────────────────────────────────
/**
* Build a 3-stop radial gradient (highlight -> mid -> base)
*/
function buildRadialGradient3Stop(
id: string,
baseColor: string,
cx = '0.3',
cy = '0.2'
): string {
const highlight = lightenColor(baseColor, 40);
const mid = lightenColor(baseColor, 20);
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
<stop offset="40%" style="stop-color:${mid};stop-opacity:1" />
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
</radialGradient>`;
}
/**
* Build a 2-stop radial gradient (lighter -> base)
*/
function buildRadialGradient2Stop(
id: string,
baseColor: string,
cx = '0.3',
cy = '0.3'
): string {
const highlight = lightenColor(baseColor, 25);
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
</radialGradient>`;
}
/**
* Build a 4-stop radial gradient (used by droppi, rocky, starri bodies)
*/
function buildRadialGradient4Stop(
id: string,
baseColor: string,
cx = '0.3',
cy = '0.2'
): string {
const veryLight = lightenColor(baseColor, 50);
const light = lightenColor(baseColor, 25);
const dark = darkenColor(baseColor, 15);
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
<stop offset="0%" style="stop-color:${veryLight};stop-opacity:1" />
<stop offset="30%" style="stop-color:${light};stop-opacity:1" />
<stop offset="70%" style="stop-color:${baseColor};stop-opacity:1" />
<stop offset="100%" style="stop-color:${dark};stop-opacity:1" />
</radialGradient>`;
}
/**
* Build a petal gradient (outer -> inner style, like rosey/leafy)
*/
function buildPetalGradient(
id: string,
baseColor: string,
cx = '0.3',
cy = '0.2'
): string {
const veryLight = lightenColor(baseColor, 50);
const light = lightenColor(baseColor, 30);
const mid = lightenColor(baseColor, 15);
return `<radialGradient id="${id}" cx="${cx}" cy="${cy}">
<stop offset="0%" style="stop-color:${veryLight};stop-opacity:1" />
<stop offset="30%" style="stop-color:${light};stop-opacity:1" />
<stop offset="70%" style="stop-color:${mid};stop-opacity:1" />
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
</radialGradient>`;
}
/**
* Build pupil gradient
*/
function buildPupilGradient(id: string, eyeColor: string): string {
const highlight = lightenColor(eyeColor, 20);
return `<radialGradient id="${id}" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:${highlight};stop-opacity:1" />
<stop offset="100%" style="stop-color:${eyeColor};stop-opacity:1" />
</radialGradient>`;
}
// ─── Generic Gradient Replacer ────────────────────────────────────────────────
/**
* Replace a specific gradient in the SVG by ID
*/
function replaceGradient(
svgText: string,
gradientId: string,
newGradient: string
): string {
// Match both radialGradient and linearGradient
const pattern = new RegExp(
`<(radial|linear)Gradient[^>]*id=["']${gradientId}["'][^>]*>[\\s\\S]*?<\\/(radial|linear)Gradient>`,
'i'
);
const match = svgText.match(pattern);
if (match) {
return svgText.replace(match[0], newGradient);
}
return svgText;
}
// ─── Form-Specific Customizers ────────────────────────────────────────────────
/**
* Catti: Body, ears, and tail should use Blobbi color
* Gradients: cattiBody3D, cattiEar3D, cattiEarInner, cattiTail3D, cattiTailHighlight
*/
function customizeCatti(svgText: string, baseColor: string): string {
let svg = svgText;
// Body gradient (3-stop)
svg = replaceGradient(svg, 'cattiBody3D', buildRadialGradient3Stop('cattiBody3D', baseColor));
// Ear gradients (2-stop)
svg = replaceGradient(svg, 'cattiEar3D', buildRadialGradient2Stop('cattiEar3D', baseColor));
// Ear inner uses lighter color
const earInnerColor = lightenColor(baseColor, 20);
svg = replaceGradient(svg, 'cattiEarInner', buildRadialGradient2Stop('cattiEarInner', earInnerColor, '0.4', '0.3'));
// Tail gradients
const tailHighlight = lightenColor(baseColor, 40);
svg = replaceGradient(svg, 'cattiTail3D', `<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 15)};stop-opacity:1" />
</radialGradient>`);
svg = replaceGradient(svg, 'cattiTailHighlight', buildRadialGradient2Stop('cattiTailHighlight', tailHighlight, '0.4', '0.3'));
return svg;
}
/**
* Droppi: Body, arms, legs, and droplets should use Blobbi color
* Gradients: droppiBody, droppiInner, droppiArm, droppiLeg, droppiDroplet
*/
function customizeDroppi(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (4-stop)
svg = replaceGradient(svg, 'droppiBody', buildRadialGradient4Stop('droppiBody', baseColor));
// Inner reflection (lighter, 2-stop)
const innerColor = lightenColor(baseColor, 45);
svg = replaceGradient(svg, 'droppiInner', buildRadialGradient2Stop('droppiInner', innerColor, '0.4', '0.3'));
// Arms (2-stop)
svg = replaceGradient(svg, 'droppiArm', buildRadialGradient2Stop('droppiArm', lightenColor(baseColor, 15)));
// Legs (2-stop, slightly darker)
svg = replaceGradient(svg, 'droppiLeg', buildRadialGradient2Stop('droppiLeg', darkenColor(baseColor, 5), '0.3', '0.2'));
// Droplets
svg = replaceGradient(svg, 'droppiDroplet', buildRadialGradient2Stop('droppiDroplet', lightenColor(baseColor, 30), '0.5', '0.5'));
return svg;
}
/**
* Flammi: Body, inner, core, arms, legs, and embers should use Blobbi color
* Gradients: flammiBody, flammiInner, flammiCore, flammiArm, flammiLeg, flammiEmber
*/
function customizeFlammi(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (4-stop gradient with warm progression)
svg = replaceGradient(svg, 'flammiBody', buildRadialGradient4Stop('flammiBody', baseColor));
// Inner (3-stop, lighter)
const innerColor = lightenColor(baseColor, 25);
svg = replaceGradient(svg, 'flammiInner', `<radialGradient id="flammiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:${lightenColor(innerColor, 30)};stop-opacity:1" />
<stop offset="50%" style="stop-color:${innerColor};stop-opacity:1" />
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 10)};stop-opacity:1" />
</radialGradient>`);
// Core (hottest/brightest part, very light)
const coreColor = lightenColor(baseColor, 50);
svg = replaceGradient(svg, 'flammiCore', buildRadialGradient2Stop('flammiCore', coreColor, '0.5', '0.4'));
// Arms
svg = replaceGradient(svg, 'flammiArm', buildRadialGradient2Stop('flammiArm', lightenColor(baseColor, 10)));
// Legs
svg = replaceGradient(svg, 'flammiLeg', buildRadialGradient2Stop('flammiLeg', baseColor, '0.3', '0.2'));
// Embers
svg = replaceGradient(svg, 'flammiEmber', buildRadialGradient2Stop('flammiEmber', lightenColor(baseColor, 35), '0.5', '0.5'));
return svg;
}
/**
* Froggi: Body, eye base, feet should use Blobbi color
* Gradients: froggiBody3D, froggiEyeBase3D, froggiFeet3D, froggiFeetHighlight, froggiToe3D
*/
function customizeFroggi(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (3-stop)
svg = replaceGradient(svg, 'froggiBody3D', buildRadialGradient3Stop('froggiBody3D', baseColor));
// Eye base (matches body color, 2-stop)
svg = replaceGradient(svg, 'froggiEyeBase3D', buildRadialGradient2Stop('froggiEyeBase3D', lightenColor(baseColor, 15)));
// Feet (2-stop, lighter than body)
const feetColor = lightenColor(baseColor, 20);
svg = replaceGradient(svg, 'froggiFeet3D', buildRadialGradient2Stop('froggiFeet3D', feetColor, '0.3', '0.2'));
// Feet highlight (even lighter)
svg = replaceGradient(svg, 'froggiFeetHighlight', buildRadialGradient2Stop('froggiFeetHighlight', lightenColor(feetColor, 20), '0.4', '0.3'));
// Toes (linear gradient, darker)
svg = replaceGradient(svg, 'froggiToe3D', `<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:${darkenColor(baseColor, 10)};stop-opacity:1" />
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 25)};stop-opacity:1" />
</linearGradient>`);
return svg;
}
/**
* Leafy: Petals should use Blobbi color (center/face keeps brown)
* Gradients: leafyPetal (petals only - the yellow parts)
*/
function customizeLeafy(svgText: string, baseColor: string): string {
let svg = svgText;
// Petal gradient (the sunflower petals)
svg = replaceGradient(svg, 'leafyPetal', `<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 15)};stop-opacity:1" />
<stop offset="30%" style="stop-color:${lightenColor(baseColor, 25)};stop-opacity:1" />
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
</radialGradient>`);
return svg;
}
/**
* Mushie: Cap should use Blobbi color (stem keeps original)
* Gradients: mushieCap, mushieCapHighlight
*/
function customizeMushie(svgText: string, baseColor: string): string {
let svg = svgText;
// Cap (4-stop)
svg = replaceGradient(svg, 'mushieCap', buildRadialGradient4Stop('mushieCap', baseColor));
// Cap highlight (lighter)
svg = replaceGradient(svg, 'mushieCapHighlight', buildRadialGradient2Stop('mushieCapHighlight', lightenColor(baseColor, 25), '0.4', '0.3'));
return svg;
}
/**
* Rocky: Body, inner, arms, legs, and pebbles should use Blobbi color
* Gradients: rockyBody, rockyInner, rockyArm, rockyLeg, rockyPebble
*/
function customizeRocky(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (4-stop)
svg = replaceGradient(svg, 'rockyBody', buildRadialGradient4Stop('rockyBody', baseColor));
// Inner (2-stop, lighter)
svg = replaceGradient(svg, 'rockyInner', buildRadialGradient2Stop('rockyInner', lightenColor(baseColor, 35), '0.4', '0.3'));
// Arms (2-stop)
svg = replaceGradient(svg, 'rockyArm', buildRadialGradient2Stop('rockyArm', baseColor));
// Legs (2-stop, slightly darker)
svg = replaceGradient(svg, 'rockyLeg', buildRadialGradient2Stop('rockyLeg', darkenColor(baseColor, 10), '0.3', '0.2'));
// Pebbles
svg = replaceGradient(svg, 'rockyPebble', buildRadialGradient2Stop('rockyPebble', lightenColor(baseColor, 15), '0.5', '0.5'));
return svg;
}
/**
* Rosey: Petals, center, and floating petals should use Blobbi color
* Gradients: roseyPetal1, roseyPetal2, roseyPetal3, roseyCenter, roseyFloatingPetal
*/
function customizeRosey(svgText: string, baseColor: string): string {
let svg = svgText;
// Petal layers (outer to inner, using petal gradient style)
svg = replaceGradient(svg, 'roseyPetal1', buildPetalGradient('roseyPetal1', baseColor));
// Petal2 (slightly lighter)
svg = replaceGradient(svg, 'roseyPetal2', buildRadialGradient2Stop('roseyPetal2', lightenColor(baseColor, 15), '0.4', '0.3'));
// Petal3 (lightest inner petals)
svg = replaceGradient(svg, 'roseyPetal3', buildRadialGradient2Stop('roseyPetal3', lightenColor(baseColor, 30), '0.5', '0.4'));
// Center (where face is, slightly darker)
svg = replaceGradient(svg, 'roseyCenter', buildRadialGradient2Stop('roseyCenter', lightenColor(baseColor, 10)));
// Floating petals
svg = replaceGradient(svg, 'roseyFloatingPetal', buildRadialGradient2Stop('roseyFloatingPetal', lightenColor(baseColor, 20), '0.5', '0.5'));
return svg;
}
/**
* Starri: Inner star should use Blobbi color (outer stays dark/cosmic)
* Gradients: starriInner (the inner golden star - this should be the Blobbi color)
*/
function customizeStarri(svgText: string, baseColor: string): string {
let svg = svgText;
// Inner star (3-stop gradient to maintain depth)
svg = replaceGradient(svg, 'starriInner', `<radialGradient id="starriInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
</radialGradient>`);
return svg;
}
/**
* Breezy: Body, inner, veins, arms, legs, and floating leaves should use Blobbi color
* Gradients: breezyBody, breezyInner, breezyVein, breezyArm, breezyLeg, breezyFloating
*/
function customizeBreezy(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (4-stop leaf gradient)
svg = replaceGradient(svg, 'breezyBody', buildRadialGradient4Stop('breezyBody', baseColor));
// Inner highlight (lighter, 2-stop)
svg = replaceGradient(svg, 'breezyInner', buildRadialGradient2Stop('breezyInner', lightenColor(baseColor, 40), '0.4', '0.3'));
// Veins (linear gradient, darker)
svg = replaceGradient(svg, 'breezyVein', `<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:${darkenColor(baseColor, 20)};stop-opacity:1" />
<stop offset="50%" style="stop-color:${darkenColor(baseColor, 10)};stop-opacity:1" />
<stop offset="100%" style="stop-color:${darkenColor(baseColor, 20)};stop-opacity:1" />
</linearGradient>`);
// Arms (2-stop)
svg = replaceGradient(svg, 'breezyArm', buildRadialGradient2Stop('breezyArm', lightenColor(baseColor, 15)));
// Legs (2-stop)
svg = replaceGradient(svg, 'breezyLeg', buildRadialGradient2Stop('breezyLeg', baseColor, '0.3', '0.2'));
// Floating leaves
svg = replaceGradient(svg, 'breezyFloating', buildRadialGradient2Stop('breezyFloating', lightenColor(baseColor, 25), '0.5', '0.5'));
return svg;
}
/**
* Bloomi: Petals, center, and pollen should use Blobbi color
* Note: Bloomi has 6 different colored petals - we'll make them all use variations of the base color
* Gradients: bloomiPetal1-6, bloomiCenter, bloomiPollen
*/
function customizeBloomi(svgText: string, baseColor: string): string {
let svg = svgText;
// All 6 petals use variations of the Blobbi color
// Create a gradient effect across petals by varying lightness
svg = replaceGradient(svg, 'bloomiPetal1', buildRadialGradient2Stop('bloomiPetal1', lightenColor(baseColor, 30)));
svg = replaceGradient(svg, 'bloomiPetal2', buildRadialGradient2Stop('bloomiPetal2', lightenColor(baseColor, 20)));
svg = replaceGradient(svg, 'bloomiPetal3', buildRadialGradient2Stop('bloomiPetal3', lightenColor(baseColor, 10)));
svg = replaceGradient(svg, 'bloomiPetal4', buildRadialGradient2Stop('bloomiPetal4', baseColor));
svg = replaceGradient(svg, 'bloomiPetal5', buildRadialGradient2Stop('bloomiPetal5', darkenColor(baseColor, 10)));
svg = replaceGradient(svg, 'bloomiPetal6', buildRadialGradient2Stop('bloomiPetal6', darkenColor(baseColor, 5)));
// Center (3-stop, lighter than petals - this is where the face is)
svg = replaceGradient(svg, 'bloomiCenter', `<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 45)};stop-opacity:1" />
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:1" />
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 25)};stop-opacity:1" />
</radialGradient>`);
// Pollen (floating particles)
svg = replaceGradient(svg, 'bloomiPollen', buildRadialGradient2Stop('bloomiPollen', lightenColor(baseColor, 40), '0.5', '0.5'));
return svg;
}
/**
* Cacti: Body and arms should use Blobbi color (pot keeps original red)
* Gradients: cactiBody, cactiArm
*/
function customizeCacti(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (4-stop)
svg = replaceGradient(svg, 'cactiBody', buildRadialGradient4Stop('cactiBody', baseColor));
// Arms (2-stop)
svg = replaceGradient(svg, 'cactiArm', buildRadialGradient2Stop('cactiArm', lightenColor(baseColor, 10)));
return svg;
}
/**
* Cloudi: Body, highlights, and raindrops should use Blobbi color
* Gradients: cloudiBody, cloudiHighlight, cloudiRain
*/
function customizeCloudi(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (3-stop, cloud-like progression from light to slightly darker)
svg = replaceGradient(svg, 'cloudiBody', `<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 45)};stop-opacity:1" />
<stop offset="50%" style="stop-color:${lightenColor(baseColor, 30)};stop-opacity:1" />
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
</radialGradient>`);
// Highlights (very light, semi-transparent feel)
svg = replaceGradient(svg, 'cloudiHighlight', `<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 50)};stop-opacity:1" />
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 40)};stop-opacity:0.5" />
</radialGradient>`);
// Raindrops (use darker version of the color)
svg = replaceGradient(svg, 'cloudiRain', buildRadialGradient2Stop('cloudiRain', darkenColor(baseColor, 10), '0.5', '0.3'));
return svg;
}
/**
* Crysti: Body and inner should use Blobbi color (facets keep their colorful nature)
* Gradients: crystiBody, crystiInner
*/
function customizeCrysti(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (4-stop crystal gradient)
svg = replaceGradient(svg, 'crystiBody', buildRadialGradient4Stop('crystiBody', baseColor));
// Inner highlight (semi-transparent white feel preserved but tinted)
svg = replaceGradient(svg, 'crystiInner', `<radialGradient id="crystiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:${lightenColor(baseColor, 50)};stop-opacity:1" />
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 35)};stop-opacity:0.3" />
</radialGradient>`);
return svg;
}
/**
* Owli: Body, ears, and wings should use Blobbi color (beak keeps yellow/orange)
* Gradients: owliBody3D, owliEar3D, owliWing3D, owliWingHighlight
*/
function customizeOwli(svgText: string, baseColor: string): string {
let svg = svgText;
// Body (3-stop)
svg = replaceGradient(svg, 'owliBody3D', buildRadialGradient3Stop('owliBody3D', baseColor));
// Ears (2-stop, slightly darker)
svg = replaceGradient(svg, 'owliEar3D', buildRadialGradient2Stop('owliEar3D', darkenColor(baseColor, 10), '0.3', '0.2'));
// Wings (2-stop)
svg = replaceGradient(svg, 'owliWing3D', buildRadialGradient2Stop('owliWing3D', darkenColor(baseColor, 15), '0.3', '0.2'));
// Wing highlights (lighter)
svg = replaceGradient(svg, 'owliWingHighlight', buildRadialGradient2Stop('owliWingHighlight', lightenColor(baseColor, 10), '0.4', '0.3'));
return svg;
}
// ─── Form Customizer Map ──────────────────────────────────────────────────────
type FormCustomizer = (svgText: string, baseColor: string) => string;
const FORM_CUSTOMIZERS: Partial<Record<AdultForm, FormCustomizer>> = {
bloomi: customizeBloomi,
breezy: customizeBreezy,
cacti: customizeCacti,
catti: customizeCatti,
cloudi: customizeCloudi,
crysti: customizeCrysti,
droppi: customizeDroppi,
flammi: customizeFlammi,
froggi: customizeFroggi,
leafy: customizeLeafy,
mushie: customizeMushie,
owli: customizeOwli,
rocky: customizeRocky,
rosey: customizeRosey,
starri: customizeStarri,
// pandi keeps original colors - it's a panda with black/white coloring by design
};
// ─── Main Customization ───────────────────────────────────────────────────────
/**
* Apply color customizations to adult SVG.
*
* Each form has specific gradients that need to be replaced
* to apply the Blobbi's custom colors while preserving 3D shading.
*
* @param svgText - The SVG content to customize
* @param form - The adult form type
* @param customization - Color customization options
* @param isSleeping - Whether the Blobbi is sleeping (affects eye rendering)
* @param instanceId - Optional unique ID to prevent gradient ID collisions when multiple Blobbis are rendered
*/
export function customizeAdultSvg(
svgText: string,
form: AdultForm,
customization: AdultSvgCustomization,
isSleeping: boolean = false,
instanceId?: string
): string {
let modifiedSvg = svgText;
// Ensure SVG fills its container
modifiedSvg = ensureSvgFillsContainer(modifiedSvg);
// Skip color customization if no colors provided
if (!customization.baseColor && !customization.secondaryColor && !customization.eyeColor) {
// Still uniquify IDs if instanceId provided (even without color changes)
if (instanceId) {
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
}
return modifiedSvg;
}
// Apply form-specific body/part customization
if (customization.baseColor) {
const customizer = FORM_CUSTOMIZERS[form];
if (customizer) {
modifiedSvg = customizer(modifiedSvg, customization.baseColor);
} else {
// Fallback for forms without specific customizer: try generic body gradient
modifiedSvg = applyGenericBodyGradient(modifiedSvg, form, customization.baseColor);
}
}
// Apply eye color customization (skip for sleeping SVGs - eyes are closed)
if (customization.eyeColor && !isSleeping) {
modifiedSvg = applyPupilGradient(modifiedSvg, form, customization.eyeColor);
}
// Make all IDs unique to prevent collisions when multiple Blobbis are rendered
if (instanceId) {
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
}
return modifiedSvg;
}
/**
* Ensure SVG has width/height attributes so it fills its container
*/
function ensureSvgFillsContainer(svgText: string): string {
if (/\swidth=/.test(svgText) && /\sheight=/.test(svgText)) {
return svgText;
}
return svgText.replace(
/<svg([^>]*)>/,
'<svg$1 width="100%" height="100%">'
);
}
/**
* Make all SVG definition IDs unique by prefixing with an instance ID.
* This prevents gradient ID collisions when multiple Blobbis are rendered on the same page.
*
* Updates both:
* - Definition IDs: id="gradientName" → id="prefix_gradientName"
* - References: url(#gradientName) → url(#prefix_gradientName)
*/
function uniquifySvgIds(svgText: string, instanceId: string): string {
// Generate a unique prefix from the full instance ID
// Sanitize to only allow valid SVG ID characters (letters, numbers, underscore, hyphen)
// Note: instanceId format is "blobbi-{pubkeyPrefix12}-{petId10}" so we need the full ID
// to distinguish between Blobbis owned by the same user
const prefix = `b_${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
// Find all IDs defined in the SVG (in defs, gradients, clipPaths, etc.)
const idPattern = /\bid=["']([^"']+)["']/g;
const ids = new Set<string>();
let match;
while ((match = idPattern.exec(svgText)) !== null) {
ids.add(match[1]);
}
// Replace each ID and its references
let modified = svgText;
for (const id of ids) {
const prefixedId = `${prefix}_${id}`;
// Replace the ID definition
modified = modified.replace(
new RegExp(`\\bid=["']${id}["']`, 'g'),
`id="${prefixedId}"`
);
// Replace url() references
modified = modified.replace(
new RegExp(`url\\(#${id}\\)`, 'g'),
`url(#${prefixedId})`
);
// Replace xlink:href references (older SVG format)
modified = modified.replace(
new RegExp(`xlink:href=["']#${id}["']`, 'g'),
`xlink:href="#${prefixedId}"`
);
// Replace href references (newer SVG format)
modified = modified.replace(
new RegExp(`\\bhref=["']#${id}["']`, 'g'),
`href="#${prefixedId}"`
);
}
return modified;
}
/**
* Fallback: Apply generic body gradient for forms without specific customizer
*/
function applyGenericBodyGradient(
svgText: string,
form: AdultForm,
baseColor: string
): string {
let modified = svgText;
// Try common patterns: {form}Body3D, {form}Body
const bodyPatterns = [
new RegExp(`<radialGradient[^>]*id=["'](${form}Body3D)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
new RegExp(`<radialGradient[^>]*id=["'](${form}Body)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
];
for (const pattern of bodyPatterns) {
const match = modified.match(pattern);
if (match) {
const gradientId = match[1];
const newGradient = buildRadialGradient3Stop(gradientId, baseColor);
modified = modified.replace(match[0], newGradient);
break;
}
}
return modified;
}
/**
* Apply pupil gradient customization
*/
function applyPupilGradient(
svgText: string,
form: AdultForm,
eyeColor: string
): string {
let modified = svgText;
// Try common patterns: {form}Pupil3D, {form}Pupil
const pupilPatterns = [
new RegExp(`<radialGradient[^>]*id=["'](${form}Pupil3D)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
new RegExp(`<radialGradient[^>]*id=["'](${form}Pupil)["'][^>]*>[\\s\\S]*?<\\/radialGradient>`, 'i'),
];
for (const pattern of pupilPatterns) {
const match = modified.match(pattern);
if (match) {
const gradientId = match[1];
const newGradient = buildPupilGradient(gradientId, eyeColor);
modified = modified.replace(match[0], newGradient);
break;
}
}
return modified;
}
// ─── Convenience Functions ────────────────────────────────────────────────────
/**
* Convenience function to customize adult SVG from a Blobbi instance.
*
* Uses the Blobbi's ID to uniquify SVG IDs, preventing gradient collisions
* when multiple Blobbis are rendered on the same page.
*/
export function customizeAdultSvgFromBlobbi(
svgText: string,
form: AdultForm,
blobbi: Blobbi,
isSleeping: boolean = false
): string {
const customization: AdultSvgCustomization = {
baseColor: blobbi.baseColor,
secondaryColor: blobbi.secondaryColor,
eyeColor: blobbi.eyeColor,
};
// Pass blobbi.id to uniquify gradient IDs and prevent collisions
return customizeAdultSvg(svgText, form, customization, isSleeping, blobbi.id);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
/**
* Adult Blobbi SVG Resolver
*
* Handles loading and resolving adult stage SVG assets.
* Each adult form has its own folder with base and sleeping variants.
*/
import type { Blobbi } from '@/types/blobbi';
import {
type AdultForm,
type AdultSvgResolverOptions,
ADULT_FORMS,
resolveAdultForm,
getDefaultAdultForm,
} from '../types/adult.types';
import { ADULT_SVG_MAP } from './adult-svg-data';
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Get adult base SVG content for a specific form
*/
export function getAdultBaseSvg(form: AdultForm): string {
return ADULT_SVG_MAP[form]?.base ?? getFallbackAdultSvg(form);
}
/**
* Get adult sleeping SVG content for a specific form
*/
export function getAdultSleepingSvg(form: AdultForm): string {
return ADULT_SVG_MAP[form]?.sleeping ?? getFallbackAdultSvg(form);
}
/**
* Get adult SVG by form and variant
*/
export function getAdultSvgByVariant(
form: AdultForm,
variant: 'base' | 'sleeping'
): string {
return variant === 'sleeping'
? getAdultSleepingSvg(form)
: getAdultBaseSvg(form);
}
/**
* Resolve adult Blobbi SVG content.
*
* Determines the correct form from blobbi data (evolutionForm or seed-derived),
* then returns the appropriate SVG based on sleeping state.
*/
export function resolveAdultSvg(
blobbi: Blobbi,
options: AdultSvgResolverOptions = {}
): string {
const { isSleeping = false } = options;
if (blobbi.lifeStage !== 'adult') {
console.warn('resolveAdultSvg called with non-adult Blobbi');
return getFallbackAdultSvg(getDefaultAdultForm());
}
const form = resolveAdultForm(blobbi);
return isSleeping ? getAdultSleepingSvg(form) : getAdultBaseSvg(form);
}
/**
* Resolve adult form from Blobbi and return both form and SVG
*/
export function resolveAdultSvgWithForm(
blobbi: Blobbi,
options: AdultSvgResolverOptions = {}
): { form: AdultForm; svg: string } {
const { isSleeping = false } = options;
const form = resolveAdultForm(blobbi);
const svg = isSleeping ? getAdultSleepingSvg(form) : getAdultBaseSvg(form);
return { form, svg };
}
/**
* Get all available adult forms
*/
export function getAvailableAdultForms(): readonly AdultForm[] {
return ADULT_FORMS;
}
/**
* Preload all adult SVGs for quick switching
*/
export function preloadAdultSvgs(): void {
// All SVGs are inlined constants — this function exists for API consistency
// This function exists for API consistency
for (const form of ADULT_FORMS) {
getAdultBaseSvg(form);
getAdultSleepingSvg(form);
}
}
// ─── Fallback ─────────────────────────────────────────────────────────────────
/**
* Get fallback adult SVG content.
* Used when the expected asset is not found.
*/
function getFallbackAdultSvg(form: AdultForm): string {
// Simple placeholder SVG that indicates the form name
return `
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="fallbackAdultGradient" cx="0.3" cy="0.25">
<stop offset="0%" style="stop-color:#a78bfa"/>
<stop offset="60%" style="stop-color:#8b5cf6"/>
<stop offset="100%" style="stop-color:#7c3aed"/>
</radialGradient>
</defs>
<!-- Body -->
<ellipse cx="100" cy="110" rx="50" ry="60" fill="url(#fallbackAdultGradient)" />
<!-- Eyes -->
<ellipse cx="82" cy="95" rx="10" ry="12" fill="#fff" />
<ellipse cx="118" cy="95" rx="10" ry="12" fill="#fff" />
<circle cx="82" cy="96" r="7" fill="#374151" />
<circle cx="118" cy="96" r="7" fill="#374151" />
<circle cx="84" cy="94" r="2.5" fill="white" />
<circle cx="120" cy="94" r="2.5" fill="white" />
<!-- Mouth -->
<path d="M 88 120 Q 100 130 112 120" stroke="#374151" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Form label (dev only) -->
<text x="100" y="180" text-anchor="middle" font-size="12" fill="#666">${form}</text>
</svg>
`;
}
@@ -0,0 +1,117 @@
/**
* Adult Blobbi Module Types
*
* Type definitions for adult stage visuals and customization
*/
import type { Blobbi } from '@/types/blobbi';
/**
* All available adult evolution forms.
* Each form corresponds to a folder in assets/
*/
export const ADULT_FORMS = [
'bloomi',
'breezy',
'cacti',
'catti',
'cloudi',
'crysti',
'droppi',
'flammi',
'froggi',
'leafy',
'mushie',
'owli',
'pandi',
'rocky',
'rosey',
'starri',
] as const;
export type AdultForm = typeof ADULT_FORMS[number];
/**
* Adult visual variant types
*/
export type AdultVariant = 'base' | 'sleeping';
/**
* Adult SVG customization options
*/
export interface AdultSvgCustomization {
/** Base body color */
baseColor?: string;
/** Secondary body color */
secondaryColor?: string;
/** Eye/pupil color */
eyeColor?: string;
}
/**
* Adult SVG resolver options
*/
export interface AdultSvgResolverOptions {
/** Whether the adult is sleeping */
isSleeping?: boolean;
}
/**
* Extracts adult-specific customization from a Blobbi
*/
export function extractAdultCustomization(blobbi: Blobbi): AdultSvgCustomization {
return {
baseColor: blobbi.baseColor,
secondaryColor: blobbi.secondaryColor,
eyeColor: blobbi.eyeColor,
};
}
/**
* Validates if a string is a valid adult form
*/
export function isValidAdultForm(form: string): form is AdultForm {
return ADULT_FORMS.includes(form as AdultForm);
}
/**
* Gets the default adult form (used as fallback)
*/
export function getDefaultAdultForm(): AdultForm {
return 'catti';
}
/**
* Resolves adult form from Blobbi data.
* Uses adult.evolutionForm if set and valid, otherwise derives from seed.
*/
export function resolveAdultForm(blobbi: Blobbi): AdultForm {
// Check explicit evolutionForm first
if (blobbi.adult?.evolutionForm && isValidAdultForm(blobbi.adult.evolutionForm)) {
return blobbi.adult.evolutionForm;
}
// Derive from seed if available
if (blobbi.seed) {
return deriveAdultFormFromSeed(blobbi.seed);
}
// Fallback to default
return getDefaultAdultForm();
}
/**
* Derives adult form deterministically from a seed string.
* Uses simple hash-based selection for consistency.
*/
export function deriveAdultFormFromSeed(seed: string): AdultForm {
// Simple hash: sum of char codes
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
}
// Convert to positive index
const index = Math.abs(hash) % ADULT_FORMS.length;
return ADULT_FORMS[index];
}
+93
View File
@@ -0,0 +1,93 @@
# Baby Blobbi Module
Self-contained module for baby stage Blobbi visuals and customization.
## Overview
This module provides everything needed to render and customize baby stage Blobbis:
- **SVG Assets**: Base and sleeping variants
- **SVG Resolution**: Loading and variant selection
- **Customization**: Color and appearance customization
- **Type Safety**: Full TypeScript support
## Module Structure
```
src/blobbi/baby-blobbi/
├── assets/
│ ├── blobbi-baby-base.svg # Awake baby variant
│ └── blobbi-baby-sleeping.svg # Sleeping baby variant
├── lib/
│ ├── baby-svg-resolver.ts # SVG loading and resolution
│ └── baby-svg-customizer.ts # Color customization utilities
├── types/
│ └── baby.types.ts # Type definitions
├── index.ts # Barrel exports
└── README.md # This file
```
## Usage
### Basic SVG Resolution
```typescript
import { resolveBabySvg, getBabyBaseSvg, getBabySleepingSvg } from '@/blobbi/baby-blobbi';
// Get specific variant
const awakeSvg = getBabyBaseSvg();
const sleepingSvg = getBabySleepingSvg();
// Resolve from Blobbi instance
const svg = resolveBabySvg(blobbi, { isSleeping: false });
```
### Color Customization
```typescript
import { customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
// Get base SVG
const baseSvg = getBabyBaseSvg();
// Apply Blobbi's colors
const customizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, false);
```
### Preloading
```typescript
import { preloadBabySvgs } from '@/blobbi/baby-blobbi';
// Preload all baby SVGs for quick switching
preloadBabySvgs();
```
## Customization Options
The module supports three color customizations:
- **baseColor**: Primary body color
- **secondaryColor**: Secondary gradient color
- **eyeColor**: Pupil/eye color (not applied to sleeping variant)
## Design Principles
1. **Portability**: Self-contained, minimal external dependencies
2. **Type Safety**: Full TypeScript coverage
3. **Performance**: Eager loading via Vite for instant access
4. **Consistency**: Follows established patterns from egg module
5. **Separation**: Baby-specific logic isolated from adult/egg logic
## Integration
This module is designed to be:
- Imported via barrel exports from `@/blobbi/baby-blobbi`
- Used alongside egg and adult modules
- Easily moved to other projects with minimal changes
## Related Modules
- **Egg Module**: `src/egg/` - Egg stage visuals and incubation
- **Adult Module**: Adult stage visuals (to be refactored similarly)
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Body gradient -->
<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="60%" style="stop-color:#7c3aed;stop-opacity:1" />
<stop offset="100%" style="stop-color:#6d28d9;stop-opacity:1" />
</radialGradient>
<!-- Eye gradient -->
<radialGradient id="blobbiEyeGradient" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f1f5f9;stop-opacity:1" />
</radialGradient>
<!-- Pupil gradient -->
<radialGradient id="blobbiPupilGradient" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<!-- Mouth gradient -->
<linearGradient id="blobbiMouthGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main body - cute water droplet shape -->
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
fill="url(#blobbiBodyGradient)" />
<!-- Soft inner glow -->
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
<!-- Eyes (white/base eye shapes) -->
<ellipse cx="38" cy="45" rx="8" ry="10" fill="url(#blobbiEyeGradient)" />
<ellipse cx="62" cy="45" rx="8" ry="10" fill="url(#blobbiEyeGradient)" />
<!-- Pupils (pupil + highlights) -->
<circle cx="38" cy="46" r="6" fill="url(#blobbiPupilGradient)" />
<circle cx="62" cy="46" r="6" fill="url(#blobbiPupilGradient)" />
<circle cx="40" cy="44" r="2" fill="white" />
<circle cx="64" cy="44" r="2" fill="white" />
<!-- Mouth -->
<path d="M 42 62 Q 50 68 58 62" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Soft blush for cuteness -->
<ellipse cx="22" cy="55" rx="6" ry="4" fill="rgba(255,182,193,0.5)" />
<ellipse cx="78" cy="55" rx="6" ry="4" fill="rgba(255,182,193,0.5)" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Body gradient -->
<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="60%" style="stop-color:#7c3aed;stop-opacity:1" />
<stop offset="100%" style="stop-color:#6d28d9;stop-opacity:1" />
</radialGradient>
<!-- Mouth gradient -->
<linearGradient id="blobbiMouthGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main body -->
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
fill="url(#blobbiBodyGradient)" />
<!-- Soft inner glow -->
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
<!-- Sleeping eyes -->
<path d="M 30 45 Q 40 48 45 45" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 55 45 Q 65 48 70 45" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="50" cy="65" r="1.5" fill="url(#blobbiMouthGradient)" />
<!-- Z's for sleeping -->
<text x="75" y="25" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="80" y="20" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="83" y="16" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+36
View File
@@ -0,0 +1,36 @@
/**
* Baby Blobbi Module
*
* Self-contained module for baby stage Blobbi visuals and customization.
* This module includes:
* - Baby SVG assets (awake and sleeping)
* - SVG resolution and loading utilities
* - Color and customization utilities
* - Type definitions
*
* This module is designed to be portable and can be moved to other projects.
*/
// Types
export type {
BabyVariant,
BabySvgCustomization,
BabySvgResolverOptions
} from './types/baby.types';
export { extractBabyCustomization } from './types/baby.types';
// SVG Resolution
export {
getBabyBaseSvg,
getBabySleepingSvg,
getBabySvgByVariant,
resolveBabySvg,
preloadBabySvgs,
} from './lib/baby-svg-resolver';
// SVG Customization
export {
customizeBabySvg,
customizeBabySvgFromBlobbi,
} from './lib/baby-svg-customizer';
@@ -0,0 +1,224 @@
/**
* Baby Blobbi SVG Customizer
*
* Handles applying colors and customizations to baby SVG content
*/
import { Blobbi } from '@/types/blobbi';
import { BabySvgCustomization } from '../types/baby.types';
/**
* Lighten a color by a percentage
*/
function lightenColor(color: string, percent: number): string {
// Handle hex colors
if (color.startsWith('#')) {
const num = parseInt(color.slice(1), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) + amt;
const G = (num >> 8 & 0x00FF) + amt;
const B = (num & 0x0000FF) + amt;
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1).toUpperCase();
}
// Return as-is for non-hex colors (rgb, etc.)
return color;
}
/**
* Apply color customizations to baby SVG
*
* @param svgText - The SVG content to customize
* @param customization - Color customization options
* @param isSleeping - Whether the Blobbi is sleeping (affects eye rendering)
* @param instanceId - Optional unique ID to prevent gradient ID collisions when multiple Blobbis are rendered
*/
export function customizeBabySvg(
svgText: string,
customization: BabySvgCustomization,
isSleeping: boolean = false,
instanceId?: string
): string {
let modifiedSvg = svgText;
// Ensure SVG fills its container by adding width/height attributes
// This is needed because the SVG only has viewBox, and without explicit dimensions
// it may not fill flex containers properly
modifiedSvg = ensureSvgFillsContainer(modifiedSvg);
// Only apply customizations if we have colors
if (!customization.baseColor && !customization.secondaryColor && !customization.eyeColor) {
// Still uniquify IDs if instanceId provided (even without color changes)
if (instanceId) {
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
}
return modifiedSvg;
}
// Apply body gradient customization
if (customization.baseColor) {
modifiedSvg = applyBodyGradient(modifiedSvg, customization);
}
// Apply eye color customization (skip for sleeping SVGs - eyes are closed)
if (customization.eyeColor && !isSleeping) {
modifiedSvg = applyEyeColor(modifiedSvg, customization.eyeColor);
}
// Make all IDs unique to prevent collisions when multiple Blobbis are rendered
if (instanceId) {
modifiedSvg = uniquifySvgIds(modifiedSvg, instanceId);
}
return modifiedSvg;
}
/**
* Ensure SVG has width/height attributes so it fills its container
*/
function ensureSvgFillsContainer(svgText: string): string {
// Check if width and height are already set
if (/\swidth=/.test(svgText) && /\sheight=/.test(svgText)) {
return svgText;
}
// Add width="100%" height="100%" to the SVG tag
return svgText.replace(
/<svg([^>]*)>/,
'<svg$1 width="100%" height="100%">'
);
}
/**
* Make all SVG definition IDs unique by prefixing with an instance ID.
* This prevents gradient ID collisions when multiple Blobbis are rendered on the same page.
*
* Updates both:
* - Definition IDs: id="gradientName" → id="prefix_gradientName"
* - References: url(#gradientName) → url(#prefix_gradientName)
*/
function uniquifySvgIds(svgText: string, instanceId: string): string {
// Generate a unique prefix from the full instance ID
// Sanitize to only allow valid SVG ID characters (letters, numbers, underscore, hyphen)
// Note: instanceId format is "blobbi-{pubkeyPrefix12}-{petId10}" so we need the full ID
// to distinguish between Blobbis owned by the same user
const prefix = `b_${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
// Find all IDs defined in the SVG (in defs, gradients, clipPaths, etc.)
const idPattern = /\bid=["']([^"']+)["']/g;
const ids = new Set<string>();
let match;
while ((match = idPattern.exec(svgText)) !== null) {
ids.add(match[1]);
}
// Replace each ID and its references
let modified = svgText;
for (const id of ids) {
const prefixedId = `${prefix}_${id}`;
// Replace the ID definition
modified = modified.replace(
new RegExp(`\\bid=["']${id}["']`, 'g'),
`id="${prefixedId}"`
);
// Replace url() references
modified = modified.replace(
new RegExp(`url\\(#${id}\\)`, 'g'),
`url(#${prefixedId})`
);
// Replace xlink:href references (older SVG format)
modified = modified.replace(
new RegExp(`xlink:href=["']#${id}["']`, 'g'),
`xlink:href="#${prefixedId}"`
);
// Replace href references (newer SVG format)
modified = modified.replace(
new RegExp(`\\bhref=["']#${id}["']`, 'g'),
`href="#${prefixedId}"`
);
}
return modified;
}
/**
* Apply body gradient customization
*/
function applyBodyGradient(svgText: string, customization: BabySvgCustomization): string {
const bodyGradientRegex = /<radialGradient[^>]*id=["']blobbiBodyGradient["'][^>]*>([\s\S]*?)<\/radialGradient>/;
const bodyGradientMatch = svgText.match(bodyGradientRegex);
if (!bodyGradientMatch || !customization.baseColor) {
return svgText;
}
let newGradient = '';
if (customization.secondaryColor) {
// Both base_color and secondary_color are present
newGradient = `<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
<stop offset="0%" style="stop-color:${customization.secondaryColor}"/>
<stop offset="60%" style="stop-color:${lightenColor(customization.secondaryColor, 20)}"/>
<stop offset="100%" style="stop-color:${customization.baseColor}"/>
</radialGradient>`;
} else {
// Only base_color is present
newGradient = `<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
<stop offset="0%" style="stop-color:${lightenColor(customization.baseColor, 40)}"/>
<stop offset="60%" style="stop-color:${lightenColor(customization.baseColor, 20)}"/>
<stop offset="100%" style="stop-color:${customization.baseColor}"/>
</radialGradient>`;
}
return svgText.replace(bodyGradientMatch[0], newGradient);
}
/**
* Apply eye color customization
*/
function applyEyeColor(svgText: string, eyeColor: string): string {
const eyeGradientRegex = /<radialGradient[^>]*id=["']blobbiPupilGradient["'][^>]*>([\s\S]*?)<\/radialGradient>/;
const eyeGradientMatch = svgText.match(eyeGradientRegex);
if (!eyeGradientMatch) {
return svgText;
}
const newEyeGradient = `<radialGradient id="blobbiPupilGradient" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:${lightenColor(eyeColor, 30)}"/>
<stop offset="100%" style="stop-color:${eyeColor}"/>
</radialGradient>`;
return svgText.replace(eyeGradientMatch[0], newEyeGradient);
}
/**
* Convenience function to customize baby SVG from a Blobbi instance.
*
* Uses the Blobbi's ID to uniquify SVG IDs, preventing gradient collisions
* when multiple Blobbis are rendered on the same page.
*/
export function customizeBabySvgFromBlobbi(
svgText: string,
blobbi: Blobbi,
isSleeping: boolean = false
): string {
const customization: BabySvgCustomization = {
baseColor: blobbi.baseColor,
secondaryColor: blobbi.secondaryColor,
eyeColor: blobbi.eyeColor,
};
// Pass blobbi.id to uniquify gradient IDs and prevent collisions
return customizeBabySvg(svgText, customization, isSleeping, blobbi.id);
}
@@ -0,0 +1,95 @@
// Auto-generated: inlined SVG content for baby Blobbi assets.
// Source: src/blobbi/baby-blobbi/assets/
export const BABY_BASE_SVG = `<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Body gradient -->
<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="60%" style="stop-color:#7c3aed;stop-opacity:1" />
<stop offset="100%" style="stop-color:#6d28d9;stop-opacity:1" />
</radialGradient>
<!-- Eye gradient -->
<radialGradient id="blobbiEyeGradient" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f1f5f9;stop-opacity:1" />
</radialGradient>
<!-- Pupil gradient -->
<radialGradient id="blobbiPupilGradient" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<!-- Mouth gradient -->
<linearGradient id="blobbiMouthGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main body - cute water droplet shape -->
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
fill="url(#blobbiBodyGradient)" />
<!-- Soft inner glow -->
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
<!-- Eyes (white/base eye shapes) -->
<ellipse cx="38" cy="45" rx="8" ry="10" fill="url(#blobbiEyeGradient)" />
<ellipse cx="62" cy="45" rx="8" ry="10" fill="url(#blobbiEyeGradient)" />
<!-- Pupils (pupil + highlights) -->
<circle cx="38" cy="46" r="6" fill="url(#blobbiPupilGradient)" />
<circle cx="62" cy="46" r="6" fill="url(#blobbiPupilGradient)" />
<circle cx="40" cy="44" r="2" fill="white" />
<circle cx="64" cy="44" r="2" fill="white" />
<!-- Mouth -->
<path d="M 42 62 Q 50 68 58 62" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Soft blush for cuteness -->
<ellipse cx="22" cy="55" rx="6" ry="4" fill="rgba(255,182,193,0.5)" />
<ellipse cx="78" cy="55" rx="6" ry="4" fill="rgba(255,182,193,0.5)" />
</svg>`;
export const BABY_SLEEPING_SVG = `<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Body gradient -->
<radialGradient id="blobbiBodyGradient" cx="0.3" cy="0.25">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="60%" style="stop-color:#7c3aed;stop-opacity:1" />
<stop offset="100%" style="stop-color:#6d28d9;stop-opacity:1" />
</radialGradient>
<!-- Mouth gradient -->
<linearGradient id="blobbiMouthGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main body -->
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
fill="url(#blobbiBodyGradient)" />
<!-- Soft inner glow -->
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
<!-- Sleeping eyes -->
<path d="M 30 45 Q 40 48 45 45" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 55 45 Q 65 48 70 45" stroke="url(#blobbiMouthGradient)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="50" cy="65" r="1.5" fill="url(#blobbiMouthGradient)" />
<!-- Z's for sleeping -->
<text x="75" y="25" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="80" y="20" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="83" y="16" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>`;
@@ -0,0 +1,81 @@
/**
* Baby Blobbi SVG Resolver
*
* Handles loading and resolving baby stage SVG assets
*/
import { Blobbi } from '@/types/blobbi';
import { BabyVariant, BabySvgResolverOptions } from '../types/baby.types';
import { BABY_BASE_SVG, BABY_SLEEPING_SVG } from './baby-svg-data';
/**
* Get baby base SVG content
*/
export function getBabyBaseSvg(): string {
return BABY_BASE_SVG;
}
/**
* Get baby sleeping SVG content
*/
export function getBabySleepingSvg(): string {
return BABY_SLEEPING_SVG;
}
/**
* Get baby SVG by variant
*/
export function getBabySvgByVariant(variant: BabyVariant): string {
return variant === 'sleeping' ? getBabySleepingSvg() : getBabyBaseSvg();
}
/**
* Resolve baby Blobbi SVG content
*/
export function resolveBabySvg(blobbi: Blobbi, options: BabySvgResolverOptions = {}): string {
const { isSleeping = false } = options;
if (blobbi.lifeStage !== 'baby') {
console.warn('resolveBabySvg called with non-baby Blobbi');
return getFallbackBabySvg();
}
return isSleeping ? getBabySleepingSvg() : getBabyBaseSvg();
}
/**
* Preload baby SVGs for quick switching
*/
export function preloadBabySvgs(): void {
// Both SVGs are inlined constants — this function exists for API consistency
// This function exists for API consistency
getBabyBaseSvg();
getBabySleepingSvg();
}
/**
* Get fallback baby SVG content
*/
function getFallbackBabySvg(): string {
return `
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="fallbackBodyGradient" cx="0.3" cy="0.25">
<stop offset="0%" style="stop-color:#8b5cf6"/>
<stop offset="60%" style="stop-color:#7c3aed"/>
<stop offset="100%" style="stop-color:#6d28d9"/>
</radialGradient>
</defs>
<path d="M 50 15 Q 50 10 50 15 Q 72 25 75 55 Q 75 80 50 88 Q 25 80 25 55 Q 28 25 50 15"
fill="url(#fallbackBodyGradient)" />
<ellipse cx="50" cy="45" rx="15" ry="20" fill="white" opacity="0.2" />
<ellipse cx="38" cy="45" rx="8" ry="10" fill="#fff" />
<ellipse cx="62" cy="45" rx="8" ry="10" fill="#fff" />
<circle cx="38" cy="46" r="6" fill="#374151" />
<circle cx="62" cy="46" r="6" fill="#374151" />
<circle cx="40" cy="44" r="2" fill="white" />
<circle cx="64" cy="44" r="2" fill="white" />
<path d="M 42 62 Q 50 68 58 62" stroke="#374151" stroke-width="2.5" fill="none" stroke-linecap="round" />
</svg>
`;
}

Some files were not shown because too many files have changed in this diff Show More