Compare commits

...

307 Commits

Author SHA1 Message Date
Alex Gleason 0a5e72efd0 release: v2.6.1 2026-04-06 00:58:23 -05:00
Alex Gleason 0f1021e0d3 Switch nsite preview from local-shakespeare.dev to iframe.diy
Replace the local-shakespeare.dev preview domain with iframe.diy, which
provides a service-worker based sandbox. This brings the nsite preview
implementation in line with Shakespeare's approach.

Key changes:
- iframe.diy handshake: listen for 'ready', respond with 'init'
- Derive private HMAC-SHA256 subdomains via deriveIframeSubdomain('nsite', ...)
- Inject preview script into HTML responses for console forwarding,
  SPA navigation tracking, and /index.html path normalization
- Remove sandbox attribute (iframe.diy manages its own sandboxing)
- Serve injected script from virtual /__injected__/preview.js path
2026-04-06 00:51:39 -05:00
Alex Gleason be65c659b2 Derive private iframe.diy subdomains with HMAC-SHA256
The i-tag UUID used for webxdc coordination is attacker-controlled.
Using it directly as the iframe.diy subdomain lets a malicious event
author pick a subdomain that collides with another app's origin,
gaining access to its localStorage/IndexedDB.

Introduce a persistent random seed in localStorage (ditto:seed) and
derive the subdomain as base36(HMAC-SHA256(seed, prefix|identifier)).
The prefix (e.g. "webxdc") domain-separates different use-cases.
The subdomain is stable per device+app but unpredictable to event
authors.
2026-04-06 00:33:55 -05:00
Alex Gleason b64aa4b24a Add Content-Security-Policy to webxdc fetch responses
Apply a strict CSP header to every response served from the .xdc archive
to enforce the webxdc offline sandbox. Permits same-origin, inline, eval,
wasm, data: and blob: but blocks all external network access.
2026-04-05 23:20:21 -05:00
Alex Gleason f63d8943d8 Replace webxdc.app with iframe.diy for webxdc sandboxing
Migrate the webxdc iframe runtime from webxdc.app to iframe.diy. Instead of
sending ZIP bytes to the iframe and having the SW unzip them, the parent now
unzips the .xdc archive and serves files via iframe.diy's fetch-proxy RPC.
A webxdc bridge script is served as a virtual /webxdc.js file, and a
<script> tag is injected into HTML responses via DOMParser to load it.

- Rewrite Webxdc.tsx to use iframe.diy's ready/init/fetch protocol
- Unzip .xdc archives on the parent side and serve via fetch RPC responses
- Serve webxdc bridge as virtual /webxdc.js via the fetch handler
- Inject <script src="/webxdc.js"> into HTML using DOMParser
2026-04-05 22:15:16 -05:00
Alex Gleason 517a72cce7 Fix profile avatar/banner lightbox appearing behind right sidebar
Portal ProfileImageLightbox to document.body, matching the fix
already applied to the shared Lightbox component. Without the
portal, the lightbox was trapped inside the center column's z-0
stacking context from MainLayout, causing the right sidebar
(a sibling outside that context) to paint on top.
2026-04-05 20:37:09 -05:00
Chad Curtis 6fc68766c9 Merge branch 'refactor/blobbi-remove-item-quantity-selection' into 'main'
Remove quantity selectors from Blobbi item usage flows

See merge request soapbox-pub/ditto!163
2026-04-06 00:38:27 +00:00
filemon ae81c13cc1 Merge branch 'main' into fix-items-blobbi-companion 2026-04-05 21:21:31 -03:00
filemon 41358d27ce Update comments and docs to reflect item-as-ability model
Replace outdated references to 'inventory items', 'consume',
'quantity', and 'storage decrement' across 14 files. Comments
now consistently describe items as reusable abilities sourced
from the shop catalog, not consumable inventory.
2026-04-05 21:18:35 -03:00
Chad Curtis ac8bffba23 Merge branch 'fix-items-blobbi-companion' into 'main'
Remove inventory ownership requirement from Blobbi companion items

See merge request soapbox-pub/ditto!162
2026-04-05 23:59:18 +00:00
filemon 748365de40 Remove quantity selectors from Blobbi item usage flows
Items are now single-use abilities — tap item, press Use, effect
happens immediately. No confirmation dialogs or quantity selectors.

Changes:
- Remove BlobbiUseItemConfirmDialog and InventoryUseConfirmDialog
- Remove quantity state, selectors, and multi-use loops from modals
- Simplify mutation hooks to always apply item effects once
- Drop quantity parameter from UseItemFunction type signature
- Update all call sites through the full stack (BlobbiPage, context,
  companion layer, companion item use hook)
2026-04-05 20:58:31 -03:00
filemon 361f8b9506 Remove inventory ownership requirement from Blobbi companion items
Items are now treated as abilities/tools unlocked by stage, not
consumable inventory that must be purchased. All catalog items are
shown in the companion action menu regardless of inventory quantity.

Changes:
- Source items from shop catalog instead of user inventory storage
- Remove quantity validation and storage decrement on item use
- Remove quantity badges and 'in inventory' text from all modals
- Keep stage-based filtering (egg vs baby/adult restrictions)
- Cap quantity selector at 99 instead of inventory count
2026-04-05 19:59:30 -03:00
Alex Gleason c1ec7a25ed Use published_at tag to show created/updated verbs in event action headers
Replaceable and addressable event headers now distinguish between
first publish and subsequent updates using the published_at tag:
- published_at == created_at → 'created' verb (e.g. 'created an emoji pack')
- published_at != created_at → 'updated' verb (e.g. 'updated an emoji pack')
- no published_at → 'shared' fallback for backward compatibility
2026-04-05 15:12:19 -05:00
Alex Gleason 272586d033 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-05 14:54:41 -05:00
Alex Gleason c77c098843 Add NIP-24 published_at to useNostrPublish for replaceable/addressable events
Extend useNostrPublish with an optional `prev` property on the event
template. For replaceable and addressable kinds, the hook automatically
manages published_at:

- First publish (no prev): set published_at equal to created_at
- Update (prev provided): preserve published_at from the old event
- Old event lacks published_at: don't fabricate one
- Caller already set published_at in tags: leave it alone

Callers pass `prev` when they have the old event from fetchFreshEvent,
giving the hook everything it needs without extra network requests.
Updated all 11 call sites that publish replaceable or addressable events.
Documents the prev convention in AGENTS.md.
2026-04-05 14:23:58 -05:00
Chad Curtis ea7afa94f7 Fix infinite scroll on custom profile tab feeds reloading same content
Two issues caused custom tab feeds (e.g. Magic Decks) to loop:

1. ProfileSavedFeedContent flattened pages without deduplication, so
   events returned by multiple pages rendered as visible duplicates.

2. useTabFeed only stopped paginating when rawCount === 0. For
   addressable events the relay keeps returning the same latest
   versions, so rawCount never hit zero. Changed to rawCount < limit
   (relay returned fewer than requested = exhausted).
2026-04-05 12:23:25 -05:00
Chad Curtis 0c29506402 Fix all 50 ESLint warnings by extracting non-component exports and adding missing deps
- Extract utility functions from component files into dedicated modules
  to fix react-refresh/only-export-components warnings:
  - parseBadgeDefinition -> src/lib/parseBadgeDefinition.ts
  - parseProfileBadges -> src/lib/parseProfileBadges.ts
  - getColors, paletteToTheme -> src/lib/colorMomentUtils.ts
  - parseDimToAspectRatio, eventToMediaItem -> src/lib/mediaUtils.ts
  - isAudioUrl, isImageUrl, isVideoUrl -> src/lib/mediaTypeDetection.ts
  - buildKindOptions, parseSelectedKinds -> src/lib/feedFilterUtils.ts
  - useVideoThumbnail -> src/hooks/useVideoThumbnail.ts
  - useEnvelopeDimensions -> src/hooks/useEnvelopeDimensions.ts
  - usePortalContainer -> src/hooks/usePortalContainer.ts
  - useAudioPlayer -> src/contexts/audioPlayerContextDef.ts
  - SubHeaderBar context/hooks -> src/components/SubHeaderBarContext.ts
  - EmotionDev hooks -> src/blobbi/dev/useEmotionDev.ts
  - BlobbiActions context def -> BlobbiActionsContextDef.ts

- Remove export from internal-only functions (useEventComments,
  parseEmojiPack, useScrollCarets, formatEffectSummary, getSortedEffectEntries)

- Fix react-hooks/exhaustive-deps warnings by adding missing dependencies
  to useEffect/useCallback/useMemo hooks across 14 files

- Fix logical expression dependency warnings by wrapping conditional
  values (tasks, pubkeys, authorPubkeys) in useMemo

- Move module-level constants (CORE_TAB_IDS, CORE_TAB_LABELS,
  DEFAULT_TAB_LABELS) out of ProfilePage component body

- Reorder usePushNotifications hooks so syncPreferences is defined
  before enable to fix block-scoped variable error
2026-04-05 11:57:31 -05:00
Chad Curtis b0609e7877 Make reaction emoji visible per-row in interactions modal
Replace grouped-by-emoji layout with a flat list where each reaction
row shows an inline emoji badge (similar to the zap amount badge).
Add an emoji summary bar at the top when multiple emoji types are
present. This makes it immediately obvious who reacted with what.
2026-04-05 11:07:55 -05:00
Chad Curtis 946be28b81 Use standard author layout for follow pack/set cards instead of content-first 2026-04-05 10:59:01 -05:00
Chad Curtis 89250c7472 Add kind action headers for follow packs and follow sets 2026-04-05 10:56:22 -05:00
Chad Curtis cfc7a0d31c Extract useActiveTabIndicator hook to deduplicate TabButton and SortableTabChip
The scroll-aware active indicator reporting and scroll listener logic was
duplicated between TabButton and SortableTabChip. Extract into a shared
useActiveTabIndicator hook in SubHeaderBar.
2026-04-05 10:47:34 -05:00
Chad Curtis 21003e3aed Add edit button for custom profile tabs and clear underline on tab removal
- Add pencil icon to SortableTabChip for editing existing custom tabs
- Wire onEdit to open ProfileTabEditModal with the existing tab data
- Clear the active arc underline when an active tab is removed (cleanup in useLayoutEffect)
- Round dnd-kit transform values to avoid sub-pixel rendering issues
2026-04-05 10:42:23 -05:00
Chad Curtis 93e8a6290f Merge branch 'fix/167-post-compose-box-renders-too-small-and-unclickable' into 'main'
fix: intermittent mobile compose modal collapse and unclickable input

Closes #167

See merge request soapbox-pub/ditto!146
2026-04-05 15:28:55 +00:00
Dmitriy E 47831ffa64 fix: intermittent mobile compose modal collapse and unclickable input 2026-04-05 10:27:43 -05:00
Chad Curtis 1533420320 Fix desktop tab overflow and add interest tab management in settings
SubHeaderBar: add left/right chevron scroll arrows on desktop when tabs
overflow, with gradient fade. Auto-scroll active tab into view and keep
arc hover/active indicators aligned during horizontal scroll.

ContentSettings: add Interest Tabs section with inline add/remove for
hashtags and geotags. Remove buttons always visible (mobile-friendly),
X icons use strokeWidth 4.
2026-04-05 10:20:58 -05:00
Chad Curtis e3ef542875 Fix desktop tab bar overflow: add scroll arrows and auto-scroll active tab into view
On desktop, overflowing feed tabs were completely inaccessible since the
scrollbar was hidden and there was no swipe gesture. Add left/right
chevron scroll buttons that appear only on desktop when tabs overflow,
with gradient fade indicators. Also auto-scrolls the active tab into
view when switching tabs, and keeps the arc hover/active indicators
aligned during horizontal scroll.
2026-04-05 10:15:01 -05:00
Chad Curtis 3bf55990c0 Fix missing bottom border on collapsed thread expand button
When a depth-collapsed 'Show X more replies' button was the last item
in a reply sequence, it lacked a bottom border separator. Added an
isLast prop to ExpandThreadButton that adds border-b when the button
terminates the visual sequence.
2026-04-05 09:50:52 -05:00
Chad Curtis 283b31813c release: v2.6.0 2026-04-05 08:31:35 -05:00
Chad Curtis 6e1197a067 Redesign LinkFooter as compact icon+label chips 2026-04-05 08:27:37 -05:00
Chad Curtis b7d1fbf860 Fix mobile sidebar bottom links clipping into safe area 2026-04-05 08:09:21 -05:00
Chad Curtis 8fde660075 Fix Blobbi page missing bg-background/85 overlay on custom themes
DashboardShell uses fixed positioning on mobile, placing it directly
over the body background image. Without the bg-background/85 class
that MainLayout's center column provides, the raw background image
showed through unthemed. Add the same 85% opacity background overlay
used consistently across the rest of the app.
2026-04-05 07:29:06 -05:00
Chad Curtis 50c7d67928 Fix blobbi state resets caused by stale cache reads and invalidation races
All blobbi mutations now follow the read-modify-write pattern: fetch fresh
state from relays before mutating, then optimistically update the cache.
This prevents two classes of bugs:

1. Stale cache reads: mutations were reading from TanStack Query cache
   (30s staleTime) instead of relays, causing newer events to be silently
   overwritten with old stats when actions happened within the cache window.

2. Invalidation races: every mutation called invalidateCompanion() after
   the optimistic update, which triggered a refetch from relays before the
   just-published event had propagated, overwriting the optimistic data
   with the pre-mutation state.

Changes:
- ensureCanonicalBlobbiBeforeAction now fetches fresh companion + profile
  from relays (the read step) instead of using cached closure values
- useBlobbiCareActivity fetches fresh companion before streak updates
- Removed all invalidateCompanion()/invalidateProfile() calls after
  optimistic updates across every action hook
- updateCompanionEvent now updates ALL blobbi-collection query caches
  for the user, not just the specific d-tag list it was instantiated with,
  keeping BlobbiPage and companion layer caches in sync
2026-04-05 07:20:26 -05:00
Chad Curtis e355c43925 Fix cross-device settings sync and smart sync gate
Settings (theme, sidebar, etc.) changed on one device were not applied
on other devices. Three root causes:

1. NostrSync seeded lastSyncedTimestamp to remoteSync on first load,
   then the guard (remoteSync <= lastSyncedTimestamp) blocked the same
   data from being applied. Settings were never applied on page reload.

2. The encrypted settings query had staleTime: Infinity and
   refetchOnWindowFocus: false, so remote changes were never fetched.

3. useInitialSync was missing customTheme, corsProxy, faviconUrl, and
   linkPreviewUrl fields.

To avoid gating every F5 behind a spinner, a lastSync timestamp is
now persisted to localStorage whenever settings are applied. On reload,
InitialSyncGate checks this: if present, render immediately from
localStorage and let NostrSync hot-swap remote changes in background.
If absent (new browser, cleared storage), show the spinner until
settings load.
2026-04-05 06:55:05 -05:00
Chad Curtis 696204870d Fix custom theme not applying on new device login
Initial sync applied the theme mode (e.g. 'custom') from encrypted
settings but not the customTheme config (colors, fonts, background),
so the theme appeared broken on first login requiring manual setup
which also triggered an unwanted kind 16767 publish.
2026-04-05 06:33:43 -05:00
Chad Curtis 0a7e01d17c Match own-profile follow link style to the following/already-following states
Use the same icon + primary semibold text + full-width button layout
instead of muted small text with an outline button.
2026-04-05 06:17:52 -05:00
Chad Curtis dd87bc96ec Fix top nav arc overlapping letter compose picker drawer
Set hasSubHeader on LetterComposePage so the MobileTopBar uses a flat
rect instead of the down-arc variant, preventing the 20px arc overhang
from painting over the LetterEditor picker panel.
2026-04-05 06:15:34 -05:00
Chad Curtis a12d5db560 Add follow URI system with QR sharing and immersive follow page
Introduce a /follow/:npub deep link that auto-follows a user when
visited by a logged-in user, or presents an immersive business card
with a 'Follow on Ditto' CTA for logged-out visitors. The page applies
the target user's profile theme, renders their feed with infinite
scroll, and uses the same banner/avatar/arc styling as the main profile.

Add a FollowQRDialog that generates a themed QR code for the follow
URL. The QR colors are derived from the active theme: primary color
for modules (with contrast-safe darkening/lightening), and background
color for the QR background. Foreground text color is used when it is
colorful and offers significantly better contrast.

Surface the QR dialog from: own profile page (top-level button),
profile more menu, desktop sidebar account popover, and mobile drawer.
2026-04-05 06:01:48 -05:00
Alex Gleason 614634789c Merge branch 'main' of nostr://npub10qdp2fc9ta6vraczxrcs8prqnv69fru2k6s2dj48gqjcylulmtjsg9arpj/relay.ngit.dev/ditto 2026-04-04 23:17:35 -05:00
Alex Gleason 29696fa3d3 Apply nearest-neighbor scaling to small custom emoji images
Custom emoji images with natural dimensions <= 16x16 now render with
image-rendering: pixelated to preserve crisp pixels instead of blurring.

Also consolidates 6 direct <img> sites to use the shared CustomEmojiImg
component so all custom emoji rendering benefits from this behavior.
2026-04-04 22:58:42 -05:00
Chad Curtis ffc31e8e8f Merge branch 'fix/blobbi-reuse-existing-eggs' into 'main'
Fix repeated egg creation and reuse existing eggs during ceremony

See merge request soapbox-pub/ditto!158
2026-04-05 02:09:45 +00:00
filemon 720a7e91fe Base ceremony decision on actual companion stages, not onboardingDone flag
The onboardingDone flag can be true on inconsistent accounts where the
user never actually hatched an egg. Now the ceremony check always waits
for companions to load and inspects their real stages:

- Any baby/adult exists: skip ceremony, auto-fix flag if needed
- Only eggs exist: ceremony with existing egg (regardless of flag)
- No companions resolved: ceremony creates a new egg

A ceremonyCheckDone flag prevents the effect from re-firing as
companion data updates during normal use.
2026-04-04 21:06:22 -03:00
filemon 05096e2cd9 Fix duplicate egg creation on every page load during onboarding
The ceremony was triggered whenever onboardingDone was false, without
waiting for companion data to load. This caused a new egg to be
published on every page visit/refresh for users mid-onboarding.

Now the decision tree waits for companions to load before deciding:
- No profile / no pets: ceremony creates a new egg (brand new user)
- Has baby/adult: skip ceremony, auto-fix onboardingDone flag
- Has only eggs: reuse an existing egg via existingCompanion prop
- Stale pet references: treat as new user

The chosen egg is locked in a ref so mid-ceremony refreshes don't
switch eggs or create duplicates.
2026-04-04 20:37:11 -03:00
filemon 05667460eb Fix first-time egg ceremony not covering RightSidebar
Portal the first-time hatching ceremony to document.body with z-[100],
matching the subsequent hatch ceremony implementation. The overlay was
previously rendered inline inside the center column's stacking context
(relative z-0), which prevented its fixed z-50 from painting over the
sibling RightSidebar.
2026-04-04 20:15:52 -03:00
Chad Curtis b10dae7655 Persist companion position across page navigations instead of replaying entry animation 2026-04-04 17:18:24 -05:00
Chad Curtis c799b9efd6 Fix crash when rendering egg: guard against undefined allTags from CompanionData cast 2026-04-04 17:14:55 -05:00
Chad Curtis fe4834e157 Remove deprecated dead code: selector modal state, useRerollMission plumbing, unused companion prop 2026-04-04 17:11:43 -05:00
Chad Curtis 5d972249a4 Fix all ESLint errors: remove unused imports, variables, and props across 4 files 2026-04-04 17:03:32 -05:00
Chad Curtis f607a01577 Fix ambiguous Tailwind duration-[2000ms] class warning 2026-04-04 16:50:56 -05:00
Chad Curtis 1e232e6a9e Blobbi hatching ceremony: immersive egg-to-blobbi experience with redesigned care UI
Replaces the old onboarding tour with a full hatching ceremony featuring golden aura,
sparkles, typewriter dialog, and fade-to-white reveal. Redesigns the BlobbiPage with
curved arc stats, floating action bubbles, overlay drawer tabs, and responsive layout.
Adds companion pill button, simplified photo modal, and egg animation styles.
Removes the old tour system (FirstHatchTour, tour hooks, tour types).
2026-04-04 16:49:51 -05:00
Alex Gleason 431c388129 release: v2.5.2 2026-04-04 13:54:13 -05:00
Alex Gleason 72b63dac21 Set default AppConfig.client to Ditto's kind 31990 handler naddr 2026-04-04 13:30:09 -05:00
Chad Curtis be82cb9626 Propagate relay and author hints to all event fetch call sites
Wire relay URL hints (from e/E tag position [2]) and author pubkey hints
(from e/E tag position [4] or p/P tag fallback) through every component
that fetches a referenced event:

- NoteCard: use getParentEventHints, pass hints through ReplyContext
- ReplyContext: accept and forward relay/author hints to EmbeddedNote
- CommentContext: extract hints from E/A tags in parseCommentRoot,
  pass to useEvent, useAddrEvent, and EmbeddedNote
- NotificationsPage: extract hints from e tag in ReferencedNoteCard
- usePollVoteLabel: extract hints from e tag for parent poll fetch
- ComposeBox: pass quotedEvent.pubkey as authorHint to EmbeddedNote
2026-04-04 06:03:33 -05:00
Chad Curtis c2c6f711b5 Fix parent author hint extraction and useEvent query cache keying
getParentEventHints only looked at position [4] of the e tag for the parent
author pubkey, but many clients (e.g. Wisp) omit it. When the relay hint
doesn't have the event, Tier 3 (NIP-65 outbox resolution) never fired
because authorHint was undefined. Now falls back to the first p tag, which
per NIP-10 convention holds the parent author's pubkey.

Also include relays and authorHint in the useEvent queryKey so calls with
different hints aren't served stale null results from a hint-less query.
2026-04-04 05:50:21 -05:00
Chad Curtis 3fba81a7d2 Fix ancestor thread fetching to use relay hints and author outbox relays
AncestorThread was calling useEvent(eventId) without relay hints or author
hints, so ancestor events only resolved via Tier 1 (user's configured relays).
Tiers 2 (relay hints from e tags) and 3 (author's NIP-65 outbox relays) were
never activated, causing parent events on personal relays to silently fail.

Added getParentEventHints() to extract relay URL and author pubkey from NIP-10
e tags, and wired both through AncestorThread's recursive chain.
2026-04-04 05:22:28 -05:00
Chad Curtis 6f2b51197f Add option filter bars to poll voters modal with scrollable overflow and accent divider 2026-04-04 03:23:39 -05:00
Chad Curtis 00c801e9dc Add poll voter interactions, kind 1018 vote rendering, and DRY activity card refactor
Poll voters:
- Clickable voter avatar stack + vote count on polls (before and after voting)
- Voters modal showing each voter with avatar, name, option, and nevent link
- Extract VoterAvatarsButton to DRY the avatar stack pattern

Kind 1018 vote rendering:
- Register in PostDetailPage as compact activity card with parent poll ancestor
- Register in NoteCard with threaded + normal variants (user avatar, not icon)
- Register in CommentContext with Vote icon, 'a vote' label, and rich hover showing voter + option
- Extract usePollVoteLabel hook to DRY vote label resolution across 3 call sites

ActivityCard refactor:
- Extract shared ActivityCard and ActorRow from NoteCard
- Refactor reaction (kind 7), repost (kind 6/16), zap (kind 9735), and poll vote (kind 1018)
- Reuse ActivityCard in PostDetailPage for vote detail view
- Net ~250 line reduction in NoteCard
2026-04-04 03:09:20 -05:00
Chad Curtis 47e7d05cb9 Add poll voter avatars, voters modal, and kind 1018 vote detail view
- Show clickable voter avatar stack + vote count on polls (both before and after voting)
- Clicking opens a voters modal listing each voter with avatar, name, voted option, and link to their vote nevent
- Extract VoterAvatarsButton to DRY the avatar stack pattern
- Register kind 1018 in PostDetailPage so vote nevents render as compact activity cards (avatar + 'voted' + label)
- Parent poll appears as threaded ancestor above the vote card
- Use PostActionBar for vote detail action buttons
2026-04-04 02:42:19 -05:00
Chad Curtis 4ef6d1b149 Revert "Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data"
This reverts commit ed083bfdad.
2026-04-04 01:56:40 -05:00
Alex Gleason badd19d27c Reorder default sidebar: Blobbi, Badges, Emojis, Letters, Themes 2026-04-04 00:25:16 -05:00
Alex Gleason e67f90582b release: v2.5.1 2026-04-03 23:31:09 -05:00
Alex Gleason 7fa6e574f8 Fix lightbox z-index by portaling inside Lightbox itself, not just ImageGallery
The previous fix (db502b46) only portaled the Lightbox when rendered
from ImageGallery. But Lightbox is also rendered directly by
NoteContent, MediaCollage, and MagicDeckContent — all still trapped
inside the center column's z-0 stacking context (added in 8e3f778f).

Move createPortal(…, document.body) into Lightbox so every consumer
escapes the stacking context automatically.
2026-04-03 23:27:53 -05:00
Alex Gleason 9b36bf3325 release: v2.5.0 2026-04-03 23:09:20 -05:00
Alex Gleason bc1c4cb7cf Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 22:50:34 -05:00
Chad Curtis 119f684fb3 Fix sharp corners on compose box by adding rounded-2xl 2026-04-03 22:41:16 -05:00
Chad Curtis 45134ef9cc Allow file uploads in poll composer
Remove the separate pollQuestion state and poll builder branch. Poll
mode now reuses the normal textarea/preview ternary (with edit/preview
toggle, file uploads, paste handling, imeta tags) and renders poll
options and settings below it.
2026-04-03 22:29:32 -05:00
Chad Curtis db502b462c Fix lightbox appearing behind right sidebar by portaling to document.body 2026-04-03 21:52:05 -05:00
Chad Curtis ed083bfdad Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data
The default pool eoseTimeout (300ms) races and resolves shortly after the
fastest relay. Blobbi pet state and profile data are accuracy-sensitive —
stale data from a single fast relay can cause data loss when mutations
overwrite newer versions on other relays.

- Add eoseTimeout option to fetchFreshEvent and new fetchFreshEvents variant
- Update useBlobbisCollection, useBlobbonautProfile, and useBlobbiSleepToggle
  to use fetchFreshEvents/fetchFreshEvent with eoseTimeout: 1000
- Widen NostrBatcher.req() type to pass through eoseTimeout to NPool
- Gate unconditional console.log in parseBlobbiEvent behind import.meta.env.DEV
- Remove unconditional console.logs from useBlobbisCollection
2026-04-03 21:39:00 -05:00
Alex Gleason 47811f9190 Use NIP-5A canonical subdomains for nsite preview iframe origins
Instead of generating a random session ID for the iframe subdomain,
derive it from the nsite event using the NIP-5A canonical format:
- Root sites (kind 15128): npub subdomain
- Named sites (kind 35128): base36(pubkey) + d-tag subdomain

Extract hexToBase36 and getNsiteSubdomain into a shared utility
used by both NsiteCard and NsitePreviewDialog.
2026-04-03 18:37:28 -05:00
Alex Gleason ba99cdc51c Fix MIME type for nsite assets by always using extension-based detection
Blossom servers commonly return incorrect Content-Type headers (e.g. text/plain
for .js files), causing browsers to reject module scripts under strict MIME
checking. Since we always know the file path from the manifest, use guessMimeType
based on the file extension instead of trusting the Blossom response header.
2026-04-03 18:13:40 -05:00
Alex Gleason 7092f7306f Serve nsite previews directly from Blossom instead of proxying through nsite.lol gateway
NsitePreviewDialog now builds a path→sha256 manifest from the event's 'path'
tags and resolves files directly from Blossom servers (from the event's 'server'
tags, falling back to the user's configured app Blossom servers). Each fetch
request from the iframe is intercepted, the sha256 is looked up in the manifest,
and the blob is fetched from the first Blossom server that responds successfully.
Unknown paths fall back to /index.html to support SPA client-side routing.

- NsitePreviewDialog: remove nsiteUrl proxy, accept NostrEvent instead
- NsiteCard: pass event directly to dialog
- AppHandlerContent: use useAddrEvent to fetch the kind 35128 event by
  pubkey+d-tag from the 'a' tag, then pass the event to the dialog; disable
  Run button until the nsite event is loaded; remove unused hexToBase36
2026-04-03 18:10:22 -05:00
Alex Gleason 357dd56de0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 17:54:42 -05:00
Alex Gleason fadec0574a Add Run button to NsiteCard for in-app nsite preview 2026-04-03 17:49:06 -05:00
Alex Gleason 469806886a Fix card navigation firing on button/link clicks in NoteCard 2026-04-03 17:45:36 -05:00
Alex Gleason f7ab980ecd Fix nsite preview panel height using measured column rect
Replace absolute/sticky positioning with fixed + inline styles derived
from a ResizeObserver on the center column element. The panel now sits
at exactly the column's left/top/width and fills to the bottom of the
viewport, unaffected by the column's pb-overscroll padding.
2026-04-03 17:41:09 -05:00
Alex Gleason c6b5ab2284 Replace address bar and external link with app icon and name in preview nav bar 2026-04-03 17:36:17 -05:00
Alex Gleason 2231673ee6 Fix nsite preview panel to fill exactly the center column
Add CenterColumnContext to LayoutContext and expose the center column DOM
element from MainLayout via a useState ref callback. NsitePreviewDialog now
portals into that element using absolute inset-0 instead of fixed positioning
with hardcoded sidebar insets, so it always covers exactly the center column
regardless of viewport width.
2026-04-03 17:30:00 -05:00
Alex Gleason f8907475f9 Link client tag name to /:naddr on post detail page 2026-04-03 17:28:29 -05:00
Alex Gleason 4252841125 Replace dialog with fixed center-column overlay panel for nsite preview
Remove the Radix Dialog and browser chrome (back/forward/refresh/fullscreen).
The preview now renders as a portal-based fixed panel that overlays exactly
the center column using responsive left/right insets matching the sidebar
widths (sidebar:left-[300px], xl:right-[300px]). A slim nav bar at the top
shows the nsite:// URL, an external-link button, and a close button.
2026-04-03 17:25:55 -05:00
Alex Gleason ee8220c1f0 Use nsite://<name><path> in preview address bar
Separate the proxy target (nsite.lol gateway URL) from the display URL.
Pass nsiteName through to the dialog so the address bar shows a clean
nsite:// scheme with no gateway hostname.
2026-04-03 17:11:45 -05:00
Alex Gleason 11e29646a7 Show nsite:// URL in preview address bar instead of gateway URL 2026-04-03 17:09:03 -05:00
Alex Gleason a9bab7f8e8 Remove default dialog close button from nsite preview 2026-04-03 17:03:09 -05:00
Alex Gleason 0b69ab51f4 Fix Content-Type header matching in nsite preview proxy
The iframe-fetch-client does an exact equality check for "text/html",
but real servers return "text/html; charset=UTF-8". Also, the browser
fetch() API lowercases all header names while main.js checks Title-Case
keys. Fix both: re-key headers to Title-Case and strip charset params
from Content-Type values before sending them to the iframe.
2026-04-03 17:01:59 -05:00
Alex Gleason 2a32e79b13 feat: change AppConfig.client to naddr1 format, decode relay hint per NIP-89
AppConfig.client now expects a NIP-19 naddr1 string pointing to the app's
kind 31990 handler event instead of a raw 'a' tag value. useNostrPublish
decodes the naddr at publish time to extract the 31990:<pubkey>:<d-tag>
addr and any embedded relay hint, producing a fully NIP-89-compliant
client tag: ["client", <name>, <addr>, <relay-hint>].
2026-04-03 16:57:57 -05:00
Alex Gleason 39fc7549ac Add Run button and nsite preview dialog to app handler cards
When a kind 31990 app event includes an 'a' tag pointing to a kind 35128
nsite, display a 'Run' button that opens an in-app preview dialog. The
dialog embeds the nsite in a sandboxed iframe via the Shakespeare
iframe-fetch-client protocol (local-shakespeare.dev), proxying fetch
requests from the iframe to the live nsite URL so the SPA renders
without needing CORS headers on the origin server.
2026-04-03 16:53:25 -05:00
Alex Gleason 414f42e339 Add Blobbi (kind 31124) to the Ditto homepage feed 2026-04-03 16:50:17 -05:00
Alex Gleason 8e3f778f5b Improve Zapstore and app handler card display
- Rename Zapstore kind labels to include 'Zapstore' prefix across all
  label registries (NoteCard, PostDetailPage, CommentContext,
  ExternalContentHeader, NotificationsPage, extraKinds)
- Wrap Zapstore (32267, 30063, 3063) compact and detail content in
  rounded bordered cards with hover effects; remove redundant mt-2/mt-3
  margins from component roots
- Replace useLinkPreview thumbnail with metadata banner/picture in kind
  31990 app handler cards (compact and full views)
- Add pt-4 to Zapstore detail card wrappers in PostDetailPage
- Fix sticky tab bar (SubHeaderBar z-10) being painted over by card
  content: remove z-10 from AppHandlerContent inner div and add z-0 to
  the main content column in MainLayout
2026-04-03 16:20:40 -05:00
Alex Gleason bc83d08961 Upgrade Nostrify 2026-04-03 13:56:48 -05:00
Alex Gleason 7d83273410 Simplify sidebar media query to a single useQuery with inline fallback logic 2026-04-03 00:53:45 -05:00
Alex Gleason fabcb4170d Fill profile media sidebar with kind 1 fallback when kind 20 results are sparse
When fewer than 9 media-native events (kind 20, 21, 22, etc.) are found for a
profile, perform a secondary query for kind 1 events with search:media:true and
append them to fill the remaining slots. Kind 20 events are always displayed first.
2026-04-03 00:36:44 -05:00
Alex Gleason 8b824f8cc9 release: v2.4.1 2026-04-02 23:12:45 -05:00
Alex Gleason 3e429fe0b0 Add rendering for Zapstore release (kind 30063) and asset (kind 3063) events
- New ZapstoreReleaseContent component: shows app icon/name fetched from the
  linked kind 32267, version badge, channel badge, release notes, and a
  downloads section that fetches and renders each linked kind 3063 asset
- New ZapstoreAssetContent component: shows MIME-type icon, platform/arch
  badges, file size, SHA-256 hash, commit hash, supported NIPs, and APK
  certificate hashes
- Register both kinds in NoteCard, PostDetailPage, extraKinds, CommentContext,
  ExternalContentHeader, and NotificationsPage label/icon maps
- Route kind 3063 to the Zapstore relay in NostrProvider and useEvent
- Kind 3063 is excluded from feeds (display-only on direct navigation)
2026-04-02 23:09:01 -05:00
Alex Gleason a261934ab0 ci: publish zsp to relay.ditto.pub and use blossom.ditto.pub; remove --publish-server-list from nsite 2026-04-02 22:48:46 -05:00
Alex Gleason 822ff13ac3 Merge branch 'update-first-egg-tour' into 'main'
Allow first-hatch tour for migrated accounts with blobbi_onboarding_done=true

See merge request soapbox-pub/ditto!156
2026-04-03 03:42:13 +00:00
filemon afa475ecef Allow first-hatch tour for migrated accounts with blobbi_onboarding_done=true
Older accounts had onboarding_done migrated to blobbi_onboarding_done=true
before the first-hatch tour existed. When the user has exactly 1 egg and
no baby/adult companions, skip the profileOnboardingDone gate so those
accounts can still enter the tour. The localStorage isCompleted check
still prevents re-triggering for users who already finished it.

This is a temporary migration safeguard. The long-term fix is a dedicated
blobbi_first_hatch_tour_done tag.
2026-04-03 00:34:51 -03:00
Alex Gleason 853b5ead9c release: v2.4.0 2026-04-02 21:47:33 -05:00
Alex Gleason a5746ee915 Merge branch 'update-hatch-action' into 'main'
Add first-hatch tour orchestration layer (state machine + activation)

See merge request soapbox-pub/ditto!153
2026-04-03 02:43:05 +00:00
filemon fa3376ac4f Remove legacy blobbi re-export wrappers and unused duplicate hooks
No imports remained pointing at the @/lib/blobbi* or @/hooks/use{ProjectedBlobbiState,BlobbisCollection,BlobbiMigration} paths.
Delete the transitional re-exports and the dead hook copies so only
src/blobbi/core/lib/ and src/blobbi/core/hooks/ remain as the single
source of truth.
2026-04-02 23:25:52 -03:00
filemon 6f0c10fe9b Address review feedback: deduplicate blobbi.ts, remove dead props and state
- Convert src/lib/blobbi*.ts files to thin re-exports from canonical
  src/blobbi/core/lib/ sources, eliminating duplicated logic
- Remove unused emoji, title, description props from TasksPanelProps
  and their call site in BlobbiMissionsModal
- Remove dead direction state from MissionSurfaceCard (was always 'right')
- Remove unused onContinue prop from FirstHatchTourCard and call site
2026-04-02 23:10:10 -03:00
Chad Curtis 2f1bf0bca5 Fix notification dot reappearing after marking as read
Remove the invalidateQueries call in markAsRead that raced with the
setQueriesData(false) update. The invalidation triggered an immediate
refetch whose queryFn closure still held the old notificationsCursor
(from a render before the settings cache update propagated). That stale
refetch re-queried the relay with the old since value, found the same
unread events, returned true, and overwrote the false just set --
causing the dot to reappear.

The setQueriesData(false) call provides the immediate UI update. The
60-second poll and real-time subscription naturally re-evaluate once
the cursor has fully propagated.
2026-04-02 20:35:09 -05:00
filemon 9be98d9a8d Merge branch 'main' into update-hatch-action 2026-04-02 20:47:21 -03:00
filemon c4dd8e7c3d Set blobbi_onboarding_done at tour completion, not at egg adoption
Adopting a first Blobbi egg should not mark onboarding as complete —
the user still needs to go through the first-hatch tutorial. Removed
the premature blobbi_onboarding_done:'true' write from adoptPreview()
in useBlobbiOnboarding.

The flag is now set to 'true' only when the first-hatch tour reaches
its final step (egg_hatching), right after the hatch mutation succeeds.
This is the correct semantic: onboarding means the full tutorial is
done, not just that the user created a profile or adopted an egg.
2026-04-02 20:40:17 -03:00
Chad Curtis 42832b72e3 Revert dialog fly-up on mobile keyboard open
The keyboard-aware repositioning of dialogs was too aggressive and broken.
Removes the CSS rule, dialog-keyboard-aware class, and global keyboard
detector mount. The useKeyboardVisible hook is preserved for ArticleEditor.
2026-04-02 18:20:56 -05:00
filemon e77436d02a Rename onboarding_done to blobbi_onboarding_done and make profile authoritative
The onboarding completion flag was stored as a generic 'onboarding_done'
tag on the kind 11125 Blobbonaut profile, while the first-hatch tour
relied solely on device-local localStorage. This caused issues with
multi-account usage on the same browser.

Changes:
- New profiles write 'blobbi_onboarding_done' (not 'onboarding_done')
- Parsing reads 'blobbi_onboarding_done' first, falls back to old tag
- Auto-migration: useBlobbonautProfileNormalization detects old tag
  and replaces it with the new one on next profile republish
- MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES includes both tags so the
  merge logic can remove the old one during migration
- Tour activation now accepts profileOnboardingDone flag from the
  Blobbonaut profile as the authoritative completion source;
  localStorage remains a secondary fallback for in-progress UI state
- BlobbiPage passes profile.onboardingDone to the activation hook
2026-04-02 20:13:36 -03:00
filemon 302d7732ef Keep first-hatch card visible with completed state before advancing
When the user's hatch post is detected, the tour card now stays on
the 'show_hatch_card' step for 2 seconds showing a celebratory
completed state (large checkmark, 'Post shared!', 'Continuing in a
moment...') before auto-advancing to 'egg_glowing_waiting_click'.

Previously the effect called goTo() immediately on post detection,
so the checkmark was never visible — the card jumped straight to
the tap-egg phase.

Changes:
- BlobbiPage.tsx: wrap the goTo() in a 2s setTimeout
- FirstHatchTourCard.tsx: redesign completed state with centered
  checkmark, bold success text, and 'continuing' hint; remove the
  manual Continue button (auto-advance handles progression);
  update title/description to reflect the confirmed state
2026-04-02 19:28:05 -03:00
filemon b09b4938d2 Add lightweight collapsible sections to missions modal
Both Current Focus and Daily Bounties sections are now collapsible
via Radix Collapsible, defaulting to open. Section headers stay
visible when collapsed and show summary info at a glance:

- Current Focus: Hatch/Evolve badge + progress count (e.g. 2 / 5)
- Daily Bounties: coin progress + green dot for claimable count

A subtle animated chevron rotates on toggle. The collapsible
animation uses new collapsible-down/up keyframes added to the
Tailwind config (mirrors the existing accordion pattern but uses
--radix-collapsible-content-height).

Settings row stays non-collapsible to keep it simple.
2026-04-02 19:05:51 -03:00
filemon 0a0d6de111 Refactor missions modal to card-grid layout with expandable cards
- Add ExpandableMissionCard: shared component with compact collapsed
  state (icon + title + progress ring) and full-width expanded state
  with details, progress bar, action links, claim buttons
- Rework TasksPanel as a 2-col (3-col on sm+) grid of task cards;
  each card maps its task id to a specific lucide icon (Palette,
  Droplets, MessageSquare, Heart, UserPen, Activity)
- Rework DailyMissionsPanel as the same grid; each card maps its
  action type to an icon (Utensils, Moon, Camera, Mic, etc.)
- Only one card expanded at a time per section
- Add MissionTypeLegend popover in the header (? icon) explaining
  Daily / Hatch / Evolve mission types with color-coded dots
- Pass category prop (hatch | evolve | daily) through to cards for
  per-type accent colors (sky / violet / amber)
- Keep all existing behavior: claim, reroll, stop, CTA buttons
2026-04-02 18:47:29 -03:00
filemon 4e9b893822 Redesign missions modal with lighter quest-board aesthetic
- Remove all Collapsible wrappers; sections are always visible
- Restructure layout: Current Focus (hatch/evolve) on top, Daily
  Bounties below, settings toggle at footer
- Flatten TasksPanel: remove Card/CardHeader chrome, use minimal
  rows with soft rounded backgrounds and inline action links
- Lighten DailyMissionsPanel: compact mission rows, smaller claim
  buttons, muted claimed state, no heavy border cards
- Add empty focus state with Compass icon when no active process
- Sticky header with quest-themed subtitle
- ~100 fewer lines across the three files
2026-04-02 17:52:46 -03:00
filemon c60e87ad65 Merge branch 'main' into update-hatch-action 2026-04-02 16:49:03 -03:00
Alex Gleason 8e07ad515a Merge branch 'improve-baby-tasks' into 'main'
Broaden evolve 'Edit Wall' mission to accept profile metadata edits (kind 0)

See merge request soapbox-pub/ditto!155
2026-04-02 19:28:00 +00:00
filemon b4c4b8eb21 Rename wall-specific identifiers to profile-oriented naming
- KIND_WALL_EDIT → KIND_PROFILE_TABS
- wallEditEvents → profileTabsEvents
- edit_wall task id → edit_profile
- Split completion check into hasTabsEdit / hasMetadataEdit / hasProfileEdit
2026-04-02 15:52:54 -03:00
filemon 23ee6f1196 Broaden evolve 'Edit Wall' mission to accept profile metadata edits (kind 0)
The mission now completes when the user either:
- Edits custom profile tabs (kind 16769, existing behavior)
- Updates profile metadata (kind 0, new)

Both paths require the event's created_at to be after the evolution
start timestamp (stateStartedAt), so pre-existing events won't
auto-complete the task.

Updated UI copy: 'Edit Your Profile' / 'Update your profile info or
customize your profile tabs'.
2026-04-02 15:32:36 -03:00
Alex Gleason 4b97baa428 Merge branch 'exclude-text-from-media-sidebar' into 'main'
Exclude kind 1 and kind 1111 from profile Media sidebar

See merge request soapbox-pub/ditto!152
2026-04-02 16:51:27 +00:00
Alex Gleason c8e844a19a release: v2.3.1 2026-04-02 10:25:17 -05:00
Chad Curtis 205a252cac Fix slug collision check blocking edits to existing articles
Replace isEditMode guard with originalSlug comparison so the collision
check is skipped when republishing an article with the same slug it was
loaded with, but still runs if the user changes the slug to one that
would overwrite a different article.
2026-04-02 07:48:14 -05:00
Chad Curtis ad604eae68 Improve dialog UX on mobile: rounded corners, button spacing, keyboard awareness
- Add rounded-xl to Dialog and AlertDialog (was sm:rounded-lg only)
- Add consistent gap-2 to footer buttons on mobile (was no gap)
- Use w-[calc(100%-2rem)] for mobile side margins
- Push dialogs to top of viewport only when keyboard is visible via
  .keyboard-visible class on <html>, toggled by useKeyboardVisible
- Mount useKeyboardVisible globally in MainLayout so the class is
  always available for CSS-only consumers
2026-04-02 05:10:07 -05:00
Chad Curtis 57064b4f40 Save draft on blur and show cloud sync indicator
- Trigger silent draft save when title or editor loses focus
- Add onBlur prop to MilkdownEditor, wired to both WYSIWYG and source textarea
- Mark saved immediately after local write instead of waiting for relay
- Show persistent cloud icon in status; pulses while relay sync is in flight
2026-04-02 05:10:07 -05:00
Chad Curtis bb7b8da581 Always save drafts locally so they appear immediately in My Articles
Previously, drafts were only saved to localStorage on relay failure.
If the relay accepted the event but hadn't indexed it yet for queries,
the draft would show 'Saved' but not appear under My Articles. Now
we always persist locally first for instant visibility, then sync to
the relay in the background.
2026-04-02 05:10:07 -05:00
Chad Curtis 5683f6ea1e Fix source mode toggle clearing editor content
initialValueRef was only set once on mount, so toggling back from
source mode reinitialized Milkdown with stale content. Keep
initialValueRef and lastExternalValue in sync with the current value
so remounts and the replaceAll guard work correctly.
2026-04-02 05:10:07 -05:00
Chad Curtis 61c606822a Fix crash when editing in markdown source mode
The replaceAll effect tried to access editorViewCtx while in source
mode where the ProseMirror view isn't mounted, causing a 'Context
editorView not found' error. Skip the sync when sourceMode is active
and add a try/catch for the initial render race.
2026-04-02 05:10:07 -05:00
Chad Curtis bc12331cd4 Keep tab bar in article editor but make it non-sticky on mobile write mode
Add ARC_OVERHANG_PX spacer div after the header to prevent arc
overlapping content, matching the pattern used across other pages.
2026-04-02 05:10:07 -05:00
Chad Curtis 2478bf1c66 Improve mobile article editor UX when virtual keyboard is open
- Hide tab bar in write mode on mobile, replace with slim back+title header
- Hide publish FAB when keyboard is visible (was floating over content)
- Collapse metadata (summary, slug, tags) behind a 'Details' toggle on mobile
- Hide header image and stats bar when keyboard is up to maximize writing area
- Add useKeyboardVisible hook using Visual Viewport API
2026-04-02 05:10:07 -05:00
filemon ade9eb4999 Merge branch 'main' into update-hatch-action 2026-04-02 06:17:41 -03:00
Alex Gleason 213bbb21c1 release: v2.3.0 2026-04-02 03:57:37 -05:00
Alex Gleason dd3ae4da4e npm audit fix 2026-04-02 03:52:24 -05:00
Alex Gleason 681d2ab90b Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 03:51:09 -05:00
Alex Gleason 24a645277e Fix custom emoji stretching by adding object-contain to all emoji images
Custom emoji images with non-1:1 aspect ratios were being stretched
into a square. Added object-contain to preserve natural aspect ratio
within the bounding box. Moved text sizing classes to parent containers
for reaction emoji bubbles so unicode emojis still size correctly.
2026-04-02 03:50:50 -05:00
Chad Curtis fa34922cce refactor: harden article editor — encryption, mobile UX, deduplication, source toggle
- Encrypt drafts with NIP-44 via NIP-37 (kind 31234) instead of
  plaintext kind 30024
- Fix slug auto-generation overwriting manual edits
- Guard auto-save state setters against unmount
- Deduplicate save logic, load handlers, tag extraction, and types
  via shared ArticleFields/parseArticleEvent helpers
- Replace derived state (wordCount/readingTime) with useMemo
- Mobile UX: sticky toolbar, touch-friendly header image swap,
  adaptive tooltips (pointer:fine only), FAB bottom clearance,
  responsive editor min-height
- Editor placeholder: hide on focus, handle trailing whitespace
- Tighten editor padding and paragraph spacing
- Add raw markdown source toggle (Eye/EyeOff) in toolbar
- Shrink slug/tag fields, consistent sizing
2026-04-02 03:48:10 -05:00
Chad Curtis 89c71ed073 Merge branch 'feat/article-editor' into 'main'
feat: add in-app article editor with Milkdown WYSIWYG

See merge request soapbox-pub/ditto!150
2026-04-02 08:47:37 +00:00
filemon 0f02563d3a Add mission card dismiss/toggle and fix More menu for hidden bar items
- Mission surface card now has an X dismiss button (onHide prop)
  that hides it via localStorage ('blobbi:mission-card-visible')
- BlobbiMissionsModal gains a 'Show mission card on main page'
  toggle at the bottom, reflecting the same preference
- Both controls share the same state: hiding from the card or
  toggling from the modal are equivalent
- More dropdown now conditionally shows items: if an action
  (Blobbies, Items, Missions, Photo, Companion) is visible in
  the bottom bar, it is skipped in More to avoid duplication;
  if removed from the bar, it appears in More so no action
  becomes inaccessible
2026-04-02 05:26:21 -03:00
Alex Gleason f49909dedf Close mobile drawer when clicking footer links (Changelog, Privacy) 2026-04-02 03:23:13 -05:00
Alex Gleason ab43225f0c Remove Nostr protocol jargon from changelog and add rule to release skill 2026-04-02 03:14:01 -05:00
Alex Gleason 2bb1b07dd6 release: v2.2.11 2026-04-02 03:05:10 -05:00
Alex Gleason f93c759bf2 Fix VersionCheck crash: move VersionCheck and Toaster inside BrowserRouter
VersionCheck and Toaster were rendering outside the BrowserRouter in App.tsx,
so the <Link> in the version update toast had no Router context. Moved both
into AppRouter.tsx inside BrowserRouter. Also truncate changelog excerpt
to 60 chars with ellipsis for cleaner toast display.
2026-04-02 03:01:32 -05:00
filemon 38630be23d Add customizable bottom bar, mission surface card, and action bar editor
Bottom bar simplification:
- Default to 3 visible items: Blobbies (left), Main Action (center),
  More (right). Items/Missions/Photo moved into More dropdown.
- All existing actions (Set as Companion, Evolve/Hatch, View Blobbi,
  dev tools) remain in More with existing guards.
- 'Edit action bar' entry in More opens the new editor.

Editable action bar preferences:
- New preference model (action-bar-preferences.ts) with localStorage
  persistence, validation, and migration support.
- Candidates: Blobbies, Missions, Items, Take Photo, Set as Companion.
- Up to 3 custom visible slots (Main Action + More are fixed).
- Each slot can be shown/hidden, reordered, or highlighted.
- ActionBarEditor modal for editing with reset-to-default option.

Mission surface card:
- MissionSurfaceCard renders below the Blobbi visual, above the bar.
- Shows one mission at a time with badge (Hatch/Evolve/Daily),
  progress bar, description, and coin reward for dailies.
- Priority: hatch/evolve tasks first, then unclaimed daily missions.
- Auto-rotates every 5s when multiple cards; manual tap cycles.
- 'View all missions' link opens existing missions modal.
- Hidden during first-hatch tour (preserves tour behavior).
2026-04-02 04:55:00 -03:00
Alex Gleason ef4ac2e3f4 release: v2.2.10 2026-04-02 02:48:34 -05:00
Alex Gleason 32b36b2f54 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 02:40:44 -05:00
Alex Gleason dee5c82fa8 Add 'deployed an nsite' action header to nsite detail page 2026-04-02 02:39:40 -05:00
Alex Gleason 22d66a28d7 Add Open App button to compact view, fix stopPropagation on all buttons 2026-04-02 02:37:13 -05:00
Alex Gleason 984a56c412 Add 'published an app' action header to app detail page 2026-04-02 02:34:14 -05:00
Alex Gleason 207e7a13a2 Move Shakespeare badge above Open App button, restore h-6 size 2026-04-02 02:25:44 -05:00
Alex Gleason cc7feebbb0 Replace small external link icon with prominent Visit Website button 2026-04-02 02:22:25 -05:00
filemon 9b8cff63da Polish first-hatch tour: center click hint over egg, keep crack visible during opening
- Move click hint emoji to centered overlay with larger size (text-4xl)
  so users clearly see it over the egg, not tucked in a corner
- Keep crack overlay visible during egg_opening state by including
  'opening' in tourShowCrack and mapping it to crack level 3
- The crack SVG lives inside the shell div, so it inherits the
  opening animation (scale/blur/fade) and disappears with the shell
- Suppress shake animation during opening so it doesn't conflict
  with the smooth open sequence
2026-04-02 04:12:00 -03:00
Alex Gleason 925619b13c Add background color to app icon for transparent images 2026-04-02 02:09:58 -05:00
Alex Gleason ceb7bbc718 Fix app icon z-index so it renders above the og:image hero 2026-04-02 02:06:41 -05:00
Alex Gleason 53a607fa53 Overlay app icon over og:image like a profile avatar 2026-04-02 01:57:56 -05:00
filemon e13473809d Fix egg crack progression, companion auto-assignment, and add dev tour controls
- Replace full-width crack with stage-specific SVG paths that grow
  outward from the egg center: level 0 shows a small central cluster,
  level 1 expands left/right with branches, level 2 reaches further
  with more fracture detail, level 3 spans near-full width
- Remove current_companion assignment during egg adoption so eggs
  are never auto-set as the floating companion
- Add first-hatch tour dev controls to BlobbiDevEditor: skip post
  requirement, restart tour, and reset-to-egg+tour buttons
2026-04-02 03:52:54 -03:00
Alex Gleason e9eeebc4b1 Rename 'App Handler' to 'App' in UI labels 2026-04-02 01:48:08 -05:00
Alex Gleason b42d241882 Fix Shakespeare clone URL to use NostrURI class 2026-04-02 01:43:33 -05:00
Alex Gleason 68da609a9e Hide app handler screenshot hero when no og:image, reduce image height 2026-04-02 01:38:07 -05:00
Chad Curtis 1afa78ae39 Merge branch 'fix/disappearing-post-box' into 'main'
Fix disappearing compose box after posting

See merge request soapbox-pub/ditto!141
2026-04-02 06:29:56 +00:00
filemon 00a9ad20de Merge branch 'main' into update-hatch-action 2026-04-02 03:13:16 -03:00
Alex Gleason e0ff462f12 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 01:06:25 -05:00
Alex Gleason f4e38123e4 Add display for kind 31990 NIP-89 app handler events 2026-04-02 01:00:32 -05:00
Alex Gleason eb1c873b9a Show 'What's new' with changelog excerpt in version update toast 2026-04-01 23:50:22 -05:00
Alex Gleason 22f13c1505 Replace __DITTO_CONFIG__ global with import.meta.env.DITTO_CONFIG and remove ThemeSchemaCompat
Move build-time ditto.json injection from a Vite define global to
import.meta.env.DITTO_CONFIG (a JSON string parsed and validated at
runtime via DittoConfigSchema). Remove the global type declaration
from vite-env.d.ts.

Drop ThemeSchemaCompat and its legacy "black"/"pink" migration code
from AppProvider and NostrSync — invalid theme values now simply fail
Zod validation.

Fix a latent bug where a partial feedSettings from ditto.json would
replace the full hardcoded defaults; defaultConfig now deep-merges
feedSettings.
2026-04-01 23:16:33 -05:00
Alex Gleason cbfc8f149f Redesign changelog page: hero latest release, collapsible entries, flat item list with category icons, tooltips, typography fixes, and search integration 2026-04-01 22:33:18 -05:00
Alex Gleason 2e41859747 Show update toast with changelog link when app version changes 2026-04-01 21:26:23 -05:00
Alex Gleason 3b176a3e8f release: v2.2.9 2026-04-01 21:12:42 -05:00
Alex Gleason a1e1e1d57f Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-01 20:55:01 -05:00
Alex Gleason eb973cc20b Redesign shop/items dialog: tile layout, instant buy, remove accessories and categories 2026-04-01 20:53:56 -05:00
Alex Gleason f66ab92e51 Unify Shop/Inventory, remove Info modal and visibility, consolidate dev tools
- Combine Shop and Inventory into a single tabbed dialog (Shop tab
  with category sub-tabs, Inventory tab with item list and use flow)
- Remove BlobbiInfoModal entirely
- Move dev tools (Dev Hatch/Evolve, State Editor, Emotion Tester) into
  the bottom bar 'More' dropdown with yellow text, remove floating
  dev tools panel
- Remove 'visibleToOthers' / 'visible_to_others' concept from the
  entire codebase: types, interfaces, tag schemas, event construction,
  parsing, UI badges, dev editor, and documentation
2026-04-01 20:14:34 -05:00
Alex Gleason 4d573ffaa8 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-01 20:13:37 -05:00
Alex Gleason 081189886a Hide emoji packs without any valid emoji tags from feeds 2026-04-01 20:07:43 -05:00
Alex Gleason 1efc8de880 Add description field to emoji pack create/edit dialog
Parse and publish the 'about' tag for kind 30030 emoji sets.
EmojiPackContent already displays it.
2026-04-01 20:02:01 -05:00
Alex Gleason 8bf9db382e Fix emoji pack drag reorder, expand truncated grid, resolve shortcode collisions
- Replace chevron up/down buttons with @dnd-kit SortableList/SortableItem
  for proper drag-and-drop reorder in EmojiPackDialog
- Remove 'N emojis' badge from emoji pack display
- Make '+N' overflow indicator clickable to expand full emoji grid
- Stop click propagation on expand button to prevent feed navigation
- Resolve shortcode collisions across emoji packs by prefixing with pack
  d-tag identifier when two packs define the same shortcode with different URLs
2026-04-01 19:57:29 -05:00
Alex Gleason 103b9c71bf Move right-side floating controls into bottom bar 3-dots menu
Replace the cluster of floating action buttons on the right side of the
Blobbi dashboard with a single 'More' dropdown in the bottom control bar.
The menu contains: Inventory, Take a Photo, Set as Companion, Evolve/Hatch,
Blobbi Info, and View Blobbi. The floating controls now only show the back
button (left) and dev tools (right, localhost only).
2026-04-01 19:29:15 -05:00
Alex Gleason e27057788b Replace drag reorder with move buttons, remove colons and parenthetical count
- Swap native HTML drag-and-drop reorder for chevron up/down buttons,
  fixing scroll conflicts inside ScrollArea and drop zone interference
- Remove the colon decorations around shortcode inputs
- Show plain 'N emojis' count without parenthetical upload note
2026-04-01 19:20:30 -05:00
Alex Gleason 4983b3c1ef Use single upload icon in emoji drop zone 2026-04-01 19:14:35 -05:00
Alex Gleason 197ab6c28a Simplify BlobbiStateCard to show just the character and name 2026-04-01 19:14:01 -05:00
Alex Gleason fd0d47160d Defer all Blossom uploads and event signing until submit
Files are held as local blob previews while editing. Nothing is
uploaded or signed until the user clicks the publish button, at which
point all pending files are uploaded in parallel and then the event
is published in a single batch.
2026-04-01 19:10:09 -05:00
Alex Gleason 4697d269bc Put Title and ID side-by-side, auto-slug ID from Title, remove NIP jargon 2026-04-01 19:02:39 -05:00
Alex Gleason 73bf03cfab Add Blobbi view link and register kind 31124 in feed/detail views
Add a 3-dots menu to the Blobbi dashboard with a 'View Blobbi' link that
navigates to the naddr detail page. Register kind 31124 (Blobbi Pet State)
across all UI registration points so Blobbi events render properly in
feeds, detail pages, comment contexts, and embedded previews.
2026-04-01 19:01:47 -05:00
Alex Gleason c3d4d5f06e Add emoji pack create/edit dialog with drag-and-drop upload
Introduce EmojiPackDialog for publishing and editing kind 30030 custom
emoji sets (NIP-30). The dialog supports multi-file and folder
drag-and-drop, automatically extracting shortcodes from filenames. The
d-tag identifier is locked after initial publish. Existing packs show
an Edit button on the feed card for the author. The /emojis page FAB
now opens the create dialog.
2026-04-01 18:53:51 -05:00
Alex Gleason 4c201cc2d3 release: v2.2.8 2026-04-01 18:39:05 -05:00
Mary Kate Fain d28364531b Exclude kind 1 and kind 1111 from profile Media sidebar
Give ProfileRightSidebar its own query using a kind whitelist
(20, 21, 22, 34236, 36787, 34139, 30054, 30055) instead of
relying on the parent's search-based media query. This ensures
the desktop sidebar only shows media-native events, excluding
kind 1 text notes and kind 1111 comments at the query level.

The Media tab continues to use the broader useProfileMedia hook
with search: 'media:true' and is unaffected.
2026-04-01 18:34:32 -05:00
Alex Gleason c30a6a7bcd Merge branch 'remove-egg-task' into 'main'
Remove egg maintain_stats task and hide companion button for eggs

See merge request soapbox-pub/ditto!151
2026-04-01 23:15:11 +00:00
Alex Gleason c4354774ad Add Color Moments and Geocaching to community NIP specs, sort table by kind number 2026-04-01 18:05:52 -05:00
Alex Gleason 8a44f77fb1 Add community NIP specs to NIP.md (Letters, Weather, Blobbi)
Add a Community Kinds section to the overview table and a Community
NIP Specifications section with summaries and links to the full specs
maintained by Chad Curtis, Sam Thomson, and Danifra.
2026-04-01 17:56:50 -05:00
filemon f3eb4adba5 Fix first-hatch tour: full flow wiring, progressive cracks, hatch reveal
Tour flow fixes:
- Rename show_hatch_modal -> show_hatch_card (no longer a modal)
- Remove unused egg_ready_hint, await_create_post, tour_rewards_reveal,
  tour_set_companion_hint steps; simplify to 9 focused steps
- Auto-advance from idle -> show_hatch_card immediately
- Card stays visible through show_hatch_card + glowing + crack stages
- Post detection advances to egg_glowing_waiting_click automatically
- Crack stages are manual (1 click per stage, 3 clicks total)
- egg_opening and egg_hatching are auto-advance with timers
- egg_hatching triggers the actual useBlobbiHatch mutation + completes tour

Egg visual improvements:
- Initial crack (hairline) shown from show_hatch_card step onward
- Progressive crack SVG: level 0 (hairline) -> 1 (branches) -> 2 (more)
  -> 3 (full fracture pattern with large splits)
- Auto-wiggle every 2.5s during show_hatch_card and glowing_waiting_click
- Shell opening animation (scale + brightness + blur -> fade out)
- Bright white glow during opening/hatching for light burst effect
- onTourEggClick callback threaded through BlobbiStageVisual -> BlobbiEggVisual -> EggGraphic

Fake pointer hint:
- After 10s on egg_glowing_waiting_click, show bouncing pointer emoji
- Repeats every 5s if user doesn't click
- Disappears immediately on egg click

Layout during tour:
- Inline card rendered ABOVE stats section, directly below egg
- Stats section hidden entirely during first-hatch tour
- Dashboard controls + bottom bar + inline activities still hidden

FirstHatchTourCard improvements:
- Accepts currentStep prop for adaptive messaging
- Post step: shows mission card with required phrase + Create Post
- Click steps: shows 'Tap {Name} to hatch!' with tap icon hint
- Post completed state: shows checkmark + Continue button
2026-04-01 19:34:54 -03:00
Chad Curtis 9ebd9a304f Fix notification dot not clearing after marking as read
setQueryData requires an exact query key match, but the unread
notifications query uses a 4-element key (prefix, pubkey, kindsKey,
authorsKey). The markAsRead callback was calling setQueryData with only
2 elements, silently missing the cache entry. Switch to setQueriesData
which uses prefix matching, correctly hitting the real cache entry.
2026-04-01 16:24:23 -05:00
filemon 0487586af9 Replace first-hatch modal with inline card, hide dashboard controls during tour
UX change: the first-hatch experience is now a focused onboarding screen
instead of a modal interruption.

Layout during first-hatch tour:
- Egg visual (top, with tour animations)
- Stats (if any visible)
- FirstHatchTourCard inline below stats (mission + post CTA)
- No floating hero controls (camera, info, companion, incubation)
- No bottom action bar (blobbies, missions, actions, shop, inventory)
- No inline activity area (music, sing)

The page feels like a dedicated guided flow rather than a dashboard
with overlays. Normal dashboard controls return after tour completion.

Architecture: clean branch in BlobbiDashboard render --
isFirstHatchTourActive gates visibility of controls/bar/activities.
The inline card lives at the same level as other content sections.
The first egg is treated as already in the hatch onboarding path
without requiring the normal 'start incubation' entry point.
2026-04-01 18:11:48 -03:00
filemon 2c737ca322 Wire first-hatch tour into BlobbiPage with post phrase update and egg visuals
Tour integration:
- Call useFirstHatchTour + useFirstHatchTourActivation in BlobbiDashboard
- Auto-advance: idle -> egg_ready_hint (immediate) -> show_hatch_modal (3s)
- Poll for valid hatch post during show_hatch_modal/await_create_post
- On post detected, advance to egg_glowing_waiting_click
- Missions button opens tour modal instead of normal missions during tour
- Hide incubation button during tour (tour handles the flow)
- Badge shows tour-specific remaining count (1 post mission)

Post phrase update:
- New format: 'Posting to hatch {Name} #blobbi' (was: 'Hello Nostr! Posting to hatch #name #blobbi #ditto #nostr')
- Update isValidHatchPost to check for phrase anywhere in content
- Add buildHatchPhrase helper
- Simplify BlobbiPostModal validation and tag extraction

Egg visual layer:
- Add EggTourVisualState type ('idle' | 'ready_hint' | 'glowing_waiting_click')
- Thread tourVisualState prop: BlobbiStageVisual -> BlobbiEggVisual -> EggGraphic
- ready_hint: auto-wiggle every 2.5s using existing egg-tap-wiggle animation
- glowing_waiting_click: enlarged pulsing glow via new egg-tour-glow CSS animation
- Add reduced-motion support for new animation

FirstHatchTourModal component:
- Shows during show_hatch_modal/await_create_post steps
- Single mission: create a hatch post with the required phrase
- Continue button appears when post is detected
2026-04-01 17:42:12 -03:00
filemon c9823055fd Add first-hatch tour orchestration layer (state machine + activation)
New src/blobbi/tour/ module with:
- tour-types.ts: Generic TourStepDef/TourState/TourActions types, plus
  FirstHatchTourStepId enum and ordered FIRST_HATCH_TOUR_STEPS array
- useFirstHatchTour: Step-based state machine with localStorage
  persistence, advance/goTo/complete/reset actions, and derived
  booleans (isStep, isAnyStep, currentStepDef) for UI consumption
- useFirstHatchTourActivation: Precondition guard that auto-starts
  the tour when: exactly 1 Blobbi, egg stage, no baby/adult, not
  yet completed
- Barrel index.ts exporting all types, hooks, and constants

No visual/UI changes yet -- this is the orchestration foundation
that rendering layers will plug into.
2026-04-01 16:33:57 -03:00
filemon d2cd5f22bf Merge branch 'main' into update-hatch-action 2026-04-01 15:54:34 -03:00
filemon b223a9c1f2 Remove egg maintain_stats task and hide companion button for eggs
Egg stats no longer decay, so the 'Keep Egg Healthy' dynamic task is
unnecessary and misleading. Remove it along with HATCH_STAT_THRESHOLD.
The baby/adult 'Peak Condition' evolve task is unchanged.

Also hide the 'Set as Companion' button entirely for eggs instead of
rendering it as disabled.
2026-04-01 15:03:29 -03:00
Derek Ross 2d1a3ff6f5 chore: update package-lock.json after rebase 2026-04-01 14:03:17 -04:00
Derek Ross 90bd10d87a fix: remove unused imports and variables in ArticleEditor 2026-04-01 14:01:46 -04:00
Derek Ross 280bcbd5ab fix: prevent Save Draft button wrapping to second line on mobile 2026-04-01 14:01:46 -04:00
Derek Ross 65ecfca05e fix: show bottom navigation bar on article editor page 2026-04-01 14:01:46 -04:00
Derek Ross 91f5afc110 fix: default logged-out users to global tab on kind-specific feed pages
Kind-specific pages (articles, photos, videos, etc.) clamped the feed tab
to 'follows' for all users, but the follows query requires a logged-in
user. Logged-out users saw infinite skeleton loading with no way to switch
tabs. Now defaults to 'global' when no user is present.
2026-04-01 14:01:46 -04:00
Derek Ross 1c980fb039 refactor: simplify article editor to New/My Articles tabs with inline metadata
Remove Details tab and Save header icon. Metadata (image, summary, slug,
tags) now sits inline between title and editor body like Medium. Save Draft
button moved to bottom of compose form. Header tabs renamed to New and
My Articles.
2026-04-01 14:01:46 -04:00
Derek Ross e93c665123 feat: add in-app article editor with Milkdown WYSIWYG
Replace external Inkwell link with a built-in article creation experience.
Uses Milkdown editor with tabbed UI (Write/Details/Drafts) matching the
letters compose pattern, FAB publish button, relay+local draft support,
and kind 30023/30024 publishing.
2026-04-01 14:01:46 -04:00
Alex Gleason 6be49ec14a Merge branch 'hide-all-status' into 'main'
Centralize stat visibility threshold (<70) across all Blobbi stages

See merge request soapbox-pub/ditto!149
2026-04-01 17:58:18 +00:00
Alex Gleason 793b408e3f Fix encrypted letter envelope to show back (mailing) side first
The envelope previously showed flap-like V-fold lines on both sides,
which doesn't match how real envelopes work. Now the default view
shows the back/mailing side with sender name top-left and recipient
name centered, then flipping reveals the front with the triangular
flap and wax seal, and clicking opens the flap to reveal the Nushu
ciphertext.
2026-04-01 12:56:23 -05:00
filemon 213e8abf28 Merge branch 'main' into hide-all-status 2026-04-01 14:52:39 -03:00
filemon bac2f3a5c7 Centralize stat visibility threshold (<70) across all Blobbi stages
Move the hardcoded < 70 stat visibility checks from BlobbiPage.tsx into
the shared getVisibleStatsWithValues() utility in blobbi-decay.ts. This
ensures egg, baby, and adult stages all use the same STAT_VISIBILITY_THRESHOLD
constant, and any future UI consuming visibleStats gets the filtering for free.
2026-04-01 14:47:07 -03:00
Alex Gleason 1e38d9d2a2 Add clientName to Zod schema and document AppConfig update process 2026-04-01 12:25:45 -05:00
Alex Gleason 419c1ceb48 Add clientName config option for the NIP-89 client tag 2026-04-01 12:06:25 -05:00
Alex Gleason f6bde5871a Remove dead EGG_DECAY constants since egg stage no longer decays 2026-04-01 11:54:29 -05:00
Alex Gleason 9c56a7f987 Revert "Fix unused EGG_DECAY variable eslint errors by prefixing with underscore"
This reverts commit 2e8efab2aa.
2026-04-01 11:53:03 -05:00
Alex Gleason 2e8efab2aa Fix unused EGG_DECAY variable eslint errors by prefixing with underscore 2026-04-01 11:52:08 -05:00
Alex Gleason 0f45ce743f Consolidate 3 dev buttons into a single dropdown menu 2026-04-01 11:36:30 -05:00
Alex Gleason 7794cd5dbd Disable stat decay for egg stage and reset all stats to 100 on hatch 2026-04-01 11:31:58 -05:00
Alex Gleason c0cb6454ac Only show Health, Hygiene, and Happy gauges when value is below 70% 2026-04-01 11:27:33 -05:00
Alex Gleason 2f45a9bbf5 Replace Users icon with Egg icon for Blobbies button and dialog 2026-04-01 11:22:54 -05:00
Alex Gleason 11a61322e8 Remove unused isFetching prop from BlobbiDashboard 2026-04-01 11:17:44 -05:00
Alex Gleason cb1bc1a865 Add tap-to-wiggle interaction on Blobbi eggs
Clicking any egg triggers a playful rock-and-hop animation (0.6s)
that wobbles side to side with a small upward jump, then settles.
Uses CSS animation with onAnimationEnd to auto-reset state.
Respects prefers-reduced-motion and doesn't interrupt cracking.
2026-04-01 11:14:30 -05:00
Alex Gleason 622cb14813 Make bottom bar flow inline below stats, remove stage badge, update loading skeleton 2026-04-01 11:03:35 -05:00
Alex Gleason 4afea98e77 Align bottom bar max-width with content container after card removal 2026-04-01 10:58:46 -05:00
Alex Gleason 79f3cc85dd Merge branch 'implement-reactions-clean' into 'main'
Implement Blobbi XP Progression, Interaction Features, and Refactors

See merge request soapbox-pub/ditto!147
2026-04-01 15:46:44 +00:00
Alex Gleason 4052f865c9 Address MR review: sanitize instanceId in eye-animation, add blobbi-xp tests
- Sanitize instanceId in eye-animation.ts with the same regex pattern
  used in svg/ids.ts for defense-in-depth consistency
- Add comprehensive unit tests for blobbi-xp.ts covering all pure
  functions: calculateActionXP, calculateInventoryActionXP, applyXPGain,
  getXPGainSummary, formatXPGain, getXPGainMessage, and XP constants
2026-04-01 10:44:23 -05:00
Chad Curtis 5887f790c6 Style expanded thread siblings with distinct connector line
Add threadedLineClassName prop to NoteCard to allow customizing the
connector line color. Revealed hidden siblings use bg-primary/30
to visually distinguish them from the main thread chain. Remove
bottom border from the expand thread button for seamless flow.
2026-04-01 03:46:04 -05:00
Chad Curtis 6fc5d3ed97 Add 'Show X more replies' for branching threads
When a reply has multiple children, only the first child renders
inline in the thread chain. Remaining siblings are hidden behind
a 'Show N more replies' button placed between the parent and
its inline child. Clicking reveals them as threaded items with
the connector line. Removes bottom border from the expand button
so it flows seamlessly in the thread.
2026-04-01 03:42:13 -05:00
Chad Curtis 0eaf30cd8b Fix threaded replies: only show first child inline, hide siblings
The linear threading UI (connector lines) only works for single chains.
When a reply had multiple children, siblings after the first rendered
without any visual connection to their parent, making them look like
top-level replies. Fix by only including the first child in each node's
thread chain — additional siblings are hidden since there is no UI to
display branching threads.
2026-04-01 03:36:26 -05:00
Chad Curtis f1d5e8d4ca Move broadcast button from overflow menu to Event JSON dialog 2026-04-01 02:47:24 -05:00
Chad Curtis 7763aa2e0a Add broadcast event option to overflow menu 2026-04-01 02:45:00 -05:00
Chad Curtis 500f06b538 DRY up drag-and-drop: refactor profile field reorder to use shared SortableList/SortableItem
- Refactor ProfileSettings SortableFieldRow to use SortableItem instead
  of manual useSortable/GripVertical/CSS.Transform boilerplate
- Replace inline DndContext/SortableContext with SortableList wrapper
- Add gripClassName prop to SortableItem for width customization
  (w-6 h-9 for profile fields, default w-8 for badges/sidebar)
- Add space-y-3 to SortableList in profile fields for row padding
- Remove all direct @dnd-kit imports from ProfileSettings
- Remove unused onOpenCreate prop chain from MyBadgesTab
2026-04-01 02:37:27 -05:00
Chad Curtis 85227c2175 Polish My Badges tab: fix scroll areas, overflow menus, clean up chrome
- Fix ScrollArea to use fixed h-[24rem] with content inside (loading,
  empty, list) matching the lief sticker management pattern
- Remove background boxes from all badge rows and scroll containers
- Remove glowing border from pending badge area
- Extract shared BadgeOverflowMenu with View link for all badges,
  Award/Edit/Delete only shown for badges you created
- Replace inline action buttons on created badges with overflow menu
- Add rounded-full hover on pending nav arrows, strokeWidth 4
- Remove redundant New Badge button from Created section (FAB exists)
2026-04-01 02:29:56 -05:00
Chad Curtis a570b318d7 Revamp My Badges tab: draggable badge order, scroll areas, and pending carousel
- Extract reusable SortableList/SortableItem components sharing the same
  @dnd-kit pattern used by the sidebar edit view (DRY)
- Replace ChevronUp/ChevronDown reorder buttons with drag-and-drop on
  accepted badges list
- Wrap accepted and created badge sections in ScrollArea (max-h 420px)
- Redesign pending badges as a carousel showing one badge at a time in
  the notification-style BadgeContent presentation (rotating rays, 3D
  tilt), with left/right arrows to navigate the pending queue
2026-04-01 02:16:14 -05:00
Chad Curtis 99e32d9491 Fix followers/following modal staying open after navigating to a profile 2026-04-01 02:01:28 -05:00
Chad Curtis 74022e8181 Render full reply threads on post detail page
Replace the flat reply list (showing one sub-reply hint per reply) with
a recursive threaded tree on the post detail page. Threads deeper than
3 levels collapse behind a 'Show N more replies' button that expands
the subtree in-place.

Also fix useReplies to fetch iteratively — some clients only tag the
immediate parent in e-tags, not the thread root, so a single query
misses deeper replies. The hook now discovers the full tree by querying
for replies to each new batch of event IDs (up to 5 rounds).

Other pages (profile wall, external content, badges) keep the existing
flat preview via FlatThreadedReplyList.
2026-04-01 01:55:01 -05:00
Alex Gleason d0b5164e6d Deploy nsite as named site 'ditto' and drop publish-relay-list 2026-03-31 21:01:47 -05:00
Alex Gleason defc39c0f3 Replace GitLab Pages deploy with nsite deploy via nsyte
Use nsyte CLI with NIP-46 nbunksec bunker credential to deploy
the web app to nsite on every default branch push. Downloads the
nsyte binary, builds the Vite app, and uploads to configured
Blossom servers and Nostr relays with SPA fallback routing.
2026-03-31 20:29:29 -05:00
filemon a9844b3a4f fix(lint): remove unused gaze state variable in useBlobbiCompanionGaze 2026-03-31 22:12:25 -03:00
Alex Gleason 77b8498850 npm audix fix 2026-03-31 20:00:22 -05:00
filemon 4c34aba66d fix(blobbi-page): restore page-specific sleep toggle and sleeping recipe overlay
Part A — Restore BlobbiPage handleRest:
- Revert handleRest to the original blobbi-specific implementation that
  operates on the page-selected companion (via selectedD/companionsByD),
  not profile.currentCompanion. This ensures the BlobbiActionsModal
  sleep/wake button targets the correct Blobbi.
- The companion floating button continues to use useBlobbiSleepToggle
  independently (targets profile.currentCompanion). These are separate
  and correct targets for their respective contexts.
- Restore imports: KIND_BLOBBI_STATE, updateBlobbiTags, applyBlobbiDecay,
  trackDailyMissionProgress, getStreakTagUpdates.

Part B — Apply sleeping recipe overlay on BlobbiPage:
- Keep useStatusReaction enabled during sleep (was disabled with
  enabled: !isSleeping). Body effects (dirty, stink) and extras (food
  icon) still resolve while sleeping.
- Apply buildSleepingRecipe(rawStatusRecipe) when isSleeping is true,
  same pattern as BlobbiCompanionLayer. This overlays closed eyes,
  sleeping mouth, and Zzz while preserving compatible status effects.
- Suppress actionOverride during sleep (no happy/excited flash).
- Remove opacity-80 dim on sleeping Blobbi container (sleeping visuals
  are now expressed through the recipe, not opacity).

Part D — Sleepy vs sleeping verified:
- sleepyBlink (drowsy cycling animation) and sleepingClosed (permanent
  eye closure) are separate EyeRecipe fields that never overlap.
- buildSleepingRecipe never sets sleepyBlink; status reactions never
  set sleepingClosed. The guard in applyVisualRecipe (line 894) skips
  sleepingClosed when sleepyBlink is present, but this case never
  occurs in practice.
2026-03-31 21:58:23 -03:00
filemon 2bf4ed2af8 Merge branch 'main' into implement-reactions-clean 2026-03-31 21:45:48 -03:00
filemon 5afeac3c14 refactor(sleeping): recipe overlay instead of SVG swap, unify sleep paths
Part A — Remove sleeping SVG asset swap:
- Both renderers now always use the base (awake) SVG and run the full
  visual pipeline (eye animation, recipe, body effects). The isSleeping
  gate and sleeping SVG variant selection are removed.
- Sleeping visuals are achieved through the recipe system: permanently
  closed eyes via clip-path closure, closed-eye line overlays, sleeping
  mouth, and animated Zzz — all injected by applySleepingClosedEyes()
  in applyVisualRecipe() when recipe.eyes.sleepingClosed is set.
- Delete sleeping-animation.ts (dead code from previous approach).
- Remove opacity-70 dim on sleeping containers.

Part B — Sleeping as recipe overlay with selective coexistence:
- Add sleepingClosed field to EyeRecipe for permanently closed eyes
- Add buildSleepingRecipe() that takes a status recipe and produces a
  sleeping variant: overrides eyes/mouth/eyebrows, preserves body
  effects (dirty smudges, stink clouds) and food icon, strips drool/
  tears/watery eyes/dizzy spirals
- BlobbiCompanionLayer keeps useStatusReaction enabled during sleep
  (was previously disabled), applies buildSleepingRecipe overlay on
  top so body effects still render while the face shows sleeping state
- Action overrides are suppressed during sleep

Part C — Unify sleep action paths:
- BlobbiPage.handleRest now delegates to useBlobbiSleepToggle (same
  hook used by the companion radial menu), ensuring identical event
  publish, cache update, and companion state propagation regardless
  of which UI triggers sleep
- Fix useBlobbiSleepToggle cache update: use getQueriesData with
  partial key matching to find all blobbi-collection cache entries
  for the user, then setQueryData on each with exact keys. This
  ensures the optimistic update reaches the correct cache entry
  that useBlobbiCompanionData reads from
- Remove unused imports from BlobbiPage (KIND_BLOBBI_STATE,
  updateBlobbiTags, applyBlobbiDecay, trackDailyMissionProgress,
  getStreakTagUpdates) that were only used by the old handleRest
2026-03-31 21:32:18 -03:00
filemon 39e3c0b30f fix(companion): implement sleeping visuals, standalone sleep action, fix state types
Part A — Sleeping visuals:
- Add sleeping-animation.ts with CSS keyframe animations for the
  pre-baked sleeping SVG assets: Zzz text floats with staggered delays,
  body gently breathes via scaleY pulse
- Both BlobbiBabySvgRenderer and BlobbiAdultSvgRenderer now call
  applySleepingAnimation() in the isSleeping path instead of returning
  the raw static colorizedSvg

Part B — State propagation:
- Tighten CompanionData.state from 'string | undefined' to
  'BlobbiState | undefined' so the sleeping state is type-safe through
  the full chain: parseBlobbiEvent -> useBlobbisCollection ->
  useBlobbiCompanionData -> companionDataToBlobbi -> SVG renderers
- Remove the unnecessary 'as BlobbiState' cast in the adapter now that
  CompanionData.state is properly typed

Part C — Standalone companion sleep action:
- Add useBlobbiSleepToggle hook that independently fetches fresh event
  data from relays, applies decay, publishes the state change, and
  optimistically updates the TanStack cache. Works on any page without
  BlobbiPage being mounted
- Remove the toggleSleep registration plumbing from BlobbiActionsProvider
  and BlobbiActionsContext (ToggleSleepFunction type, toggleSleepRef,
  third parameter on useBlobbiActionsRegistration)
- BlobbiCompanionLayer now uses useBlobbiSleepToggle directly instead
  of reading toggleSleep from useBlobbiActions context
2026-03-31 20:47:00 -03:00
Alex Gleason d749718584 release: v2.2.7 2026-03-31 17:48:17 -05:00
Alex Gleason 922a66835a Fix Nushu script not rendering on Android by bundling Noto Sans Nushu font
Android system fonts don't include glyphs for the Nushu Unicode block
(U+1B170-U+1B2FF), causing the encrypted letter ciphertext to render as
empty boxes. Bundle @fontsource/noto-sans-nushu as a web font so the
glyphs render correctly on all platforms.
2026-03-31 17:44:30 -05:00
Alex Gleason 0d4a96e785 Fix zapstore publish: replace removed -y flag with --quiet
zsp v0.4.5 renamed the -y flag to --quiet. The old flag caused
the publish command to fail silently (exit 0 with usage printed
to stderr), so the CI job appeared to succeed.
2026-03-31 17:23:22 -05:00
Alex Gleason a3e10bc12b release: v2.2.6 2026-03-31 16:52:20 -05:00
Alex Gleason 49c482f2ba Allow scrolling Nushu text when container is too small
Switch from overflow-hidden to overflow-y-auto so the ciphertext can
be scrolled on small screens. The fade gradient becomes sticky so it
stays at the bottom of the visible area as a scroll hint.
2026-03-31 16:48:35 -05:00
Alex Gleason 0ad7a7892b Fix encryption notice overflow on mobile by truncating Nushu text
Make the inner letter sheet a flex column so the decorative rule and
'This message is encrypted' notice are always visible (shrink-0). The
Nushu text area takes the remaining space (flex-1 min-h-0 overflow-hidden)
with a bottom fade-out gradient mask when it overflows.
2026-03-31 16:46:46 -05:00
Alex Gleason 989b423714 Map base64 ciphertext 1:1 to first 64 Nushu characters
Each base64 symbol maps directly to a Nushu codepoint (U+1B170-1B1AF),
preserving the same information density as the original encoding rather
than reducing through an arbitrary modulo.
2026-03-31 16:44:30 -05:00
Alex Gleason 13f703a3ec Remove mail icon from envelope front face for cleaner sealed state 2026-03-31 16:41:08 -05:00
Alex Gleason aa7c8e038b Refine encrypted letter envelope: 3D tilt, curated Nushu, sizing
- Add useCardTilt hook for badge-style 3D hover/touch tilt effect
- Constrain envelope with max-w-md, centered with horizontal padding
- Replace dense Nushu encoding with curated set of simpler characters
  spaced with thin spaces for an elegant, sparse look
- Remove all hint text (flip/open/close) to invite curiosity instead
- Add 'This message is encrypted' with lock icon on the open state
- Use Lock icon import for the encryption notice
2026-03-31 16:38:56 -05:00
Alex Gleason 0469b6cec9 Display encrypted letters as interactive 3D envelopes with Nushu ciphertext
Register kind 8211 across the event rendering pipeline so encrypted
letters render as 3D interactive envelopes instead of raw ciphertext.
Back shows a sealed envelope with sender/recipient names in script font
and a wax seal avatar. Click flips the envelope (CSS 3D transform),
click again opens it to reveal the ciphertext rendered as Nushu
characters -- a real historical secret women's script from China.
2026-03-31 16:32:27 -05:00
filemon ef88ca4235 feat(companion): implement proper sleep/wake state, fix mobile tap interaction
- Fix sleep visuals on floating companion: companionDataToBlobbi adapter
  now passes through actual state and isSleeping instead of hardcoding
  'active'/false, so sleeping Blobbi renders closed eyes and Zzz

- Refactor companion sleep button as direct action: sleep/wake toggle
  is routed through BlobbiActionsProvider (toggleSleep registration)
  instead of the item-flow system. Companion menu button shows Wake up
  (sun emoji) when sleeping, Sleep (moon emoji) when awake

- Freeze companion movement during sleep: state machine respects
  isSleeping flag, clears all timers/targets, forces idle state.
  Float animation and sway CSS animation also disabled while sleeping.
  Blobbi stays parked exactly where sleep was triggered

- Fix mobile tap on companion: remove duplicate touch event handlers
  (touchstart/touchmove/touchend) that conflicted with pointer events.
  Pointer events handle mouse+touch+pen natively. Use containerRef for
  setPointerCapture instead of e.target for reliable cross-platform
  tracking. Remove preventDefault from pointerdown to avoid blocking
  browser touch-to-pointer synthesis
2026-03-31 12:32:48 -03:00
Chad Curtis 1adbe1c98a fix: add /remoteloginsuccess route for remote signer callback 2026-03-31 08:33:11 -05:00
Chad Curtis b97299ce0a fix: remove unused user and useCurrentUser left over from PostActionBar extraction 2026-03-31 08:25:58 -05:00
Chad Curtis 93eeffb1ad fix: remove dead ZapDialog and canZapAuthor imports left over from PostActionBar extraction 2026-03-31 08:22:14 -05:00
Chad Curtis 081ad9240f fix: move zap comment inside right column instead of pl-[52px] offset 2026-03-31 08:19:47 -05:00
Chad Curtis 7d3b92048b fix: load letter fonts so font picker options render in correct typeface 2026-03-31 07:50:38 -05:00
Chad Curtis 3c425a4e68 fix: badge detail and my badges UX improvements
- Move Award to… button inline with awarded count, right-aligned, styled as pill
- Accept Badge action moved to its own row below stats
- Always show organize buttons (move up/down, remove) on mobile in My Badges list
- Use Trash2 icon instead of X for remove badge button
2026-03-31 07:45:29 -05:00
Chad Curtis 4ae90080e8 refactor: extract PostActionBar and unify badge detail tab bar
- Extract shared PostActionBar component used by both PostDetailPage and BadgeDetailContent
- Replace badge detail inline reaction bar with PostActionBar (removes copy button, adds share + more)
- Replace badge detail hand-rolled sticky tab div with SubHeaderBar (pinned) for arc style and hide-on-scroll behaviour
- Add ARC_OVERHANG_PX spacer above tab content
2026-03-31 07:37:44 -05:00
Chad Curtis 2cdcd543a4 fix: only show safe-area padding on pinned SubHeaderBar when at top of viewport
Previously the safe-area padding was tied to navHidden, which fires after
just 8px of scroll — causing the spacer above profile tabs to appear while
the bar was still mid-page. Now a scroll listener checks the bar's actual
getBoundingClientRect().top against the measured safe-area-inset-top, so
the padding only appears once the bar has physically reached the top.
2026-03-31 07:21:23 -05:00
Chad Curtis 71f8ee0e16 fix: support accented and Unicode characters in hashtags
Replace /#\w+/g with /#[\p{L}\p{N}_]+/gu across all hashtag regexes
so that hashtags like #Bíblia and #verdade parse correctly. Affects
NoteContent, BioContent, ComposeBox, and PhotoComposeModal.
2026-03-31 06:48:36 -05:00
Chad Curtis 92634705b3 fix: notifications reply button navigates to /letters/compose instead of in-page 2026-03-31 06:45:10 -05:00
Chad Curtis 7aee4fe712 fix: navigate to /letters/compose instead of opening compose in-page
Reply button and FAB on LettersPage now navigate to the dedicated
/letters/compose route. The ?to= query param pre-fills the recipient
when replying to a received letter.
2026-03-31 05:31:23 -05:00
filemon 0e4ce974f0 fix(companion): remove baseLift from walking float offset
Complement to the wrapper-split fix — also remove the baseLift = -2
constant that biased the walking Y offset permanently upward.
2026-03-31 06:06:17 -03:00
filemon 4ddcee95d9 fix(companion): split float and sway wrappers to fix walking ground gap
Root cause: the CSS animation `animate-blobbi-sway` (blobbi-gentle-sway
keyframes) sets `transform: rotate(-2deg)` which **replaces** the entire
inline `transform` on the same element while the animation is active.
This dropped the `translateY(size * 0.12)` alignment shift (~13px) that
anchors the body to the ground, causing Blobbi to float above the shadow
during walking.

Fix: split the single wrapper into two nested divs:

  Float wrapper (outer): owns translateY + JS float offset (inline transform)
  Sway wrapper (inner):  owns CSS rotation animation only

The CSS keyframes now only override the sway wrapper's transform (which
has no positioning), while the float wrapper's translateY and float
offset remain unaffected. The SVG subtree stability is preserved —
MemoizedBlobbiVisual stays inside the sway wrapper with no changes.
2026-03-31 05:56:48 -03:00
filemon 4e1f7b6007 fix(blobbi): move flies to lower body, use soft smile for hungry mouth
Flies: Reposition all fly orbits (baby + adult) to the lower third of
the body, well below the face region. Orbits are tighter so flies stay
near the grimy lower body / feet area and never overlap eyes or mouth.

Hungry mouth: Replace round 'O' mouth with smallSmile at warning/high
severity. The round mouth read as surprise rather than hunger. A soft
smile pairs naturally with hopeful eyes and drool, reading as 'please
feed me'. Critical hunger still uses droopyMouth for the desperate
state. The priority system is unchanged — if another stat with higher
mouth priority contributes a round mouth, that still wins.
2026-03-31 05:38:58 -03:00
filemon 00f3deb5b2 redesign(blobbi): overhaul dirty effect with muddy smudges, odor wisps, and flies
Replace visually weak dirt marks with a deliberate, cartoon-style
dirty effect inspired by Tamagotchi/Pokémon status readability.

Dirt layer (on-body):
- Organic muddy smudge blobs in warm brown palette (replaces thin strokes)
- Small grime spots clustered near smudges for texture depth
- Subtle dusty haze patches for grimy tinting
- New intensity parameter controls opacity/density per severity

Smell layer (off-body):
- Wavy S-curve stink lines in muted sage green (replaces tiny clouds)
- Soft puff cloudlets with rise-and-fade animation
- Optional buzzing flies on elliptical orbits (critical severity only)
- Wisps are placed at body sides/top so they read as emanating outward

Severity escalation:
- Warning: light smudges (intensity 0.45), 2 faint wisps
- High: heavier grime (0.65), 3 wisps
- Critical: heavy grime (0.80), 4 wisps + 2 buzzing flies
2026-03-31 05:34:44 -03:00
filemon b8037c48e9 chore(blobbi): final validation pass — tighten comments, remove blank line noise
Regression review confirmed all flows intact. Minimal cleanup:
- Remove stray blank lines in SvgRenderer components
- Condense useExternalEyeOffset inline comments (replaced verbose
  per-line explanations with concise section labels)
2026-03-31 05:03:34 -03:00
filemon a3dfe25d13 Merge branch 'main' into implement-reactions-clean 2026-03-31 04:56:59 -03:00
filemon 50a834c4fc refactor(companion): reduce BlobbiCompanionLayer responsibility and remove dead gaze state
Cleanup pass with zero behavior changes.

Extracted from BlobbiCompanionLayer:
- DebugGroundOverlay: 76-line debug overlay moved to its own component
- useActionEmotionOverride: action emotion state + timer logic extracted
  into a focused hook, replacing the inline state/setTimeout/wrapper pattern

Removed dead code:
- gaze state from useBlobbiCompanionGaze return (internal state preserved
  for the hook's own mode-selection logic; only the unused external return
  field removed)
- gaze field from useBlobbiCompanion return and UseBlobbiCompanionResult
- GazeState import from useBlobbiCompanion (no longer in return type)
- gaze field from CompanionContextValue type (unused interface)
- companionRecipeProp / companionRecipeLabelProp identity aliases
- originalHandleItemUse unnecessary alias
- handleItemUseWithEmotion wrapper (replaced by inline triggerOverride call)

Clarified:
- BlobbiCompanionLayer docblock explains its orchestration-only role
- Section comments organize the wiring concerns (item reaction, action
  menu, item use, status reaction, render)
2026-03-31 04:31:47 -03:00
filemon f00332fca5 chore(blobbi): cleanup dead code and add architectural contracts
Stabilization pass — zero behavior changes.

Dead code removed:
- Unused containerRef in both SvgRenderer components (parent wrapper
  owns the DOM query boundary for eye hooks, not the renderer)
- Unused containerRef in BlobbiCompanionVisual
- Dead eyeOffset React state from useBlobbiCompanionGaze (only the ref
  is used now; the state was never updated after the ref-based fix)
- Dead eyeOffset value from useBlobbiCompanion return and
  BlobbiCompanionLayer destructure
- Deprecated AdultReactionState / BabyReactionState type aliases
  (no consumers)
- Deprecated ExternalEyeOffset re-exports from visual wrappers
  (canonical export is lib/types.ts)
- Stale JSDoc comment about containerRef forwarding in renderer

Contract comments added:
- SvgRenderer components: explicit MUST NOT list (no hooks, no modes,
  no reaction classes)
- Visual wrapper containerRef: explains it is the DOM query boundary
  for eye hooks
- MemoizedBlobbiVisual: stability contract listing what it must and
  must not depend on
- useExternalEyeOffset: clarified page vs companion usage for each
  offset prop
- BlobbiCompanionVisual direction prop: documented why it exists unused
2026-03-31 04:19:10 -03:00
filemon 384936f106 refactor(blobbi): extract pure SVG renderers and add explicit render mode
Architecture refactor for the Blobbi visual system:

1. Centralized debug helper (src/blobbi/ui/lib/debug.ts):
   - Replaces all scattered console.log/trace instrumentation
   - Single BLOBBI_DEBUG flag, only logs in DEV mode when enabled
   - Typed debug categories for filtering

2. Explicit render mode API (BlobbiRenderMode: 'page' | 'companion'):
   - Replaces implicit companion detection via eye offset prop sniffing
   - Controls tracking, reaction class suppression, and future behaviors
   - Default is 'page' — no changes needed for existing BlobbiPage callers

3. Pure SVG renderer extraction:
   - BlobbiAdultSvgRenderer: resolve → customize → animate → recipe → sanitize → innerHTML
   - BlobbiBabySvgRenderer: same pipeline for baby stage
   - These components know nothing about hooks, modes, or runtime state
   - Only rerender when visual content changes (blobbi, recipe, emotion, bodyEffects)

4. Visual wrappers simplified:
   - BlobbiAdultVisual/BlobbiBabyVisual own the containerRef, eye hooks,
     and reaction CSS classes — delegate SVG output to the renderers
   - ~480 lines removed across the visual layer

Net result: -305 lines, zero debug console spam, clean separation between
SVG pipeline, eye behavior, and companion runtime.
2026-03-31 03:53:17 -03:00
filemon 81966dac0d fix(companion): stabilize SVG DOM to prevent animation restarts
The companion rerender storm (~46 renders/2s from RAF loops) was causing
the animated SVG subtree to be replaced on every render, killing SMIL
and CSS animations (dizzy spirals, sleepy Zzz, etc.).

Three root causes fixed:

1. Ref-based gaze: eyeOffset was React state updated every frame in
   useBlobbiCompanionGaze, propagating rerenders through the entire
   companion tree. Now writes to a ref that useExternalEyeOffset reads
   imperatively via its own RAF loop — zero React rerenders for gaze.

2. Memoized SVG renderer: created MemoizedBlobbiVisual (React.memo)
   that only rerenders when visual content changes (blobbi, recipe,
   emotion, bodyEffects). Reaction CSS classes (sway/bounce) moved to
   an outer wrapper div in BlobbiCompanionVisual so className changes
   don't touch the dangerouslySetInnerHTML container.

3. Stable recipe references: resolveStatusRecipe() returned fresh {}
   objects for neutral state, defeating memo comparators. Now uses
   shared frozen EMPTY_RECIPE and NEUTRAL_STATUS_RESULT constants.
2026-03-31 03:39:06 -03:00
Chad Curtis 7c8e4f1735 Remove unused EventStats type import in PostDetailPage 2026-03-31 00:34:35 -05:00
Chad Curtis b9b9363468 Render kind 9735 zap receipts and kind 0 profiles in feed and detail pages
- Kind 9735: feed and detail cards mirror the reaction card layout exactly — zap icon bubble, sender avatar/name, 'zapped N sat(s)', timestamp (ml-auto), message indented under profile on the line below. Threaded variant included. Zap rows in InteractionsModal now link to the receipt nevent. ZapEntry gains eventId field.
- Kind 0: feed card renders ProfileCard inline; detail page renders ProfileCard directly with no action header
- CommentContext: add kind 0 (profile) and 9735 (zap) to KIND_LABELS and KIND_ICONS
- NoteCard KIND_HEADER_MAP: add kind 9735 zap header
- shellTitleForKind: 'Zap' for 9735, 'Profile' for 0
2026-03-31 00:24:02 -05:00
Chad Curtis 11ecfb1bcf Render kind 9735 zap receipts and kind 0 profiles in feed and detail pages
- Kind 9735: feed card shows sender, amount (pluralized sat/sats), message; detail page shows activity-style card with ancestor thread; zap rows in InteractionsModal now link to the receipt nevent instead of sender npub, and include eventId on ZapEntry
- Kind 0: feed card renders ProfileCard inline; detail page renders ProfileCard directly with no action header
- CommentContext: add kind 0 (profile) and 9735 (zap) to KIND_LABELS and KIND_ICONS
- NoteCard KIND_HEADER_MAP: add kind 9735 zap header
2026-03-30 23:53:57 -05:00
Alex Gleason 605f4e52fe release: v2.2.5 2026-03-30 22:53:56 -05:00
Alex Gleason a45e649374 Fix infinite re-render crash when dragging profile tabs in edit mode
Remove 'transform' from useLayoutEffect deps in SortableTabChip. During
a drag, useSortable produces a new transform object every frame, which
triggered onActive() -> SubHeaderBar re-render -> new transform ref ->
effect re-fires, causing React error #185 (maximum update depth exceeded).
The active indicator position only needs to update when the active tab
changes, not on every drag frame.
2026-03-30 22:48:31 -05:00
filemon 3f32c95b35 fix(blobbi): update eye helpers for nested gaze group structure
The eye injection and detection modules were broken after adding the
nested .blobbi-eye-gaze group. The issues were:

1. modifyEyeGroupContent used indexOf('</g>') which found the gaze
   group's closing tag instead of the eye group's, breaking effects
   that modify pupil/highlight content

2. injectIntoEyeTrackLayer used a naive regex that didn't handle
   nested groups

3. detectFromProcessedSvg had a rigid regex that required exact
   class order and couldn't handle the new nested structure

Fixes:
- Added findMatchingCloseTag() for balanced group parsing
- Added findGroupByClass() helper for finding group boundaries
- Updated modifyEyeGroupContent to target .blobbi-eye-gaze (innermost)
- Updated injectIntoEyeTrackLayer to use balanced parsing
- Updated detectFromProcessedSvg with flexible class matching
- Updated documentation to reflect new 3-layer eye structure:
  1. .blobbi-blink (outer) - clip-path for eyelid animation
  2. .blobbi-eye (middle) - CSS animations like sleepy wake-glance
  3. .blobbi-eye-gaze (inner) - gaze tracking transforms

This allows eye effects (sad highlights, star eyes, etc.) to work
correctly with the new gaze/animation layer separation.
2026-03-30 19:50:44 -03:00
filemon 6192dfc568 refactor(blobbi): separate eye animation and gaze layers to prevent conflicts
Previously, both CSS animations (like sleepy wake-glance) and JS gaze
tracking targeted the same .blobbi-eye group. This caused conflicts where
external gaze had to disable CSS animations to control the transform.

Now the eye structure has three layers:
1. .blobbi-blink (outer) - clip-path for eyelid/blink animation
2. .blobbi-eye (middle) - CSS animations like sleepy wake-glance
3. .blobbi-eye-gaze (inner) - JS-controlled translate for gaze tracking

This separation allows:
- Sleepy's wake-glance CSS animation to run on .blobbi-eye
- External gaze and mouse tracking to control .blobbi-eye-gaze
- Both effects to work together without disabling either
- Eyelid clip-path animation to remain independent

Changes:
- eye-animation.ts: Added nested .blobbi-eye-gaze group inside .blobbi-eye
- eyes/types.ts: Added gazeLeft, gazeRight, gaze to EYE_CLASSES
- useBlobbiEyes.ts: Updated to target .blobbi-eye-gaze for tracking
- useExternalEyeOffset.ts: Updated to target .blobbi-eye-gaze, removed
  animation disabling hack
2026-03-30 19:19:56 -03:00
filemon de57399301 Merge branch 'main' into implement-reactions-clean 2026-03-30 19:11:31 -03:00
filemon c6e6326b50 fix(blobbi): allow external gaze to override sleepy eye animation
The sleepy emotion uses a CSS animation (sleepy-wake-glance) on .blobbi-eye
elements that applies transform: translateX() for a periodic side-glance.
Previously, useExternalEyeOffset detected this animation and yielded to it,
causing eyes to stop tracking gaze entirely during sleepy.

Now, when external gaze is active and a CSS animation is detected on eye
elements, we disable the animation and take control of the transform. This
allows:

- External gaze tracking to work during sleepy emotion
- Sleepy's eyelid closing animation (SMIL on clip-path) to continue
- The drowsy heavy-lidded effect to layer with gaze tracking

The key insight is that sleepy has two visual effects:
1. Eye position animation (CSS transform) - now disabled for external gaze
2. Eyelid closing animation (SMIL clip-path) - preserved for drowsy look
2026-03-30 19:06:49 -03:00
filemon d836b1f068 fix(blobbi): use RAF loop in useExternalEyeOffset to prevent stuck eyes
The previous useEffect-based approach only applied eye transforms when the
externalEyeOffset prop changed. This caused eyes to get stuck in the center
when the companion was idle because:

- The gaze RAF loop updates eyeOffset state continuously
- React batches state updates, so re-renders may not happen every frame
- useBlobbiEyes also runs a RAF loop for blinking that could interfere
- SVG content changes (emotion recipes) could reset transforms

The fix uses a RAF loop that continuously applies the transform, reading
the latest offset from a ref. This ensures eyes stay positioned correctly
regardless of React render timing or SVG DOM changes.

Dragging previously 'fixed' the stuck eyes because isDragging changes
caused guaranteed re-renders that triggered the old useEffect.
2026-03-30 18:38:23 -03:00
filemon 03fa16ded2 fix(blobbi): fix companion motion/state desync and gaze RAF instability
BUGS FIXED:

1. Motion/State Desynchronization
   - useBlobbiCompanionState was receiving a hardcoded static motion object
     instead of real live motion data from useBlobbiCompanionMotion
   - This caused state decisions (walking, idle, observation) to use stale
     position/dragging data, desyncing behavior from rendered position
   - FIX: Introduce shared motionRef that motion hook writes and state hook
     reads, solving the bidirectional dependency cleanly

2. Gaze Animation Loop Instability
   - The RAF effect for smooth eye movement depended on companionPosition,
     mousePosition, observationTarget, attentionPosition, entryInspectionDirection
   - Every position change caused the loop to be torn down and recreated
   - This caused jitter during movement and stuck eyes after entry animation
   - FIX: Use refs for all frequently-changing values, only depend on isActive
     to start/stop the loop. Loop reads fresh values from refs each frame.

3. Drag Detection in State Hook
   - Changed from motion.isDragging dependency (no longer available) to
     polling motionRef.current.isDragging via interval since refs don't
     trigger re-renders

ARCHITECTURE CHANGES:
- useBlobbiCompanion: Creates shared motionRef, passes to state and motion hooks
- useBlobbiCompanionMotion: Accepts optional sharedMotionRef, syncs motion to it
- useBlobbiCompanionState: Receives motionRef instead of motion object
- useBlobbiCompanionGaze: Uses refs for position/target values, stable RAF loop
2026-03-30 18:14:14 -03:00
filemon 5dac0214ea fix(blobbi): improve companion reaction animations and remove duplicate hooks
- Fix reaction logic in BlobbiCompanionVisual: now uses 'swaying' when walking
  instead of always returning 'idle' (previously dead code)
- Remove duplicate useBlobbiCompanion hooks from src/hooks/ and src/blobbi/core/hooks/
  (orphaned files, not imported anywhere)
- Verified lookMode='forward' does NOT block externalEyeOffset - eye tracking
  system correctly uses external offset when disableTracking is true
2026-03-30 16:57:01 -03:00
filemon 3ddb7c8ceb Merge branch 'main' into implement-reactions-clean 2026-03-30 16:35:29 -03:00
filemon 55b551f214 fix(blobbi): companion now updates reactively when stats change
ROOT CAUSE:
The companion layer was not updating live because:
1. It used a separate query ('companion-blobbi') that wasn't optimistically updated
2. It didn't use projected state, so it showed raw relay data without decay
3. Item use only invalidated queries without optimistic updates, causing relay latency

FIXES:
1. useBlobbiCompanionData now uses useBlobbisCollection (shared with BlobbiPage)
   - Shares the same query cache that gets optimistic updates
   - No longer has a separate stale query

2. useBlobbiCompanionData now applies projected state via useProjectedBlobbiState
   - Companion shows projected decay (recalculates every 60 seconds)
   - Same behavior as BlobbiPage

3. useBlobbiItemUse now optimistically updates the blobbi-collection cache
   - Uses setQueryData to immediately update the parsed companion
   - Companion visual updates instantly after actions
   - Also invalidates for background consistency check

DATA FLOW (after fix):
1. User performs action → useBlobbiItemUse publishes event
2. Optimistic update → setQueryData updates blobbi-collection cache
3. useBlobbisCollection returns new data → blobbi reference changes
4. useProjectedBlobbiState recalculates → projectedState changes
5. useBlobbiCompanionData creates new companion object with new stats
6. BlobbiCompanionLayer's companionStats memo recalculates
7. useStatusReaction sees new stats → resolves new recipe
8. Visual updates immediately

NO REMOUNT KEY NEEDED:
The fix works purely through React's normal reactivity:
- Object references change through the memo chain
- useStatusReaction's effect detects stat changes via reference comparison
- No forced remounts required
2026-03-30 15:38:53 -03:00
filemon 93ccb572e5 fix(blobbi): alternating spiral winding, body-aware food icon positioning
- Fix egg sick spiral winding: Inner 3 now uses clockwise=false for
  proper alternation across all 7 spirals (4 outer + 3 inner)
- Add body-aware food icon positioning for adults using detectBodyPath()
- Food icon now placed at upper-left relative to detected body bounds
- Update FoodIconConfig type to accept bodyPath for shape-aware placement
- Import detectBodyPath in recipe.ts for food icon positioning
2026-03-30 15:15:34 -03:00
filemon 059fb67d26 fix(blobbi): layered egg spirals, natural adult dirt distribution, adult food icon
## Egg spiral layering:
- Added 7 spirals total (4 outer + 3 inner) for magical/dizzy effect
- Outer spirals: float around egg shell at varying distances
- Inner spirals: subtle spirals across the egg body itself
- Mixed colors: gray (#4b5563, #6b7280, #9ca3af) + white accents
- Varying sizes (0.45em to 1.1em), speeds (2s to 4s), directions
- All use true Archimedean spiral paths matching Blobbi dizzy eyes
- Counter-clockwise on left side, clockwise on right for visual balance

## Adult dirt distribution:
- Uses detected body bounds for natural placement
- Distributes across multiple zones (not clustered in center or edges):
  - Lower-left edge (primary)
  - Lower-right edge (primary)
  - Left-center lower area (secondary)
  - Right mid-lower contour (fill)
  - Left side contour (fill)
- Face region ends at 55% body height (all dirt below that)
- Mark length scales with body width (6% of width, min 3 units)

## Adult food icon position/size:
- Position: upper-left (x=55, y=45) instead of upper-right
- Size: 80% larger (scale=1.8) for better visibility
- Stroke width increased proportionally (1.5x)
- Higher opacity (0.75) vs baby (0.65)
- Baby unchanged: upper-right position, original size
2026-03-30 14:57:40 -03:00
filemon eec7f1d5b5 fix(blobbi): body-aware dirt placement, dizzy-style spirals, stronger front dust
## Adult dirt placement now uses real body silhouette:
- detectBodyPath() extracts full X/Y bounds from SVG path
- computeAdultDirtPositions() places marks relative to actual body
- Dirt at lower 35% of body height, near side edges
- Scales with body size (mark length = 5% of width)
- Fallback to conservative defaults if body not detected

## Egg spirals now match dizzy eye visual language:
- Uses same createSpiralPath() Archimedean spiral algorithm
- SVG-native animateTransform rotation (not CSS)
- Dark stroke color (#1f2937) matching dizzy eyes
- Positioned floating around egg, not inside shell
- Varying sizes and rotation speeds for visual interest

## Front/back dust distribution:
- Egg: 2 back particles below, 4 front particles at lower edges
- Baby: 3 back below body, 3 front at lower side edges
- Adult: 3 back below body, 3 front at lower edges (body-aware)
- Front dust: larger, higher opacity (0.75-0.8), darker color
- Back dust: smaller, lower opacity (0.55), lighter color
- All dust avoids face region, stays at lower body edges
2026-03-30 14:42:43 -03:00
filemon 5ab16fbbf3 fix(blobbi): reposition dirt marks to lower body edges, avoid facial zones
Protected zones (dirt marks NEVER appear here):
- Eyes, mouth, eyebrows
- Tears, saliva/drool, blush marks, sparkles
- Upper-center body area where face elements live

Preferred dirt placement zones:
- Lower-left edge of body silhouette
- Lower-right edge of body silhouette
- Bottom edge (well below face region)

Variant differences:
- Egg: dust at lower outer shell edges only, no center-front placement
- Baby (100x100): safe zone y > 72, prefer x < 35 or x > 65
- Adult (200x200): safe zone y > 120, prefer x < 85 or x > 115

Also updated dust particle positions to follow same rules.
2026-03-30 14:30:37 -03:00
filemon a74f7037ff fix(blobbi): variant-aware dirt marks, real spirals, and front-layer dust particles
- Add BlobbiVariant type to body effects system for coordinate scaling
- Separate dirt/stink positions for baby (100x100) vs adult (200x200) viewBox
- Pass variant through applyBodyEffects and applyVisualRecipe pipeline
- Replace egg sick curved paths with real Archimedean spirals using createEggSpiralPath()
- Add generateDustParticles() with front+back layer particles for stronger dirty read
- Increase dust particle opacity and use darker colors for visibility
- Add front-layer dirty particles to egg statusEffects
2026-03-30 14:14:32 -03:00
filemon 18cf251c7e feat(blobbi): add egg status effects and improve adult dirt placement
## Egg Form Visual Effects

### 1. Dirty State (new)
- Sweat droplet near upper-left of egg (blue gradient, slides down animation)
- Dust particles underneath the egg (gentle float-up animation)
- Triggered when recipe has dirtMarks or stinkClouds bodyEffects

### 2. Health/Sick State (new)
- Floating purple dizzy spirals around the egg (3 spirals, rotate animation)
- Replaces adult dizzy eyes since eggs don't have faces
- Triggered when recipe has dizzySpirals in eyes

### 3. Happy State (new)
- Golden sparkle stars around the egg (3 sparkles, twinkle animation)
- Simple 4-point or 8-point star shapes
- Triggered when reaction='happy' and no tears

### Implementation
- Added EggStatusEffects interface: { dirty, sick, happy }
- Props flow: BlobbiStageVisual → BlobbiEggVisual → EggGraphic
- Status effects derived from recipe in BlobbiStageVisual
- All animations respect prefers-reduced-motion

## Adult Form Dirt Placement

### Problem
Dirt marks were appearing outside the Blobbi body silhouette.

### Solution
- Repositioned dirt marks to be centered within body area
- X range: 42-56 (was 35-55) - more centered
- Y range: 55-78 (was 72-80) - better vertical spread
- Added positions for count=4-5 for severity escalation
- Reduced stroke width (1.3 vs 1.5) and opacity (0.55 vs 0.6) for subtlety
- Stink clouds also recentered (x: 44-56 vs 38-62)

New files/exports:
- EggStatusEffects type exported from @/blobbi/egg
- 4 new CSS animations: egg-sweat-drop, egg-dust-particle, egg-spiral, egg-sparkle
2026-03-30 13:46:31 -03:00
filemon 5de5488b24 fix(blobbi): critical health mouth overrides sleepy mouth priority
When health is critical, the dizzy round mouth now wins over sleepy mouth
regardless of energy being low. This ensures severe states read as
'urgent/sick/disoriented' rather than just 'tired'.

## New mouth precedence rule
Critical health bypasses the normal MOUTH_PRIORITY list entirely.
The check happens before pickPart() for mouth resolution.

## Scenarios where critical-health mouth now wins
- health critical + energy low → dizzy mouth (was: sleepy mouth)
- health critical + energy low + hunger low → dizzy mouth
- health critical + everything low → dizzy mouth
- any scenario with health=critical → dizzy mouth guaranteed

## Scenarios where sleepy mouth still wins
- energy low + health normal/warning/high → sleepy mouth
- energy low + any other stats (no critical health) → sleepy mouth
- ordinary tiredness without severe illness → sleepy mouth

The exception is minimal: one conditional check before normal priority
resolution, documented in both MOUTH_PRIORITY and inline comments.
2026-03-30 13:36:33 -03:00
filemon 83887b0516 docs(blobbi): cleanup pass for expression system consistency
## A) Fixed outdated/contradictory comments
- ENERGY_PARTS: Clarified that lower cycleDuration = heavier eyelids (not 'slower')
- MOUTH_PRIORITY: Updated doc to reflect hunger's severity progression (round→droopy)
- resolveStatusRecipe(): Updated example compositions to match current behavior
- recipe.ts module doc: Clarified two pathways (presets vs status-driven)

## B) Aligned EMOTION_RECIPES presets with status-driven behavior
- hungry preset: Updated to match 'high' severity (mouth 3.5x4.5, brows -14°)
- dirty preset: Updated to match 'high' severity (grimace 0.8/0.2, brows +10°)
- Added documentation explaining presets align with high/critical severity

## C) Drool semantics decision: kept hunger-driven
- Drool remains semantically tied to hunger (salivating for food)
- No other stat has natural reason to produce drool
- Architecture already supports it; no changes needed
- Added clarifying comment documenting this decision

## D) Validated multi-stat combinations
All tested combinations produce natural pet-like expressions:
- Single stats: Each has distinct, readable expression
- Multi-stats: Priority rules produce sensible compositions
- Extras additive: Multiple stats can contribute drool + tears
- Body effects: Dirt/stink shows regardless of facial expression

No priority changes needed — current rules work well.
2026-03-30 13:32:03 -03:00
filemon 002461e7cb refactor(blobbi): refine expression quality, severity behavior, and drool positioning
## Hunger Progression
- warning: hopeful/asking (small round 'ooh' mouth, mild pleading brows)
- high: needy (bigger round mouth, more worried brows)
- critical: weak/desperate (droopy pleading mouth, very worried brows)

Hunger now feels like genuine plea progression from 'ooh, food?' to 'please...'
to 'I'm so hungry...' rather than same expression at all levels.

## Health Eye Priority
- Only CRITICAL health claims eyes (dizzy spirals)
- Warning/high health no longer override sadness/hunger eyes
- This lets sad/hungry eyes show through when health is merely warning/high

## Severity Escalation (all stats)
Each stat now has documented severity escalation:
- energy: sleepy → heavier sleepy → very drowsy (slower blink cycles)
- hunger: asking → needy → desperate (mouth shape progression)
- happiness: down → sad → crying (eye wetness + tears progression)
- hygiene: uncomfortable → gross → very gross (grimace + dirt escalation)
- health: weak → sick → dizzy (only critical gets dizzy spirals)

## Drool Positioning Fix
- Added computeDroolAnchor() to calculate drool position based on final mouth shape
- Added generateDroolAtAnchor() to render drool at computed anchor
- Drool now correctly attaches to roundMouth, droopyMouth, sadMouth edges
- Previously drool used original mouth position, looked detached with some shapes

## Priority Order (unchanged)
Eyes: health(critical) > energy > happiness > hunger > hygiene
Mouth: energy > health > happiness > hunger > hygiene
Eyebrows: health > hunger > happiness > hygiene > energy
2026-03-30 13:21:47 -03:00
filemon a480379fa5 refactor(blobbi): refine emotion presets and part contributions for natural pet-like expressions
- Update EMOTION_RECIPES with better documentation and design principles
- Add design comments explaining each preset's purpose and feeling
- Refine hungry preset: pleading/hopeful (not sad) with round anticipating mouth
- Refine dirty preset: uncomfortable/irritated grimace with furrowed brows
- Refine dizzy preset: add distressed raised brows for urgency
- Update ENERGY_PARTS: emphasize relaxed drowsiness, no forced brows
- Update HEALTH_PARTS: weak/unwell feeling distinct from sadness
- Update HUNGER_PARTS: pleading expression with open 'ooh food?' mouth
- Update HYGIENE_PARTS: irritated grimace, slightly furrowed brows
- Update HAPPINESS_PARTS: genuine emotional sadness with progressive tears

Each stat now has a distinct visual 'personality' that creates empathy:
- Hunger evokes nurturing (hopeful, pleading)
- Energy evokes tiredness (drowsy, fading)
- Health evokes concern (weak, unwell)
- Hygiene evokes discomfort (irritated, 'I feel gross')
- Happiness evokes emotional connection (genuine sadness)
2026-03-30 12:42:47 -03:00
filemon c37d0d15a6 feat(blobbi): part-priority status reaction system for natural expressions
Replace the old 'single winning preset' approach in resolveStatusRecipe()
with a part-priority composition system. Each low stat now contributes
independently to eyes, mouth, eyebrows, extras, and bodyEffects.

Architecture:
- Each stat has a PartContributionResolver that returns what it contributes
  to each facial/body part at each severity level (warning/high/critical).
- Exclusive parts (eyes, mouth, eyebrows) use per-part priority lists to
  decide which stat wins that slot when multiple stats are low.
- Additive parts (extras, bodyEffects) merge contributions from all low
  stats simultaneously.

Part priority rules:
- Eyes: health(critical/dizzy) > energy(sleepy) > happiness(watery) > hunger
- Mouth: energy(sleepy) > health(sad/round) > happiness(sad) > hunger(droopy)
- Eyebrows: health > hunger(worried) > happiness(lowered) > hygiene(flat)
- Extras: additive — drool+food(hunger), tears(happiness), all coexist
- BodyEffects: additive — dirt+stink(hygiene), anger-rise, all coexist

Severity escalation examples:
- Happiness tears only appear at high/critical, not warning
- Health switches from sad face to dizzy spirals at critical
- Hunger droopiness and dirt mark counts scale with severity
- Happiness eye water fill only at critical (full crying)

Combined stat examples:
- hunger + hygiene: hungry eyes, droopy mouth, worried brows, drool +
  food icon, dirt + stink clouds
- energy + hunger: sleepy eyes, sleepy mouth, hungry eyebrows, drool +
  food icon
- health(critical) + energy: dizzy eyes (beats sleepy), sleepy mouth,
  health eyebrows
- all stats low: prioritized eyes/mouth/brows, additive drool + tears

Also adds 'sick' and 'dirty' entries to LABEL_CYCLE_DURATIONS in the
hook to match the new stat-based label format.
2026-03-30 12:33:19 -03:00
filemon 79ccfd661a refactor(blobbi): final recipe-first consistency pass
- Rename emotionName → recipeLabel in applyVisualRecipe() signature and
  update SVG class names from blobbi-emotion to blobbi-recipe, since the
  parameter carries a recipe label (e.g. 'hungry-sleepy'), not strictly
  an emotion name.

- Guard against bodyEffects double-application in BlobbiAdultVisual and
  BlobbiBabyVisual: skip the manual applyBodyEffects() call when a
  recipeProp is provided, since applyVisualRecipe() already applies
  recipe.bodyEffects internally. The manual bodyEffects prop remains
  available for non-recipe use cases only.

- Update module-level docs in recipe.ts, status-reactions.ts, and
  useStatusReaction.ts to consistently describe the recipe-first
  architecture without leftover emotion-layer terminology.

- Remove unused BodyEffectConfig import from recipe.ts (pre-existing
  eslint error).
2026-03-30 12:14:40 -03:00
filemon c77b68eed2 fix(blobbi): eliminate body effects duplication, fix stat recovery, improve merged label timing
- Remove bodyEffects from StatusRecipeResult and useStatusReaction output.
  Body effects are folded into recipe.bodyEffects by resolveStatusRecipe()
  and applied once by applyVisualRecipe(). No separate channel needed.

- Fix stat recovery logic: re-resolve via resolveStatusRecipe() on every
  stat change instead of forcing neutral when previous triggering stat
  recovers. If energy recovers but hunger is still low, the hook now
  correctly transitions to the hungry recipe instead of neutral.

- Fix getRecipeCycleDuration() for merged labels (e.g. 'boring-sleepy'):
  compute Math.max() of all matching durations instead of returning the
  first match.

- Update all consumers (BlobbiPage, BlobbiCompanionLayer) to stop
  destructuring/passing bodyEffects from status reaction output.

- Update doc comments across visual components to clarify that the
  bodyEffects prop is for manual/external use only, not for status
  reaction data.
2026-03-30 11:52:00 -03:00
filemon fd9a963b27 refactor(blobbi): complete recipe-first architecture, remove secondaryEmotion
Finish the migration from emotion-name composition to final visual
recipe resolution throughout the rendering pipeline.

Key changes:
- New emotion-types.ts: neutral type file for BlobbiEmotion/BlobbiVariant,
  breaking the import cycle between recipe.ts and emotions.ts
- status-reactions.ts: resolveStatusRecipe() now returns a fully resolved
  BlobbiVisualRecipe directly (merging sleepy+boring etc. internally)
- useStatusReaction: tracks resolved recipe state, outputs recipe+recipeLabel
  instead of emotion+secondaryEmotion
- Visual components (Adult, Baby, Stage): accept recipe+recipeLabel prop
  for recipe-first rendering; emotion prop kept as convenience for presets
- Companion components: pass recipe directly, no more secondaryEmotion
- BlobbiPage: passes resolved recipe from useStatusReaction to visuals
- emotions.ts: removed applyMergedEmotion() and mergeVisualRecipes re-export
- mergeVisualRecipes() stays in recipe.ts as an internal utility only used
  by status-reactions.ts for combining low-stat recipes

secondaryEmotion is fully eliminated from the codebase (0 occurrences).
The rendering path is now recipe-first end-to-end.
2026-03-29 23:37:27 -03:00
filemon 672d252492 refactor(blobbi): replace monolithic emotion system with part-based visual recipe architecture
Introduce BlobbiVisualRecipe as the central type for composing Blobbi
expressions from independent parts (eyes, mouth, eyebrows, bodyEffects,
extras). Named emotions are now presets that resolve into part-based
recipes via resolveVisualRecipe().

Key changes:
- New recipe.ts with BlobbiVisualRecipe types, EMOTION_RECIPES, and
  applyVisualRecipe() rendering pipeline
- emotions.ts becomes a thin public API delegating to recipe.ts
- Remove base/overlay emotion stacking model from status-reactions.ts
  and useStatusReaction.ts in favor of single emotion + secondaryEmotion
  for recipe-level merging
- Visual components (Adult, Baby, Stage) now resolve and merge recipes
  in a single pass instead of calling applyEmotion() twice
- Companion components updated to use secondaryEmotion prop
- All existing emotion presets preserved with identical visual output
- Backward-compatible: applyEmotion() API unchanged, legacy type aliases
  provided for EmotionConfig and EMOTION_CONFIGS
2026-03-29 23:16:25 -03:00
filemon bc4e00520e feat(blobbi): use stable idPrefix for body effect SVG element IDs
- applyEmotion now accepts optional instanceId parameter (5th arg)
- instanceId is passed through to applyBodyEffects as idPrefix
- BlobbiAdultVisual and BlobbiBabyVisual now pass blobbi.id as instanceId
- Anger-rise clip paths and gradients now use blobbi.id for stable IDs
  (e.g., blobbi-anger-clip-abc123 instead of random suffix)
- Random fallback still exists when instanceId is not provided
- Same Blobbi instance now produces deterministic SVG output
2026-03-29 22:03:00 -03:00
filemon d777d1bc98 refactor(blobbi): make bodyEffects/apply.ts the single entry point for body effects
- emotions.ts now delegates all body effects to applyBodyEffects()
- Removed direct imports of detectBodyPath, generateAngerRiseEffect,
  generateDirtMarks, generateStinkClouds from emotions.ts
- emotions.ts now only imports applyBodyEffects and BodyEffectsSpec
- Added unique ID generation for anger-rise clip paths and gradients
  (prevents collisions when multiple Blobbis render on same page)
- Body effects are applied after face overlays via single applyBodyEffects call
- Anger-rise overlay is still inserted right after body path for z-ordering
2026-03-29 21:53:30 -03:00
filemon 4cd97124da refactor(eyebrows): centralize class names and form offsets
- Add EYEBROW_CLASSES constant with all CSS class names
- Add FORM_EYEBROW_OFFSETS map for owli/froggi adjustments
- Rename keyframe from 'eyebrow-bounce' to 'blobbi-eyebrow-bounce'
- Export both constants from index.ts
- No behavior change, same public API
2026-03-29 21:08:42 -03:00
filemon 7e7abdee3d simplify sleepy mouth to direct replacement
- Sleepy mouth is now clearly documented as a canonical standalone shape
- Direct replacement: no morph, transition, or interpolation between mouth states
- Keeps MouthAnchor architecture for stable positioning
- Updated docs across mouth/, types, and emotions.ts to be consistent
- Removed any wording suggesting transitions or morphing
2026-03-29 20:49:42 -03:00
filemon 9ed2127494 Introduce MouthAnchor for stable sleepy mouth positioning
applySleepyMouth no longer calls detectMouthPosition internally.
Instead, the orchestrator derives a MouthAnchor from the original
neutral SVG during the detection phase and passes it through.

This makes sleepy mouth placement reliable regardless of what base
emotion mouth (round ellipse, frown path, droopy, etc.) was applied
before the sleepy overlay runs.

mouth/types.ts:
- Added MouthAnchor interface ({ cx, cy })

mouth/detection.ts:
- Added mouthAnchorFromDetection(detection): derives { cx, cy } from
  a MouthDetectionResult (center of startX..endX, controlY)

mouth/generators.ts:
- applySleepyMouth now takes (svgText, anchor: MouthAnchor) instead of
  detecting internally. No more dependency on detectMouthPosition from
  within the sleepy mouth path. generateSleepyMouth is unchanged.

emotions.ts:
- Detection phase now computes mouthAnchor alongside mouth and eyes
- applySleepyAnimation receives the anchor and passes it through
- The anchor is always from the original unmodified SVG
2026-03-29 20:28:30 -03:00
filemon 30608ae8ed Merge branch 'main' into implement-reactions-clean 2026-03-29 20:24:04 -03:00
filemon ae43014cf2 Replace sleepy mouth morph with canonical breathing mouth shape
Remove the old sleepy mouth behavior that morphed the current mouth path
(smile → U-shape → smile via SMIL path animation). Replace with a
dedicated sleepy mouth: a small filled ellipse with a subtle breathing
animation (gentle expand/contract cycle, 3s period).

What changed:

mouth/generators.ts:
- Added generateSleepyMouth(centerX, centerY): produces a canonical
  small round ellipse (rx=2.8, ry=3.2) with SMIL breathing animation
- Added applySleepyMouth(svgText): detects current mouth position,
  generates the sleepy mouth, replaces whatever mouth is present
- Removed applySleepyMouthAnimation (the old morph-based approach)

mouth/detection.ts:
- Added replaceCurrentMouth(svgText, newMouthSvg): finds any element
  with blobbi-mouth class (path or ellipse, self-closing or with
  children) and replaces it. Falls back to Q-curve path matching.
  This handles all mouth types: base smile, sad frown, round mouth,
  droopy mouth, and previously-animated mouths.

mouth/types.ts:
- Removed SleepyMouthAnimationConfig (no longer needed)

emotions.ts:
- applySleepyAnimation no longer takes a mouth parameter
- Calls applySleepyMouth(svgText) instead of the old morph function
- Sleepy eye behavior (clip-path SMIL, closed-eye lines, wake-glance
  CSS, Zzz text) is completely unchanged

The sleepy mouth is now a proper canonical mouth shape in the mouth/
module, positioned at the detected mouth center, independent of
whatever base emotion mouth was applied before it.
2026-03-29 20:14:09 -03:00
filemon ea8d3dd0f3 Extract real implementations into mouth/, eyebrows/, bodyEffects/ modules
Complete the modularization of the visual emotion system. Each subsystem
now owns its implementation rather than re-exporting from emotions.ts.

mouth/ (554 lines):
- detection.ts: marker-based + regex fallback mouth detection, replacement
- generators.ts: round, sad, droopy, big smile, small smile, drool, food
  icon, sleepy mouth morph animation
- types.ts: MouthPosition, MouthDetectionResult, shape configs
- index.ts: barrel exports

eyebrows/ (194 lines):
- generators.ts: eyebrow generation with per-eye overrides,
  variant/form offsets, animated bounce styles
- types.ts: EyebrowConfig, AnimatedEyebrowsConfig
- index.ts: barrel exports

bodyEffects/ (392 lines):
- generators.ts: body path detection, dirt marks, stink clouds,
  anger-rise effect (full implementation moved from emotions.ts)
- apply.ts: applyBodyEffects() composable applicator
- types.ts: BodyEffectConfig, BodyPathInfo, BodyEffectsSpec
- index.ts: barrel exports

eyes/ (unchanged):
- Already correctly modular. Confirmed no stranded eye code remains
  in emotions.ts — the legacy applySadEyeWaterFill/applySadEyeHighlights
  functions were dead code (superseded by eyes/effects.ts) and removed.

emotions.ts (2135 → 628 lines):
- Now a pure orchestrator: recipe definitions + composition logic
- Imports all implementations from subsystem modules
- Contains only: BlobbiEmotion type, EMOTION_CONFIGS recipes,
  applyEmotion() orchestrator, tear generation (cross-cutting overlay),
  sleepy animation coordination (cross-cutting, touches eyes + mouth),
  and deprecated re-exports for backward compatibility
- No more detection internals, SVG geometry generators, or effect
  implementations
2026-03-29 19:55:27 -03:00
filemon cf0524a211 Extract composable visual modules and decouple dirty from face emotions
Foundation for migrating the monolithic emotion system toward a composable
architecture where each visual area (eyes, mouth, eyebrows, body effects)
is handled independently.

New modules created:
- bodyEffects/ — types, generators (dirt marks, stink clouds, anger rise),
  and applyBodyEffects() for applying body decorators independently of face
- mouth/ — types and re-exports of existing mouth detection/generation
- eyebrows/ — types and re-exports of existing eyebrow generation

Dirty emotion refactored:
- Removed face modifications (droopyMouth, eyebrows) from EMOTION_CONFIGS.dirty
- dirty is now a body-only decorator that adds dirt marks + stink clouds
  without touching eyes, mouth, or eyebrows
- Hygiene stat now maps to 'boring' as the face emotion (same as happiness)
- Body effects (dirty) are resolved independently in resolveStatusEmotions()
  and flow as a separate bodyEffects field through the entire pipeline:
  resolveStatusEmotions → useStatusReaction → BlobbiStageVisual →
  BlobbiAdultVisual/BlobbiBabyVisual → applyBodyEffects()
- Any face + dirty is now possible: boring+dirty, sleepy+dirty, dizzy+dirty

The existing emotion system (applyEmotion) still works unchanged for all
other emotions. The eyes/ module already existed. This is an incremental
step — no full migration yet.
2026-03-29 17:57:45 -03:00
filemon 8d04bbbdbe Complete baseEmotion + overlayEmotion migration across the visual pipeline
Finish the two-layer emotion architecture so resolveStatusEmotions() is
the single source of truth and both the main BlobbiPage and the floating
companion use the same flow.

useStatusReaction:
- Now returns baseEmotion, overlayEmotion, triggeringBaseStat,
  triggeringOverlayStat, isStatusReactionActive, currentSeverity,
  isOverrideActive (replaces the old single currentEmotion).
- Internally calls resolveStatusEmotions() on every check cycle and
  tracks base and overlay transitions independently with animation
  safety per layer.
- Action overrides replace the overlay; the base persists underneath.

status-reactions.ts:
- Remove combineEmotions() (no longer needed).
- Deprecate resolveStatusReaction() with a JSDoc notice.
- resolveStatusEmotions() is now the primary API.

BlobbiStageVisual:
- Accepts a new baseEmotion prop and forwards it to BlobbiBabyVisual
  and BlobbiAdultVisual.

BlobbiPage (main consumer):
- Destructures baseEmotion + overlayEmotion from the hook and passes
  both through to BlobbiStageVisual.

Companion system:
- CompanionData now carries full BlobbiStats and state.
- BlobbiCompanionLayer runs its own useStatusReaction to drive the
  companion's emotions from stats, including item-use action overrides.
- BlobbiCompanion and BlobbiCompanionVisual accept baseEmotion + emotion
  props and forward them to the underlying visual components.
2026-03-29 17:27:17 -03:00
filemon a3e6ff34db Add boring and dirty emotions to dev emotion tester panel
- Added boring emotion (😑) - low-energy, unamused expression
- Added dirty emotion (💩) - hygiene-specific with dirt/stink visuals
- Maintains existing emotion order with new emotions near the top
2026-03-29 17:01:48 -03:00
filemon 82b2aeb294 Refactor Blobbi visual state system: separate base face from overlay animations
- Add 'boring' persistent face: low-energy, unamused expression (replaces sad as generic fallback)
  - Droopy mouth with shallow curve, flat eyebrows
  - Used for non-critical bad stats (health, happiness)

- Add 'dirty' persistent state: hygiene-specific visuals
  - Includes dirt marks on lower body (3 curved scratch-like lines)
  - Animated stink clouds floating upward
  - Uses boring face as base + hygiene effects

- Refactor 'sleepy' to be an overlay animation
  - KEY FIX: sleepy now animates the CURRENT mouth state instead of resetting to default smile
  - When Blobbi is unwell (boring/dirty/dizzy face), sleepy animation preserves that base face
  - Implementation: applySleepyMouthAnimation finds existing mouth path and animates from there
  - Example: boring face + sleepy = boring expression with sleepy animation on top

- Update status-reactions.ts emotion mapping
  - health: boring (not feeling good) → dizzy (critical)
  - hygiene: dirty (poor hygiene visuals)
  - happiness: boring (low energy, unamused)
  - energy: sleepy (now an overlay, not base-replacing)

- Add base + overlay emotion architecture to visual components
  - BlobbiAdultVisual and BlobbiBabyVisual now accept optional baseEmotion prop
  - Emotions applied sequentially: base first, then overlay
  - Preserves existing behavior when only one emotion provided

- Add resolveStatusEmotions() utility
  - Separates base emotions from overlay emotions
  - Returns StatusEmotionResult with baseEmotion and overlayEmotion
  - Enables proper multi-stat handling (e.g., low health + low energy = boring face with sleepy overlay)

Architecture notes:
- Base emotions (boring, dirty, dizzy, sad, happy, etc.): replace face completely
- Overlay emotions (sleepy): animate on top without replacing base
- Critical fix: Blobbi no longer visually resets to happy during sleepy cycle when in bad state
2026-03-29 16:36:16 -03:00
filemon fd20081ce8 fix: prevent reaction animations from being cut off by status recomputation
Refactored useStatusReaction hook to be more stateful and animation-aware:

- Track currently active reaction to avoid restarting same reaction
- Distinguish between persistent (sleepy, sad, dizzy, hungry) and one-shot reactions
- Persistent reactions loop continuously while condition remains active
- Only replace reactions when: type changes, higher priority interrupts, or one-shot completes
- Remove stats from useEffect dependencies to prevent reset on every recomputation
- Add animation cycle duration awareness to avoid mid-animation interruptions
- Use refs for stats/timing to maintain stable callback references

This ensures:
- Sleepy animation completes full cycle including slow eye opening
- Crying/sad reactions don't reset before tear cycle completes
- Dizzy animation doesn't keep resetting its visual motion
- Eyebrow/face reactions don't flicker from repeated reapplication
2026-03-29 15:17:33 -03:00
filemon 5ffab157d7 Unify eye system contract and fix animation conflicts
- Standardize data attributes: data-cx/cy → data-eye-cx/cy with legacy fallback
- Replace hardcoded eye selectors with EYE_CLASSES constants from eyes/types
- Remove unused side-specific clip rect class variants from EYE_CLASSES
- Fix sleepy animation: skip JS blink when SMIL animations present
- Fix companion reaction support: skip eye transforms when CSS animations active
- Update detection.ts to try new attribute format first, fall back to legacy
- Update useBlobbiEyes and useExternalEyeOffset to respect CSS animations
2026-03-29 05:16:51 -03:00
filemon c6e791d18f Implement reactions 2026-03-29 04:41:42 -03:00
Lemon a80b306248 Reset feed composer to collapsed state after posting 2026-03-28 23:11:47 -07:00
Lemon c8c294a8ad Match ComposeBox background opacity with header and subheader (bg-background/85) 2026-03-28 23:11:47 -07:00
310 changed files with 29787 additions and 10660 deletions
+1
View File
@@ -108,6 +108,7 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
- Focus on what the user sees/experiences, not internal implementation details
- Use the current date in YYYY-MM-DD format
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
+30 -11
View File
@@ -26,19 +26,36 @@ test:
script:
- npm run test
pages:
deploy-nsite:
stage: deploy
timeout: 5 minutes
timeout: 10 minutes
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
variables:
NSYTE_VERSION: "v0.24.1"
script:
# Build the web app
- npm ci
- npm run build
- rm -rf public
- mv dist public
artifacts:
paths:
- public
only:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
- cp dist/index.html dist/404.html
# Download nsyte binary
- curl -fsSL "https://github.com/sandwichfarm/nsyte/releases/download/${NSYTE_VERSION}/nsyte-linux" -o /usr/local/bin/nsyte
- chmod +x /usr/local/bin/nsyte
# Deploy to nsite via nsyte using the nbunksec credential
- >-
nsyte deploy ./dist
-i
--sec "$NSITE_NBUNKSEC"
--name ditto
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
--fallback "/index.html"
--use-fallback-relays
--use-fallback-servers
build-apk:
stage: build
@@ -185,6 +202,8 @@ publish-zapstore:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
SIGN_WITH: $ZAPSTORE_BUNKER_URL
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
BLOSSOM_URL: "https://blossom.ditto.pub"
script:
- go install github.com/zapstore/zsp@latest
@@ -198,4 +217,4 @@ publish-zapstore:
- VERSION="${CI_COMMIT_TAG#v}"
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
- zsp publish -y --skip-metadata --skip-preview zapstore.yaml
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
+126 -6
View File
@@ -699,23 +699,84 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
#### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
- **First publish (no `prev`)**: `published_at` is set equal to `created_at`
- **Update (`prev` provided)**: `published_at` is preserved from the old event
- **Old event lacks `published_at`**: nothing is fabricated
- **Caller already set `published_at` in tags**: left alone
**Convention**: Name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
```typescript
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ...
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
### D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
#### When to Check for Collisions
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
#### Implementation Pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
```typescript
// Before publishing a new addressable event:
const slug = slugify(title, { lower: true, strict: true });
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'Change the slug or edit the existing item.',
variant: 'destructive',
});
return;
}
// Safe to publish
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
```
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
### Nostr Login
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
@@ -977,6 +1038,16 @@ const defaultConfig: AppConfig = {
The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations.
### Adding a New AppConfig Value
Adding a new configuration field requires updates in **three places**. Missing any of them will cause build failures or runtime issues.
1. **TypeScript interface** (`src/contexts/AppContext.ts`): Add the field to the `AppConfig` interface with a JSDoc comment.
2. **Zod schema** (`src/lib/schemas.ts`): Add the same field to `AppConfigSchema`. The `DittoConfigSchema` (used to validate the build-time `ditto.json` file) is derived from `AppConfigSchema` with `.strict()` mode, so any field present in `ditto.json` but missing from the Zod schema will cause a build error.
3. **Default value** (`src/contexts/AppContext.ts`): If the field is required (not optional), add a default value in `defaultConfig`. Optional fields (`?` in the interface, `.optional()` in Zod) can be omitted from the default.
### Relay Management
The project includes a complete NIP-65 relay management system:
@@ -1338,7 +1409,7 @@ After adding or removing plugins, run `npx cap sync` to update the native projec
The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
1. **test** - Runs `npm run test` on every commit (skipped for tags)
2. **deploy** - Builds and deploys to GitLab Pages (default branch only)
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
@@ -1394,4 +1465,53 @@ The script accepts options:
**Key points:**
- After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs
- If the client key is rotated, run the script again and update the GitLab CI/CD variables
- If the client key is rotated, run the script again and update the GitLab CI/CD variables
### nsite Publishing
The project automatically deploys the web app to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The `deploy-nsite` CI job builds the Vite app and uploads the `dist/` directory to Blossom servers, publishing site manifest events to Nostr relays.
nsyte uses a NIP-46 bunker credential called `nbunksec` -- a bech32-encoded string that bundles the bunker pubkey, client secret key, and relay info into a single self-contained token. This is passed to nsyte via `--sec`.
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
#### Initial Setup (one-time)
1. Install nsyte locally:
```bash
curl -fsSL https://nsyte.run/get/install.sh | bash
```
2. Generate the CI credential:
```bash
nsyte ci
```
This will guide you through connecting a NIP-46 bunker (e.g. Amber) and output an `nbunksec1...` string. The credential is shown only once.
3. Add the `nbunksec1...` value as the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
#### Configured Relays and Servers
The deploy job publishes to these relays:
- `wss://relay.ditto.pub`
- `wss://relay.nsite.lol`
- `wss://relay.dreamith.to`
- `wss://relay.primal.net`
And uploads blobs to these Blossom servers:
- `https://blossom.primal.net`
- `https://blossom.ditto.pub`
- `https://blossom.dreamith.to`
The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
#### Credential Rotation
To rotate the nsite credential:
1. Revoke the old bunker connection in your signer app
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
+191 -5
View File
@@ -1,5 +1,191 @@
# Changelog
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
@@ -37,11 +223,11 @@
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
@@ -60,7 +246,7 @@
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
@@ -87,10 +273,10 @@
- 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 divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- 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
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
+68 -1
View File
@@ -2,12 +2,31 @@
## Event Kinds Overview
### Ditto Kinds
| Kind | Name | Description |
|-------|----------------------|-------------------------------------------------------|
| 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 |
### Community Kinds
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
| Kind | Name | Description | Spec |
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14921 | Blobbi Record | Immutable lifecycle record (birth, evolution, adoption) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 31124 | Blobbi Pet State | Current state of a virtual Blobbi pet (addressable) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
---
@@ -294,3 +313,51 @@ The `shape` field is added to the JSON content of a kind 0 event alongside stand
- The `shape` field is purely cosmetic and has no protocol-level significance.
- Clients MAY choose not to support this extension, in which case avatars render as circles as usual.
---
## Community NIP Specifications
The following specifications are maintained by their respective authors. Ditto implements these kinds but does not own the specs. See each link for the full event structure, tags, and client behavior.
### Color Moments (Kind 3367)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md
**App:** https://espy.you
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
### Geocaching (Kinds 37516, 7516)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md
**App:** https://treasures.to
NIP-GC defines geocaching on Nostr. Kind 37516 (addressable) is a geocache listing with location (geohash), difficulty/terrain scores, size, and type. Kind 7516 is a found log recording a successful visit. The spec also covers comment logs (kind 1111 via NIP-22), verified finds with cryptographic proof (kind 7517), and cache retirement.
### Personal Letters (Kind 8211)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md
**App:** https://lief.to
NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, decorative frames, and custom fonts. Letters render as 5:4 landscape postcards. The privacy model is intentionally postcard-like: sender/recipient metadata is visible, content is encrypted.
### Weather Station (Kinds 4223, 16158)
**Author:** Sam Thomson
**Spec:** https://github.com/nostr-protocol/nips/pull/2163
**App:** https://weather.shakespeare.wtf
**Firmware:** https://github.com/samthomson/weather-station
Kind 16158 (replaceable) describes a weather station's configuration: name, geohash location, elevation, power source, connectivity, and sensor inventory. Kind 4223 (regular) carries individual sensor readings as 3-parameter tags `[sensor_type, value, model]`, enabling historical queries and cross-station comparison. Each station has its own keypair.
### Blobbi Virtual Pet (Kinds 31124, 14919, 14920, 14921, 11125)
**Author:** Danifra
**Spec:** https://github.com/Danidfra/nostr-pet/blob/production/NIP.md
**App:** https://nostr-pet.vercel.app
**See also:** [Blobbi tag schema](docs/blobbi/blobbi-tag-schema.md) (Ditto-specific integration details)
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.2.4"
versionName "2.6.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -60,7 +60,7 @@ const builtinThemes = {
};
```
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
### ThemeConfig
+1 -2
View File
@@ -127,7 +127,6 @@ 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
@@ -192,7 +191,7 @@ These tags are from legacy versions and MUST be removed when republishing events
- 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 social tags (`breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.4;
MARKETING_VERSION = 2.6.1;
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.2.4;
MARKETING_VERSION = 2.6.1;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+1900 -57
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.2.4",
"version": "2.6.1",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -44,6 +44,7 @@
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/creepster": "^5.2.7",
"@fontsource/luckiest-guy": "^5.2.8",
"@fontsource/noto-sans-nushu": "^5.2.6",
"@fontsource/pacifico": "^5.2.7",
"@fontsource/permanent-marker": "^5.2.7",
"@fontsource/pirata-one": "^5.2.8",
@@ -52,8 +53,19 @@
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.4.0",
"@milkdown/core": "^7.20.0",
"@milkdown/ctx": "^7.20.0",
"@milkdown/plugin-clipboard": "^7.20.0",
"@milkdown/plugin-history": "^7.20.0",
"@milkdown/plugin-listener": "^7.20.0",
"@milkdown/plugin-upload": "^7.20.0",
"@milkdown/preset-commonmark": "^7.20.0",
"@milkdown/preset-gfm": "^7.20.0",
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.4.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -116,6 +128,7 @@
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"slugify": "^1.6.8",
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
+191 -5
View File
@@ -1,5 +1,191 @@
# Changelog
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
@@ -37,11 +223,11 @@
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
@@ -60,7 +246,7 @@
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
@@ -87,10 +273,10 @@
- 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 divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- 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
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
+28 -9
View File
@@ -16,12 +16,14 @@ import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
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 { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
@@ -49,6 +51,7 @@ const hardcodedConfig: AppConfig = {
appName: "Ditto",
appId: "ditto",
homePage: "feed",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
autoShareTheme: true,
@@ -114,17 +117,18 @@ const hardcodedConfig: AppConfig = {
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
"feed",
"notifications",
"search",
"themes",
"letters",
"badges",
"blobbi",
"theme",
"badges",
"emojis",
"letters",
"themes",
"settings",
"help",
],
@@ -147,15 +151,30 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
};
/**
* Parse and validate build-time ditto.json overrides from the env string.
* Returns an empty object when no config file was provided or validation fails.
*/
function parseDittoConfig(): DittoConfig {
try {
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
if (!json) return {};
return DittoConfigSchema.parse(json);
} catch {
return {};
}
}
/**
* Merge hardcoded defaults with build-time ditto.json overrides.
* Deep-merges feedSettings so a partial override doesn't erase defaults.
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
*/
const dittoConfig = parseDittoConfig();
const defaultConfig: AppConfig = {
...hardcodedConfig,
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
? __DITTO_CONFIG__
: {}),
...dittoConfig,
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
};
export function App() {
@@ -183,11 +202,11 @@ export function App() {
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
+41 -10
View File
@@ -6,8 +6,10 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { getExtraKindDef } from "./lib/extraKinds";
@@ -22,6 +24,9 @@ const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => (
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
// Lazy-loaded emoji pack dialog
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
@@ -29,6 +34,7 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
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 ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
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 })));
@@ -45,6 +51,7 @@ const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default:
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 LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
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 })));
@@ -70,6 +77,8 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
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 FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
const colorsDef = getExtraKindDef("colors")!;
@@ -99,6 +108,26 @@ function PollsFeedPage() {
);
}
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
function EmojiFeedPage() {
const [composeOpen, setComposeOpen] = useState(false);
return (
<>
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
{composeOpen && (
<Suspense fallback={null}>
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
</Suspense>
)}
</>
);
}
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
function ProfileRedirect() {
const { user, metadata } = useCurrentUser();
@@ -111,6 +140,8 @@ export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<Toaster />
<VersionCheck />
<MinimizedAudioBar />
<AudioNavigationGuard />
<DeepLinkHandler />
@@ -121,6 +152,9 @@ export function AppRouter() {
</Suspense>
</BlobbiActionsProvider>
<Routes>
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
@@ -182,6 +216,8 @@ export function AppRouter() {
}
/>
<Route path="/webxdc" element={<WebxdcFeedPage />} />
<Route path="/articles/new" element={<ArticleEditorPage />} />
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
<Route
path="/articles"
element={
@@ -189,6 +225,7 @@ export function AppRouter() {
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
fabHref="/articles/new"
/>
}
/>
@@ -202,16 +239,7 @@ export function AppRouter() {
/>
}
/>
<Route
path="/emojis"
element={
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
/>
}
/>
<Route path="/emojis" element={<EmojiFeedPage />} />
<Route
path="/development"
element={
@@ -237,6 +265,7 @@ export function AppRouter() {
<Route path="/bluesky" element={<BlueskyPage />} />
<Route path="/wikipedia" element={<WikipediaPage />} />
<Route path="/letters" element={<LettersPage />} />
<Route path="/letters/compose" element={<LetterComposePage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
@@ -249,6 +278,8 @@ export function AppRouter() {
/>
<Route path="/i/*" element={<ExternalContentPage />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
@@ -1,21 +1,18 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Loader2, 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 type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import {
@@ -37,8 +34,8 @@ interface BlobbiActionInventoryModalProps {
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
@@ -49,24 +46,19 @@ export function BlobbiActionInventoryModal({
onOpenChange,
action,
companion,
profile,
profile: _profile,
onUseItem,
onOpenShop,
onOpenShop: _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
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
const availableItems = useMemo(() => {
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
@@ -74,46 +66,9 @@ export function BlobbiActionInventoryModal({
const isEmpty = availableItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
const handleUseItem = (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));
}
onUseItem(item.itemId);
};
return (
@@ -161,14 +116,10 @@ export function BlobbiActionInventoryModal({
<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!
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for this action at your Blobbi's current stage.
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
@@ -181,7 +132,7 @@ export function BlobbiActionInventoryModal({
item={item}
companion={companion}
action={action}
onUse={() => handleSelectItem(item)}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
@@ -190,24 +141,6 @@ export function BlobbiActionInventoryModal({
)}
</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>
);
}
@@ -238,15 +171,12 @@ function BlobbiInventoryUseRow({
// 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(
@@ -255,7 +185,6 @@ function BlobbiInventoryUseRow({
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
@@ -280,16 +209,12 @@ function BlobbiInventoryUseRow({
<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
@@ -308,7 +233,6 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -350,7 +274,6 @@ function BlobbiInventoryUseRow({
{/* 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
@@ -369,7 +292,6 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -393,222 +315,3 @@ function BlobbiInventoryUseRow({
</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>
);
}
@@ -11,7 +11,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
interface BlobbiActionsModalProps {
@@ -1,19 +1,39 @@
// 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)
* Missions modal for Blobbi — card-grid quest board.
*
* Layout:
* 1. Sticky header with title, subtitle, legend help button, close
* 2. Current Focus section (hatch / evolve) — collapsible, default open
* 3. Daily Bounties section — collapsible, default open
* 4. Settings row — low emphasis toggle (not collapsible)
*
* Both main sections use lightweight Radix Collapsible wrappers.
* Collapsed headers still show summary info (progress / coins).
*/
import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
import {
Loader2,
XCircle,
AlertTriangle,
Coins,
X,
Eye,
Scroll,
Compass,
HelpCircle,
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 { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
AlertDialog,
AlertDialogAction,
@@ -24,10 +44,9 @@ import {
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 { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { NostrEvent } from '@nostrify/nostrify';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
@@ -42,36 +61,86 @@ import { useRerollMission } from '../hooks/useRerollMission';
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')[];
showMissionCard?: boolean;
onToggleMissionCard?: (visible: boolean) => void;
}
// ─── Section Chevron ─────────────────────────────────────────────────────────
function SectionChevron({ open }: { open: boolean }) {
return (
<ChevronDown
className={cn(
'size-4 text-muted-foreground/60 transition-transform duration-200',
open && 'rotate-180',
)}
/>
);
}
// ─── Mission Type Legend ──────────────────────────────────────────────────────
function MissionTypeLegend() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="rounded-full p-1.5 opacity-50 hover:opacity-100 hover:bg-muted transition-all"
aria-label="Mission types legend"
>
<HelpCircle className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56 p-3">
<p className="text-xs font-semibold mb-2">Mission Types</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
<Scroll className="size-3 text-amber-500" />
</div>
<div>
<p className="text-xs font-medium">Daily Bounty</p>
<p className="text-[10px] text-muted-foreground">Resets every day</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-sky-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🥚</span>
</div>
<div>
<p className="text-xs font-medium">Hatch Task</p>
<p className="text-[10px] text-muted-foreground">Egg progression</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🐣</span>
</div>
<div>
<p className="text-xs font-medium">Evolve Task</p>
<p className="text-[10px] text-muted-foreground">Baby progression</p>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
// ─── Daily Missions Section ───────────────────────────────────────────────────
@@ -79,14 +148,20 @@ interface BlobbiMissionsModalProps {
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) {
function DailyMissionsSection({
profile,
updateProfileEvent,
availableStages,
disabled,
defaultOpen = true,
}: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayClaimedReward,
@@ -100,58 +175,56 @@ function DailyMissionsSection({ profile, updateProfileEvent, availableStages, di
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
profile,
updateProfileEvent
updateProfileEvent,
);
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const handleClaimReward = (missionId: string) => {
claimReward({ missionId });
};
const handleRerollMission = (missionId: string) => {
rerollMission({ missionId, availableStages });
};
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<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 justify-between py-1 group">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-sm">Daily Missions</h3>
<Scroll className="size-4 text-amber-500 dark:text-amber-400 shrink-0" />
<h3 className="font-semibold text-sm">Daily Bounties</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">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
<span className="tabular-nums">
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
</span>
{claimableCount > 0 && (
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
{claimableCount}
</span>
)}
</div>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
<SectionChevron open={isOpen} />
</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 className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={(id) => claimReward({ missionId: id })}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</div>
</CollapsibleContent>
</Collapsible>
);
@@ -224,9 +297,9 @@ function StopConfirmationDialog({
);
}
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
interface ProcessContentProps {
interface CurrentFocusSectionProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
@@ -238,7 +311,7 @@ interface ProcessContentProps {
defaultOpen?: boolean;
}
function ProcessContent({
function CurrentFocusSection({
companion,
tasks,
processType,
@@ -248,93 +321,98 @@ function ProcessContent({
onStop,
isStopping,
defaultOpen = true,
}: ProcessContentProps) {
}: CurrentFocusSectionProps) {
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 badgeLabel = isIncubation ? 'Hatch' : 'Evolve';
const category = isIncubation ? ('hatch' as const) : ('evolve' as const);
const completedCount = tasks.tasks.filter(t => t.completed).length;
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 */}
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<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 justify-between py-1 group">
<div className="flex items-center gap-2">
<span className="text-lg">{emoji}</span>
<h3 className="font-semibold text-sm">{title}</h3>
<Badge
variant="secondary"
className={cn(
'text-xs font-semibold px-2 py-0.5',
isIncubation
? 'bg-sky-500/15 text-sky-600 dark:text-sky-400'
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
)}
>
{badgeLabel}
</Badge>
<span className="text-sm font-semibold">{title}</span>
</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
className={cn(
'text-xs font-medium tabular-nums',
tasks.allCompleted
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)}
>
{completedCount} / {totalTasks}
</span>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
<SectionChevron open={isOpen} />
</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}
/>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
{/* Task card grid */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onOpenPostModal={onOpenPostModal}
onComplete={onComplete}
isCompleting={isCompleting}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
category={category}
/>
{/* 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>
{/* Stop process — low emphasis */}
<div className="mt-3 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8 px-3"
>
{isStopping ? (
<>
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-3.5 mr-1.5" />
{stopLabel}
</>
)}
</Button>
</div>
</div>
</CollapsibleContent>
{/* Stop Confirmation Dialog */}
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
@@ -347,6 +425,17 @@ function ProcessContent({
);
}
// ─── Empty Focus State ────────────────────────────────────────────────────────
function EmptyFocusState() {
return (
<div className="py-6 text-center">
<Compass className="size-5 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No active progression right now</p>
</div>
);
}
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function BlobbiMissionsModal({
@@ -367,54 +456,46 @@ export function BlobbiMissionsModal({
onStopEvolution,
isStoppingEvolution,
availableStages,
showMissionCard,
onToggleMissionCard,
}: 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>
{/* ── Sticky Header ── */}
<div className="sticky top-0 z-10 bg-background px-4 sm:px-5 pt-4 pb-3 border-b border-border/60">
<div className="flex items-center justify-between">
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight">Missions</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Quests & bounties for {companion.name}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<MissionTypeLegend />
<DialogClose className="rounded-full p-1.5 opacity-60 hover:opacity-100 hover:bg-muted transition-all">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</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>
</div>
{/* 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 && (
{/* ── Scrollable Content ── */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-5 py-4 space-y-5">
{/* 1. Current Focus */}
{hasActiveProcess ? (
<>
{isIncubating && isEgg ? (
<ProcessContent
<CurrentFocusSection
companion={companion}
tasks={hatchTasks}
processType="incubation"
@@ -423,10 +504,9 @@ export function BlobbiMissionsModal({
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
defaultOpen={true}
/>
) : isEvolvingState && isBaby ? (
<ProcessContent
<CurrentFocusSection
companion={companion}
tasks={evolveTasks}
processType="evolution"
@@ -435,10 +515,43 @@ export function BlobbiMissionsModal({
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
defaultOpen={true}
/>
) : null}
</>
) : (
<EmptyFocusState />
)}
{/* Divider */}
<div className="h-px bg-border/60" />
{/* 2. Daily Bounties */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
/>
{/* 3. Settings */}
{onToggleMissionCard !== undefined && showMissionCard !== undefined && (
<>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between py-1">
<Label
htmlFor="mission-card-toggle"
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer"
>
<Eye className="size-3.5" />
Show mission card on main page
</Label>
<Switch
id="mission-card-toggle"
checked={showMissionCard}
onCheckedChange={onToggleMissionCard}
/>
</div>
</>
)}
</div>
</DialogContent>
@@ -30,6 +30,7 @@ import { toast } from '@/hooks/useToast';
import {
BLOBBI_POST_REQUIRED_HASHTAGS,
buildHatchPhrase,
} from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -49,33 +50,13 @@ interface BlobbiPostModalProps {
// ─── 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';
? 'Posting to evolve'
: 'Posting to hatch';
}
// ─── Main Component ───────────────────────────────────────────────────────────
@@ -91,20 +72,19 @@ export function BlobbiPostModal({
const { mutateAsync: createEvent, isPending } = useNostrPublish();
// Compute the required elements based on props
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
const prefix = useMemo(() => buildPrefix(process), [process]);
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
// All required hashtags including the Blobbi name (first)
const allRequiredHashtags = useMemo(() =>
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
[blobbiHashtag]
// The required phrase that must appear in the post
const requiredPhrase = useMemo(() =>
process === 'hatch'
? buildHatchPhrase(blobbiName)
: `${prefix} ${capitalizedName} #blobbi`,
[process, blobbiName, prefix, capitalizedName]
);
// Build default content
const defaultContent = useMemo(() =>
`${prefix} #${allRequiredHashtags.join(' #')}`,
[prefix, allRequiredHashtags]
);
// Build default content (the phrase itself is enough)
const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]);
const [content, setContent] = useState(defaultContent);
const [validationError, setValidationError] = useState<string | null>(null);
@@ -118,24 +98,14 @@ export function BlobbiPostModal({
}, [open, defaultContent]);
/**
* Validate that the content still contains the required prefix and hashtags.
* Validate that the content contains the required phrase.
*/
const validateContent = useCallback((text: string): string | null => {
// Check prefix
if (!text.startsWith(prefix)) {
return 'The post must start with the required text';
if (!text.includes(requiredPhrase)) {
return `The post must contain: "${requiredPhrase}"`;
}
// 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]);
}, [requiredPhrase]);
/**
* Handle content change with validation.
@@ -180,21 +150,26 @@ export function BlobbiPostModal({
}
try {
// Build tags for the post
// Build tags for the post: extract all hashtags from content
const tags: string[][] = [];
const seen = new Set<string>();
// Add all required hashtags as 't' tags
for (const hashtag of allRequiredHashtags) {
tags.push(['t', hashtag.toLowerCase()]);
// Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags
for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) {
const lower = hashtag.toLowerCase();
if (!seen.has(lower)) {
tags.push(['t', lower]);
seen.add(lower);
}
}
// 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) {
// Extract any additional hashtags from the content
const contentHashtags = content.match(/#(\w+)/g) || [];
for (const tag of contentHashtags) {
const tagValue = tag.slice(1).toLowerCase();
if (!requiredLower.includes(tagValue)) {
if (!seen.has(tagValue)) {
tags.push(['t', tagValue]);
seen.add(tagValue);
}
}
@@ -220,7 +195,7 @@ export function BlobbiPostModal({
variant: 'destructive',
});
}
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
const canPost = !validationError && content.trim().length > 0;
@@ -282,13 +257,9 @@ export function BlobbiPostModal({
{/* 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 className="text-xs text-muted-foreground mb-1">Required phrase:</p>
<p className="text-sm font-medium text-primary">
{requiredPhrase}
</p>
</div>
</div>
@@ -1,285 +1,164 @@
/**
* 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)
* DailyMissionsPanel — card-grid layout for daily bounties.
*
* Each mission is a compact card in a 2-col grid.
* Tapping a card expands it to show progress, claim button, and reroll.
* Only one card expanded at a time.
*/
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import {
Check,
Coins,
Gift,
Sparkles,
Egg,
Trophy,
RefreshCw,
Heart,
Utensils,
Droplets,
Moon,
Camera,
Mic,
Music,
Pill,
CircleDot,
} 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 type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
} from './ExpandableMissionCard';
// ─── 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 ─────────────────────────────────────────────────────────────
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
interface MissionItemProps {
mission: DailyMission;
onClaim: () => void;
onReroll?: () => void;
disabled?: boolean;
canReroll?: boolean;
isRerolling?: boolean;
function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
const cls = 'size-5';
switch (action) {
case 'interact':
return <Heart className={cls} />;
case 'feed':
return <Utensils className={cls} />;
case 'clean':
return <Droplets className={cls} />;
case 'sleep':
return <Moon className={cls} />;
case 'take_photo':
return <Camera className={cls} />;
case 'sing':
return <Mic className={cls} />;
case 'play_music':
return <Music className={cls} />;
case 'medicine':
return <Pill className={cls} />;
default:
return <CircleDot className={cls} />;
}
}
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;
// ─── Bonus Card ───────────────────────────────────────────────────────────────
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 {
interface BonusCardProps {
isAvailable: boolean;
isClaimed: boolean;
reward: number;
onClaim: () => void;
disabled?: boolean;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
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'
)}
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isClaimed}
progress={progress}
isExpanded={isExpanded}
onToggle={onToggle}
>
{/* 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>
)}
<MissionDescription>
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions to unlock this bonus'}
</MissionDescription>
{/* 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 className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
+{formatCompactNumber(reward)}
</div>
</div>
{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 h-8 text-xs"
>
<Trophy className="size-3.5 mr-1.5" />
Claim Bonus {formatCompactNumber(reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
}
// ─── No Missions Available State ──────────────────────────────────────────────
// ─── Empty / Done States ──────────────────────────────────────────────────────
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!
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Egg className="size-5 text-muted-foreground/50" />
<div>
<p className="text-sm font-medium">Hatch your Blobbi first</p>
<p className="text-xs text-muted-foreground mt-0.5">
Daily missions unlock after hatching
</p>
</div>
</div>
);
}
// ─── All Claimed State ────────────────────────────────────────────────────────
interface AllClaimedStateProps {
todayCoins: number;
}
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
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!
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-5 text-primary/60" />
<div>
<p className="text-sm font-medium">All done for today</p>
<p className="text-xs text-muted-foreground mt-0.5">
Earned{' '}
<span className="font-medium text-amber-600 dark:text-amber-400">
{formatCompactNumber(todayCoins)} coins
</span>{' '}
come back tomorrow!
</p>
</div>
</div>
@@ -288,20 +167,17 @@ function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
// ─── Reroll Counter ───────────────────────────────────────────────────────────
interface RerollCounterProps {
remaining: number;
}
function RerollCounter({ remaining }: { remaining: number }) {
const text =
remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
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" />
<div className="flex items-center justify-end gap-1 text-[11px] text-muted-foreground col-span-full">
<RefreshCw className="size-2.5" />
<span>{text}</span>
</div>
);
@@ -322,48 +198,121 @@ export function DailyMissionsPanel({
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
// Show empty state if user has no eligible missions (e.g., only eggs)
if (noMissionsAvailable) {
return <NoMissionsState />;
}
const [expandedId, setExpandedId] = useState<string | null>(null);
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} />;
}
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
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
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{/* Reroll counter */}
{onRerollMission && <RerollCounter remaining={rerollsRemaining} />}
{/* Regular mission cards */}
{missions.map((mission) => {
const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
const canClaim = mission.completed && !mission.claimed;
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
return (
<ExpandableMissionCard
key={mission.id}
id={mission.id}
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.claimed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
{/* Description */}
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.claimed && (
<MissionProgress
current={mission.currentCount}
required={mission.requiredCount}
completed={mission.completed}
/>
)}
{/* Reward + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
{formatCompactNumber(mission.reward)}
</span>
{showReroll && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onRerollMission(mission.id);
}}
disabled={disabled || isRerolling}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
>
<RefreshCw className={cn('size-3', isRerolling && 'animate-spin')} />
</button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{mission.claimed && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
</span>
)}
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onClaimReward(mission.id);
}}
disabled={disabled}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
>
<Gift className="size-3.5 mr-1.5" />
Claim {formatCompactNumber(mission.reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
})}
{/* Bonus card */}
<BonusCard
isAvailable={bonusAvailable}
isClaimed={bonusClaimed}
reward={bonusReward}
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
disabled={disabled}
isExpanded={expandedId === 'bonus'}
onToggle={handleToggle}
/>
</div>
);
@@ -0,0 +1,250 @@
// src/blobbi/actions/components/ExpandableMissionCard.tsx
/**
* Expandable mission card for the quest-board grid.
*
* Collapsed: compact square-ish card showing icon, title, and a tiny
* progress ring / checkmark.
* Expanded: full-width row that reveals description, progress bar,
* action link, claim button, dynamic hints, etc.
*
* Only one card is expanded at a time per section (controlled by parent).
*/
import type { ReactNode } from 'react';
import { Check, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export type MissionCategory = 'daily' | 'hatch' | 'evolve';
export interface ExpandableMissionCardProps {
/** Unique id used to track which card is expanded */
id: string;
/** Mission category for visual styling */
category: MissionCategory;
/** Icon rendered in the compact card (ReactNode — usually a lucide icon or emoji span) */
icon: ReactNode;
/** Short title */
title: string;
/** Whether the mission is complete */
completed: boolean;
/** Progress fraction 0-1 (used for the tiny ring in compact mode) */
progress: number;
/** Whether this card is currently expanded */
isExpanded: boolean;
/** Parent calls this to toggle expansion */
onToggle: (id: string) => void;
/** Content rendered only when expanded */
children: ReactNode;
/** Optional extra className on the outer wrapper */
className?: string;
}
// ─── Tiny Progress Ring ───────────────────────────────────────────────────────
function ProgressRing({ progress, completed, category }: { progress: number; completed: boolean; category: MissionCategory }) {
const size = 28;
const stroke = 2.5;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - progress * circumference;
if (completed) {
return (
<div className="size-7 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
</div>
);
}
const ringColor =
category === 'hatch'
? 'text-sky-500'
: category === 'evolve'
? 'text-violet-500'
: 'text-amber-500';
return (
<svg width={size} height={size} className={cn('shrink-0 -rotate-90', ringColor)}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
opacity={0.15}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
);
}
// ─── Accent colors per category ───────────────────────────────────────────────
const CATEGORY_STYLES: Record<MissionCategory, { bg: string; expandedBg: string; border: string }> = {
daily: {
bg: 'bg-amber-500/[0.06] hover:bg-amber-500/10',
expandedBg: 'bg-amber-500/[0.06]',
border: 'ring-amber-500/20',
},
hatch: {
bg: 'bg-sky-500/[0.06] hover:bg-sky-500/10',
expandedBg: 'bg-sky-500/[0.06]',
border: 'ring-sky-500/20',
},
evolve: {
bg: 'bg-violet-500/[0.06] hover:bg-violet-500/10',
expandedBg: 'bg-violet-500/[0.06]',
border: 'ring-violet-500/20',
},
};
// ─── Component ────────────────────────────────────────────────────────────────
export function ExpandableMissionCard({
id,
category,
icon,
title,
completed,
progress,
isExpanded,
onToggle,
children,
className,
}: ExpandableMissionCardProps) {
const styles = CATEGORY_STYLES[category];
// ── Collapsed card ──
if (!isExpanded) {
return (
<button
type="button"
onClick={() => onToggle(id)}
className={cn(
'flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all text-center cursor-pointer select-none',
'ring-1 ring-transparent',
completed ? 'bg-emerald-500/[0.06] hover:bg-emerald-500/10' : styles.bg,
className,
)}
>
{/* Icon */}
<div className="text-lg leading-none">{icon}</div>
{/* Title — 2 lines max */}
<span className={cn(
'text-[11px] font-medium leading-tight line-clamp-2 min-h-[2lh]',
completed && 'text-emerald-600 dark:text-emerald-400',
)}>
{title}
</span>
{/* Progress ring / check */}
<ProgressRing progress={progress} completed={completed} category={category} />
</button>
);
}
// ── Expanded card (spans full row) ──
return (
<div
className={cn(
'col-span-full rounded-xl ring-1 transition-all overflow-hidden',
completed ? 'bg-emerald-500/[0.06] ring-emerald-500/20' : cn(styles.expandedBg, styles.border),
className,
)}
>
{/* Compact header — click to collapse */}
<button
type="button"
onClick={() => onToggle(id)}
className="w-full flex items-center gap-3 p-3 text-left cursor-pointer select-none"
>
<div className="text-lg leading-none shrink-0">{icon}</div>
<span className={cn(
'text-sm font-medium flex-1 min-w-0',
completed && 'text-emerald-600 dark:text-emerald-400',
)}>
{title}
</span>
<ProgressRing progress={progress} completed={completed} category={category} />
</button>
{/* Expanded details */}
<div className="px-3 pb-3 pt-0 space-y-2">
{children}
</div>
</div>
);
}
// ─── Shared detail sub-components ─────────────────────────────────────────────
/** Description text */
export function MissionDescription({ children }: { children: ReactNode }) {
return <p className="text-xs text-muted-foreground leading-snug">{children}</p>;
}
/** Progress bar with fraction label */
export function MissionProgress({ current, required, completed }: { current: number; required: number; completed: boolean }) {
const pct = required > 0 ? Math.round((current / required) * 100) : 0;
return (
<div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
<span className="tabular-nums">{current} / {required}</span>
<span className="tabular-nums">{pct}%</span>
</div>
<Progress value={pct} className={cn('h-1.5', completed && '[&>div]:bg-emerald-500')} />
</div>
);
}
/** Inline action link (navigate, external, modal) */
export function MissionAction({
label,
type,
onClick,
}: {
label: string;
type: 'navigate' | 'external_link' | 'open_modal';
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{label}
{type === 'external_link' ? (
<ExternalLink className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</button>
);
}
/** Dynamic / live task hint */
export function DynamicHint({ current, required }: { current: number; required: number }) {
return (
<div className="flex items-center gap-1.5 text-[11px] text-amber-600/80 dark:text-amber-400/80">
<AlertCircle className="size-3 shrink-0" />
<span>Lowest stat: {current}% (need {required}%+)</span>
</div>
);
}
@@ -98,13 +98,6 @@ export function InlineSingCard({
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, []);
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
@@ -138,6 +131,13 @@ export function InlineSingCard({
}
}, [audioUrl, cleanupPlayback]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, [cleanupAll]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
+16 -16
View File
@@ -82,22 +82,6 @@ export function SingModal({
// 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) {
@@ -142,6 +126,22 @@ export function SingModal({
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open, cleanup, resetRecording]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
@@ -22,7 +22,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -26,7 +26,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
+149 -217
View File
@@ -1,22 +1,38 @@
// 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.
* Card-grid presentation for hatch / evolve tasks.
*
* Each task is a compact card in a 2-column grid.
* Tapping a card expands it inline (full row) to reveal details.
* Only one card is expanded at a time.
*/
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Palette,
Droplets,
MessageSquare,
Heart,
UserPen,
Activity,
Loader2,
HelpCircle,
} from 'lucide-react';
import { openUrl } from '@/lib/downloadFile';
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';
import type { MissionCategory } from './ExpandableMissionCard';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
MissionAction,
DynamicHint,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -24,149 +40,38 @@ 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;
/** Mission category for styling the cards */
category?: MissionCategory;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
/** Map task ids to lucide icons. Falls back to a generic icon. */
function TaskIcon({ taskId }: { taskId: string }) {
const iconClass = 'size-5';
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':
openUrl(task.actionTarget);
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>
);
switch (taskId) {
case 'create_themes':
return <Palette className={iconClass} />;
case 'color_moments':
return <Droplets className={iconClass} />;
case 'create_posts':
return <MessageSquare className={iconClass} />;
case 'interactions':
return <Heart className={iconClass} />;
case 'edit_profile':
return <UserPen className={iconClass} />;
case 'maintain_stats':
return <Activity className={iconClass} />;
default:
return <HelpCircle className={iconClass} />;
}
}
// ─── Main Component ───────────────────────────────────────────────────────────
@@ -178,86 +83,113 @@ export function TasksPanel({
onOpenPostModal,
onComplete,
isCompleting = false,
emoji,
title,
description,
completeLabel,
completingLabel,
completeEmoji,
category = 'hatch',
}: TasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
const [expandedId, setExpandedId] = useState<string | null>(null);
const navigate = useNavigate();
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
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>
<div className="space-y-3">
{/* Card grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{tasks.map((task) => {
const isDynamic = task.type === 'dynamic';
const progress =
task.required > 0 ? task.current / task.required : task.completed ? 1 : 0;
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') onOpenPostModal();
break;
}
};
return (
<ExpandableMissionCard
key={task.id}
id={task.id}
category={category}
icon={<TaskIcon taskId={task.id} />}
title={task.name}
completed={task.completed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === task.id}
onToggle={handleToggle}
>
{/* Expanded content */}
<MissionDescription>{task.description}</MissionDescription>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !isDynamic && (
<MissionProgress
current={task.current}
required={task.required}
completed={task.completed}
/>
)}
{/* Dynamic stat hint */}
{isDynamic && !task.completed && (
<DynamicHint current={task.current} required={task.required} />
)}
{/* Action link */}
{task.action && task.actionLabel && !task.completed && (
<MissionAction
label={task.actionLabel}
type={task.action}
onClick={handleAction}
/>
)}
</ExpandableMissionCard>
);
})}
</div>
{/* CTA button when all tasks are done */}
{allCompleted && (
<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 shadow-sm"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-lg">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
)}
</div>
);
}
@@ -17,7 +17,7 @@
*/
import { useMemo } from 'react';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
import type { EvolveTasksResult } from './useEvolveTasks';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
@@ -168,7 +168,7 @@ export function useActiveTaskProcess(
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = activeResult?.tasks ?? [];
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
@@ -17,14 +17,20 @@
*/
import { useCallback, useRef } from 'react';
import { useNostr } from '@nostrify/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 type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
@@ -34,8 +40,6 @@ export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
export interface CareActivityResult {
@@ -59,8 +63,8 @@ export interface CareActivityResult {
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiCareActivityParams) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -78,12 +82,24 @@ export function useBlobbiCareActivity({
throw new Error('No companion available');
}
// Fetch fresh companion from relays (read-modify-write pattern)
const freshEvents = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [companion.d],
}]);
const freshCompanion = freshEvents
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at)
.map(e => parseBlobbiEvent(e))
.find(Boolean) ?? companion;
const now = new Date();
// Calculate what the streak update should be
// Calculate what the streak update should be using fresh data
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
freshCompanion.careStreak,
freshCompanion.careStreakLastDay,
now
);
@@ -96,29 +112,29 @@ export function useBlobbiCareActivity({
};
}
// Get the tag updates
const streakUpdates = getStreakTagUpdates(companion, now);
// Get the tag updates using fresh data
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
newStreak: freshCompanion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
// Build updated tags from fresh data
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
content: freshCompanion.event.content,
tags: updatedTags,
});
// Update local cache
// Update local cache (optimistic — no invalidation needed)
updateCompanionEvent(event);
// Update session tracker
@@ -128,9 +144,9 @@ export function useBlobbiCareActivity({
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: companion.careStreak,
previousStreak: freshCompanion.careStreak,
newStreak: result.newStreak,
lastDay: companion.careStreakLastDay,
lastDay: freshCompanion.careStreakLastDay,
newDay: result.newLastDay,
});
}
@@ -141,11 +157,6 @@ export function useBlobbiCareActivity({
action: result.action,
};
},
onSuccess: (result) => {
if (result.wasUpdated) {
invalidateCompanion();
}
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
@@ -6,12 +6,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import {
clampStat,
applyStat,
@@ -22,6 +22,7 @@ import {
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
@@ -50,6 +51,8 @@ export interface DirectActionRequest {
export interface DirectActionResult {
action: DirectAction;
happinessChange: number;
xpGained: number;
newXP: number;
}
/**
@@ -66,15 +69,11 @@ export interface UseBlobbiDirectActionParams {
} | 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.
* Direct actions (play_music, sing) don't require selecting an item.
* They directly affect happiness stat.
*
* This hook:
@@ -89,8 +88,6 @@ export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -129,6 +126,9 @@ export function useBlobbiDirectAction({
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
// Track if happiness actually changed
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
// Build stats update
const isEgg = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {
@@ -161,9 +161,16 @@ export function useBlobbiDirectAction({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (ONLY if happiness actually changed) ───
// Direct actions modify happiness. Only grant XP if happiness actually increased.
const xpGained = happinessChanged ? calculateActionXP(action) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
@@ -176,22 +183,19 @@ export function useBlobbiDirectAction({
updateCompanionEvent(blobbiEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
action,
happinessChange: happinessDelta,
xpGained,
newXP,
};
},
onSuccess: ({ action, happinessChange }) => {
onSuccess: ({ action, happinessChange, xpGained }) => {
const actionMeta = DIRECT_ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} complete!`,
description: `Your Blobbi's happiness increased by ${happinessChange}!`,
description: `Your Blobbi's happiness increased by ${happinessChange}! ${xpText}`,
});
// Track daily mission progress
@@ -21,12 +21,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -62,14 +62,10 @@ export interface UseStartIncubationParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/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;
}
/**
@@ -112,8 +108,6 @@ export function useStartIncubation({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -269,12 +263,6 @@ export function useStartIncubation({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -325,14 +313,10 @@ export interface UseStopIncubationParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/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;
}
/**
@@ -363,8 +347,6 @@ export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -435,12 +417,6 @@ export function useStopIncubation({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -476,14 +452,10 @@ export interface UseStartEvolutionParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/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;
}
/**
@@ -511,8 +483,6 @@ export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -585,12 +555,6 @@ export function useStartEvolution({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -627,14 +591,10 @@ export interface UseStopEvolutionParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/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;
}
/**
@@ -665,8 +625,6 @@ export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -736,12 +694,6 @@ export function useStopEvolution({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -780,14 +732,10 @@ export interface UseSyncTaskCompletionsParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
/**
@@ -827,8 +775,6 @@ export function useSyncTaskCompletions({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseSyncTaskCompletionsParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -923,11 +869,6 @@ export function useSyncTaskCompletions({
});
updateCompanionEvent(event);
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Published successfully:', tagsToAdd);
@@ -19,14 +19,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
STAT_MAX,
updateBlobbiTags,
DEFAULT_EGG_STATS,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/lib/blobbi-tag-schema';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
// ─── Content Helpers ──────────────────────────────────────────────────────────
@@ -56,7 +56,7 @@ export interface CanonicalActionResult {
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
}
/**
@@ -69,10 +69,6 @@ export interface UseBlobbiStageTransitionParams {
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;
}
/**
@@ -113,8 +109,6 @@ export function useBlobbiHatch({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -157,14 +151,13 @@ export function useBlobbiHatch({
});
// ─── Calculate Baby Stats ───
// Baby inherits the decayed health from the egg
// Other stats start fresh at 100 for the new life stage
// All stats reset to 100 when hatching — the baby starts fresh
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
hunger: STAT_MAX,
happiness: STAT_MAX,
health: STAT_MAX,
hygiene: STAT_MAX,
energy: STAT_MAX,
};
// ─── Build Updated Tags ───
@@ -221,12 +214,6 @@ export function useBlobbiHatch({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'egg',
@@ -269,8 +256,6 @@ export function useBlobbiEvolve({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -377,12 +362,6 @@ export function useBlobbiEvolve({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'baby',
@@ -6,19 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
getStageRestrictionMessage,
clampStat,
@@ -32,27 +28,27 @@ import {
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an inventory item
* Request payload for using an item on a Blobbi companion
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an inventory item
* Result of using an item on a Blobbi companion
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
}
/**
@@ -67,50 +63,44 @@ export interface UseBlobbiUseInventoryItemParams {
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration (use instead of profile.allTags) */
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
profileStorage: import('@/lib/blobbi').StorageItem[];
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/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.
* Hook to use an item on a Blobbi companion.
*
* Items are reusable abilities sourced from the shop catalog — no
* inventory ownership or quantity is required.
*
* 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
* 1. Validates the companion and item compatibility
* 2. Ensures canonical format before action
* 3. Applies accumulated decay, then item effects to Blobbi stats
* 4. Updates Blobbi state (kind 31124)
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
invalidateCompanion,
invalidateProfile,
updateProfileEvent: _updateProfileEvent,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -124,11 +114,6 @@ export function useBlobbiUseInventoryItem({
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);
@@ -141,15 +126,6 @@ export function useBlobbiUseInventoryItem({
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');
@@ -186,54 +162,51 @@ export function useBlobbiUseInventoryItem({
// 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
// ─── Validate Play Energy Requirements ───
// For play actions, validate the Blobbi has enough energy AFTER decay
if (action === 'play') {
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
const currentEnergy = statsAfterDecay.energy;
if (energyCost > 0 && currentEnergy < energyCost) {
throw new Error(
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
);
}
// Also check if playing would have any effect at all
// If happiness is maxed AND we can't spend energy, playing is pointless
const happinessGain = shopItem.effect.happiness ?? 0;
const currentHappiness = statsAfterDecay.happiness;
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
if (!wouldGainHappiness && !wouldSpendEnergy) {
throw new Error(
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
);
}
}
// ─── Apply Item Effects (single use) ───
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);
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, 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);
}
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -244,18 +217,12 @@ export function useBlobbiUseInventoryItem({
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);
}
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -288,9 +255,15 @@ export function useBlobbiUseInventoryItem({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain ───
const xpGained = calculateInventoryActionXP(action, 1);
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
@@ -303,42 +276,25 @@ export function useBlobbiUseInventoryItem({
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();
// Items are free to use — no storage decrement needed.
// No query invalidation needed — the optimistic update above keeps the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
return {
itemName: shopItem.name,
action,
quantity,
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity }) => {
onSuccess: ({ itemName, action, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName}${quantityText} on your Blobbi.`,
description: `Used ${itemName} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
@@ -14,11 +14,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import {
type DailyMissionsState,
getTodayDateString,
+20 -19
View File
@@ -15,7 +15,7 @@ import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_THEME_DEFINITION,
@@ -30,8 +30,8 @@ import {
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for wall edit events */
export const KIND_WALL_EDIT = 16769;
/** Kind for custom profile tabs event */
export const KIND_PROFILE_TABS = 16769;
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
@@ -117,7 +117,7 @@ export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolea
* 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)
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 6. Maintain All Stats >= 80
@@ -165,14 +165,14 @@ export function useEvolveTasks(
since: stateStartedAt,
limit: 50, // Only need 1 valid evolve post
},
// Wall edits after start
// Custom profile tabs after start
{
kinds: [KIND_WALL_EDIT],
kinds: [KIND_PROFILE_TABS],
authors: [pubkey],
since: stateStartedAt,
limit: 1, // Only need 1
},
// Profile metadata after start (for Blobbi shape check)
// Profile metadata after start (for Blobbi shape check + profile edit mission)
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
@@ -197,8 +197,8 @@ export function useEvolveTasks(
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
const wallEditEvents = events.filter(e =>
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
const profileTabsEvents = events.filter(e =>
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
);
// Get latest profile after start
@@ -211,7 +211,7 @@ export function useEvolveTasks(
themeEvents,
colorMomentEvents,
postEvents,
wallEditEvents,
profileTabsEvents,
profileAfter,
};
},
@@ -287,20 +287,21 @@ export function useEvolveTasks(
// No action - just interact with Blobbi
});
// 5. Edit Wall once (PERSISTENT)
const wallEditCount = data?.wallEditEvents?.length ?? 0;
const hasWallEdit = wallEditCount >= 1;
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
const hasMetadataEdit = !!data?.profileAfter;
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
tasks.push({
id: 'edit_wall',
name: 'Edit Your Wall',
description: 'Customize your profile wall',
current: hasWallEdit ? 1 : 0,
id: 'edit_profile',
name: 'Edit Your Profile',
description: 'Update your profile info or customize your profile tabs',
current: hasProfileEdit ? 1 : 0,
required: 1,
completed: hasWallEdit,
completed: hasProfileEdit,
type: 'persistent',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Wall',
actionLabel: 'Edit Profile',
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
+26 -48
View File
@@ -5,10 +5,11 @@
*
* 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.
*
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
*/
import { useQuery } from '@tanstack/react-query';
@@ -16,7 +17,7 @@ import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -33,13 +34,10 @@ export const KIND_SHORT_TEXT_NOTE = 1;
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'];
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
/** 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;
/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
// Legacy export for backwards compatibility
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
@@ -112,16 +110,28 @@ export interface HatchTasksResult {
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Build the required phrase for a hatch post.
* Format: "Posting to hatch {CapitalizedName} #blobbi"
*/
export function buildHatchPhrase(blobbiName: string): string {
const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`;
}
/**
* Check if a post is a valid Blobbi hatch post.
* Must contain the required prefix and all required hashtags including the Blobbi name.
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
* The user may add extra text before or after it.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
* @param blobbiName - The Blobbi's name
*/
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with prefix
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
const phrase = buildHatchPhrase(blobbiName);
// The phrase must appear somewhere in the content
if (!event.content.includes(phrase)) {
return false;
}
@@ -130,18 +140,12 @@ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
// All required hashtags must be present as t tags
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);
return hasRequiredHashtags;
}
// Legacy function name for backwards compatibility
@@ -158,8 +162,8 @@ export const isValidBlobbiPost = isValidHatchPost;
* 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
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
*
* @param companion - The Blobbi companion (must be incubating)
* @param interactionCount - Current interaction count from companion tasks cache
@@ -323,32 +327,6 @@ export function useHatchTasks(
// 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');
+2 -5
View File
@@ -55,12 +55,9 @@ export {
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,
@@ -71,7 +68,7 @@ export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_WALL_EDIT,
KIND_PROFILE_TABS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
@@ -111,7 +108,7 @@ export {
} from './lib/blobbi-activity-state';
// Re-export stat bounds from canonical source
export { STAT_MIN, STAT_MAX } from '@/lib/blobbi';
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
// Utilities
export {
+23 -30
View File
@@ -1,24 +1,24 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/lib/blobbi';
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Actions that consume inventory items
* Item-based care actions (use a shop catalog item on the companion)
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Non-inventory actions that don't consume items
* These actions affect stats directly without using shop items.
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (inventory + direct)
* All Blobbi actions (item-based + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
};
/**
* Action metadata for UI display (inventory actions)
* Action metadata for UI display (item-based care actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
@@ -59,7 +59,7 @@ export const ACTION_METADATA: Record<InventoryAction, { label: string; descripti
};
/**
* Action metadata for direct actions (non-inventory)
* Action metadata for direct actions (no item required)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
@@ -217,11 +217,6 @@ export function canUseItemForStage(
return { canUse: true };
}
// Accessories are disabled
if (shopItem.type === 'accessory') {
return { canUse: false, reason: 'Accessories are not usable yet' };
}
return { canUse: true };
}
@@ -275,10 +270,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Inventory Helpers ────────────────────────────────────────────────────────
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved inventory item with shop metadata
* Resolved catalog item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
@@ -290,7 +285,7 @@ export interface ResolvedInventoryItem {
}
/**
* Options for filtering inventory by action
* Options for filtering catalog items by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
@@ -298,8 +293,8 @@ export interface FilterInventoryOptions {
}
/**
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
@@ -309,22 +304,20 @@ export interface FilterInventoryOptions {
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
storage: StorageItem[],
_storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
const allItems = getLiveShopItems();
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
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) {
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
@@ -339,8 +332,8 @@ export function filterInventoryByAction(
}
result.push({
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
@@ -381,7 +374,7 @@ export function decrementStorageItem(
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general inventory items (food, toys, hygiene)
* Stages that can use general items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
@@ -414,14 +407,14 @@ export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_m
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific inventory action.
* Check if a companion can use a specific item 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.
// All stages can technically use all item actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
@@ -447,7 +440,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
}
/**
* Check if a companion can use general inventory items (feed, play, clean).
* Check if a companion can use general items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
+1 -1
View File
@@ -21,7 +21,7 @@ import {
getLocalDayString,
getDaysDifference,
type BlobbiCompanion,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
calculateActionXP,
calculateInventoryActionXP,
applyXPGain,
getXPGainSummary,
formatXPGain,
getXPGainMessage,
ACTION_XP,
INVENTORY_ACTION_XP,
DIRECT_ACTION_XP,
} from './blobbi-xp';
describe('calculateActionXP', () => {
it('returns the correct XP for each inventory action', () => {
expect(calculateActionXP('feed')).toBe(5);
expect(calculateActionXP('play')).toBe(8);
expect(calculateActionXP('clean')).toBe(6);
expect(calculateActionXP('medicine')).toBe(10);
});
it('returns the correct XP for each direct action', () => {
expect(calculateActionXP('play_music')).toBe(7);
expect(calculateActionXP('sing')).toBe(9);
});
it('returns 0 for an unknown action', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(calculateActionXP('unknown' as any)).toBe(0);
});
});
describe('calculateInventoryActionXP', () => {
it('returns base XP for quantity 1', () => {
expect(calculateInventoryActionXP('feed', 1)).toBe(5);
expect(calculateInventoryActionXP('medicine', 1)).toBe(10);
});
it('multiplies XP by quantity', () => {
expect(calculateInventoryActionXP('feed', 3)).toBe(15);
expect(calculateInventoryActionXP('play', 5)).toBe(40);
});
it('defaults to quantity 1 when not specified', () => {
expect(calculateInventoryActionXP('clean')).toBe(6);
});
it('returns 0 for quantity less than 1', () => {
expect(calculateInventoryActionXP('feed', 0)).toBe(0);
expect(calculateInventoryActionXP('feed', -1)).toBe(0);
});
});
describe('applyXPGain', () => {
it('adds XP to a current value', () => {
expect(applyXPGain(100, 25)).toBe(125);
});
it('treats undefined current XP as 0', () => {
expect(applyXPGain(undefined, 10)).toBe(10);
});
it('never returns a negative value', () => {
expect(applyXPGain(5, -20)).toBe(0);
expect(applyXPGain(0, -1)).toBe(0);
});
it('handles zero XP gain', () => {
expect(applyXPGain(50, 0)).toBe(50);
});
});
describe('getXPGainSummary', () => {
it('returns the correct xpGained and quantity', () => {
const result = getXPGainSummary('feed', 3);
expect(result).toEqual({ xpGained: 15, quantity: 3 });
});
it('defaults quantity to 1', () => {
const result = getXPGainSummary('sing');
expect(result).toEqual({ xpGained: 9, quantity: 1 });
});
});
describe('formatXPGain', () => {
it('formats positive XP as "+N XP"', () => {
expect(formatXPGain(15)).toBe('+15 XP');
expect(formatXPGain(1)).toBe('+1 XP');
});
it('returns empty string for zero or negative XP', () => {
expect(formatXPGain(0)).toBe('');
expect(formatXPGain(-5)).toBe('');
});
});
describe('getXPGainMessage', () => {
it('formats a message with action and XP earned', () => {
expect(getXPGainMessage('feed', 5)).toBe('+5 XP earned!');
});
it('includes total when provided', () => {
expect(getXPGainMessage('feed', 5, 105)).toBe('+5 XP earned! Total: 105 XP');
});
it('returns empty string for zero or negative XP', () => {
expect(getXPGainMessage('feed', 0)).toBe('');
expect(getXPGainMessage('feed', -1)).toBe('');
});
});
describe('XP constants', () => {
it('ACTION_XP contains all inventory and direct actions', () => {
for (const action of Object.keys(INVENTORY_ACTION_XP)) {
expect(ACTION_XP).toHaveProperty(action);
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
INVENTORY_ACTION_XP[action as keyof typeof INVENTORY_ACTION_XP],
);
}
for (const action of Object.keys(DIRECT_ACTION_XP)) {
expect(ACTION_XP).toHaveProperty(action);
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
DIRECT_ACTION_XP[action as keyof typeof DIRECT_ACTION_XP],
);
}
});
it('all XP values are positive integers', () => {
for (const xp of Object.values(ACTION_XP)) {
expect(xp).toBeGreaterThan(0);
expect(Number.isInteger(xp)).toBe(true);
}
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Blobbi XP (Experience Points) System
*
* This module defines XP values for all Blobbi care actions and provides
* utilities for calculating and applying XP gains.
*
* Design Philosophy:
* - Different actions award different XP to reflect their complexity/value
* - XP values are balanced to encourage variety in care activities
* - Item actions (feed, play, clean, medicine) give varied XP per action type
* - Direct actions (sing, play_music) give moderate XP
* - XP accumulates across all life stages and never resets
*/
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for item-based care actions (feed, play, clean, medicine).
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is critical - highest item XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't require selecting an item.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
sing: 9, // Singing requires more user effort - higher XP
};
/**
* Combined XP lookup for all action types.
* Use this for a unified XP calculation interface.
*/
export const ACTION_XP: Record<BlobbiAction, number> = {
...INVENTORY_ACTION_XP,
...DIRECT_ACTION_XP,
};
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
/**
* Calculate XP gain for a single action.
*
* @param action - The action performed
* @returns XP points earned
*/
export function calculateActionXP(action: BlobbiAction): number {
return ACTION_XP[action] ?? 0;
}
/**
* Calculate XP gain for an item-based care action.
*
* @param action - The action performed
* @param quantity - Number of times performed (always 1 in current usage)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
if (quantity < 1) return 0;
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
return baseXP * quantity;
}
/**
* Apply XP gain to current experience value.
*
* @param currentXP - Current experience points (undefined = 0)
* @param xpGain - XP points to add
* @returns New total XP (never negative)
*/
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
const current = currentXP ?? 0;
const newXP = current + xpGain;
return Math.max(0, newXP);
}
/**
* Get XP gain summary for displaying to the user.
*
* @param action - The action performed
* @param quantity - Number of times the action was performed (always 1 in current usage)
* @returns Object with xpGained and quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
quantity: number = 1
): { xpGained: number; quantity: number } {
const baseXP = ACTION_XP[action] ?? 0;
const xpGained = baseXP * quantity;
return { xpGained, quantity };
}
// ─── XP Display Utilities ─────────────────────────────────────────────────────
/**
* Format XP gain for display in toasts/notifications.
*
* @param xpGained - Amount of XP gained
* @returns Formatted string like "+15 XP"
*/
export function formatXPGain(xpGained: number): string {
if (xpGained <= 0) return '';
return `+${xpGained} XP`;
}
/**
* Get a descriptive message about XP gain.
*
* @param action - The action that earned XP
* @param xpGained - Amount of XP gained
* @param newTotal - New total XP (optional, for "You now have X XP" message)
* @returns Formatted message for user feedback
*/
export function getXPGainMessage(
action: BlobbiAction,
xpGained: number,
newTotal?: number
): string {
if (xpGained <= 0) return '';
const xpText = formatXPGain(xpGained);
if (newTotal !== undefined) {
return `${xpText} earned! Total: ${newTotal} XP`;
}
return `${xpText} earned!`;
}
@@ -1,58 +1,19 @@
/**
* 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.
*
* Uses shared utilities from blobbi/ui/lib/svg for common operations.
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { lightenColor, darkenColor, uniquifySvgIds, ensureSvgFillsContainer } from '@/blobbi/ui/lib/svg';
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 ────────────────────────────────────────────────────────
/**
@@ -610,77 +571,6 @@ export function customizeAdultSvg(
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
*/
@@ -5,7 +5,7 @@
* Each adult form has its own folder with base and sleeping variants.
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import {
type AdultForm,
type AdultSvgResolverOptions,
+1 -1
View File
@@ -4,7 +4,7 @@
* Type definitions for adult stage visuals and customization
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
/**
* All available adult evolution forms.
@@ -1,35 +1,14 @@
/**
* Baby Blobbi SVG Customizer
*
* Handles applying colors and customizations to baby SVG content
*
* Handles applying colors and customizations to baby SVG content.
* Uses shared utilities from blobbi/ui/lib/svg for common operations.
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
import { lightenColor, uniquifySvgIds, ensureSvgFillsContainer } from '@/blobbi/ui/lib/svg';
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
*
@@ -78,79 +57,6 @@ export function customizeBabySvg(
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
*/
@@ -4,7 +4,7 @@
* Handles loading and resolving baby stage SVG assets
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
import { BabyVariant, BabySvgResolverOptions } from '../types/baby.types';
import { BABY_BASE_SVG, BABY_SLEEPING_SVG } from './baby-svg-data';
+1 -1
View File
@@ -4,7 +4,7 @@
* Type definitions for baby stage visuals and customization
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
/**
* Baby visual variant types
@@ -19,6 +19,7 @@ import type {
Position,
EntryState,
} from '../types/companion.types';
import type { RefObject } from 'react';
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
import {
calculateFloatAnimation,
@@ -28,6 +29,9 @@ import {
} from '../utils/animation';
import { BlobbiCompanionVisual } from './BlobbiCompanionVisual';
import { useClickDetection } from '../interaction';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
interface BlobbiCompanionProps {
/** Companion data */
@@ -36,8 +40,8 @@ interface BlobbiCompanionProps {
state: CompanionState;
/** Current motion state */
motion: CompanionMotion;
/** Eye offset for gaze */
eyeOffset: EyeOffset;
/** Ref-based eye offset for imperative gaze control (avoids per-frame rerenders) */
eyeOffsetRef: RefObject<EyeOffset>;
/** Whether entry animation is playing */
isEntering: boolean;
/** Entry animation progress (0-1) */
@@ -58,6 +62,17 @@ interface BlobbiCompanionProps {
onEndDrag: () => void;
/** Click callback (when interaction is a click, not a drag) */
onClick?: () => void;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (CSS class names). */
recipeLabel?: string;
/** Named emotion preset (convenience). Ignored when `recipe` is provided. */
emotion?: BlobbiEmotion;
/**
* Body-level visual effects — for manual/external use only.
* Status-reaction body effects are already folded into the recipe.
*/
bodyEffects?: BodyEffectsSpec;
/** Callback to report rendered position (including animations) */
onPositionUpdate?: (position: Position) => void;
/** Debug mode - disables animations and shows visual debug aids */
@@ -68,7 +83,7 @@ export function BlobbiCompanion({
companion,
state,
motion,
eyeOffset,
eyeOffsetRef,
isEntering,
entryProgress: _entryProgress,
entryState,
@@ -79,13 +94,17 @@ export function BlobbiCompanion({
onUpdateDrag,
onEndDrag,
onClick,
recipe,
recipeLabel,
emotion,
bodyEffects,
onPositionUpdate,
debugMode = false,
}: BlobbiCompanionProps) {
const config = DEFAULT_COMPANION_CONFIG;
const containerRef = useRef<HTMLDivElement>(null);
const [animationTime, setAnimationTime] = useState(0);
// Click detection - distinguishes click from drag
const clickDetection = useClickDetection({
onClick,
@@ -174,8 +193,9 @@ export function BlobbiCompanion({
}
// Calculate floating animation offset (gentle sway/float)
// Skip during entry animation, dragging, or debug mode
const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode)
// Skip during entry animation, dragging, debug mode, or sleeping
const isSleeping = companion.state === 'sleeping';
const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode && !isSleeping)
? calculateFloatAnimation(animationTime, state === 'walking')
: { x: 0, y: 0, rotation: 0 };
@@ -209,12 +229,15 @@ export function BlobbiCompanion({
: undefined;
// Drag handlers with click detection
// Uses pointer events only (handles mouse, touch, and pen natively)
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Capture pointer for tracking outside element
(e.target as HTMLElement).setPointerCapture(e.pointerId);
// Capture pointer on the container (not e.target which may be a child)
// for reliable tracking across element boundaries during drag
if (containerRef.current) {
containerRef.current.setPointerCapture(e.pointerId);
}
// Start click detection tracking
clickDetection.handlePointerDown({ x: e.clientX, y: e.clientY });
@@ -235,7 +258,9 @@ export function BlobbiCompanion({
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
if (containerRef.current) {
containerRef.current.releasePointerCapture(e.pointerId);
}
// Finalize click detection - will call onClick if it was a click
clickDetection.handlePointerUp();
@@ -246,42 +271,6 @@ export function BlobbiCompanion({
}
}, [clickDetection, motion.isDragging, onEndDrag]);
// Touch handlers for mobile (with click detection)
const handleTouchStart = useCallback((e: React.TouchEvent) => {
e.preventDefault();
if (e.touches.length === 0) return;
const touch = e.touches[0];
clickDetection.handlePointerDown({ x: touch.clientX, y: touch.clientY });
}, [clickDetection]);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 0) return;
const touch = e.touches[0];
const position = { x: touch.clientX, y: touch.clientY };
// Check if movement exceeds click threshold (starts drag)
const isDrag = clickDetection.handlePointerMove(position);
// If dragging, update position
if (motion.isDragging || isDrag) {
const newX = touch.clientX - config.size / 2;
const newY = touch.clientY - config.size / 2;
onUpdateDrag({ x: newX, y: newY });
}
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
const handleTouchEnd = useCallback(() => {
// Finalize click detection
clickDetection.handlePointerUp();
// Always end drag state
if (motion.isDragging) {
onEndDrag();
}
}, [clickDetection, motion.isDragging, onEndDrag]);
return (
<div
ref={containerRef}
@@ -302,20 +291,21 @@ export function BlobbiCompanion({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<BlobbiCompanionVisual
companion={companion}
size={config.size}
eyeOffset={eyeOffset}
eyeOffsetRef={eyeOffsetRef}
direction={isEntering ? 'right' : motion.direction}
isDragging={motion.isDragging}
isWalking={state === 'walking'}
floatOffset={floatOffset}
isOnGround={isOnGround}
distanceFromGround={distanceFromGround}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
debugMode={debugMode}
/>
</div>
@@ -1,26 +1,31 @@
/**
* BlobbiCompanionLayer
*
* Global layer component that renders the companion above all other content.
* This should be placed at the root level of the app.
*
* Entry animations are VERTICAL based on sidebar navigation direction:
* - Navigating DOWN the sidebar: Blobbi falls from the top of the screen
* - Navigating UP the sidebar: Blobbi rises from the bottom with inspection
*
* Interaction features:
* - Click/tap on Blobbi opens action menu
* - Action menu shows available actions in a radial layout
* - Selecting an action shows available items as floating bubbles
* BlobbiCompanionLayer — Global orchestration layer for the companion.
*
* This component is the top-level coordinator. It is NOT a visual component.
* It wires together:
* - Companion runtime (position, motion, gaze, entry animations)
* - Status reaction system (stats → visual recipe)
* - Action menu and hanging items interaction
* - Item use with temporary emotion overrides
*
* Visual rendering is delegated entirely to:
* BlobbiCompanion BlobbiCompanionVisual → MemoizedBlobbiVisual → Visual → SvgRenderer
*
* This file should be placed at the app root level (renders a fixed overlay).
*/
import { useCallback, useState } from 'react';
import { useCallback, useState, useMemo } from 'react';
import { useBlobbiCompanion } from '../hooks/useBlobbiCompanion';
import { useCompanionItemReaction } from '../hooks/useCompanionItemReaction';
import { useActionEmotionOverride } from '../hooks/useActionEmotionOverride';
import { BlobbiCompanion } from './BlobbiCompanion';
import { DebugGroundOverlay } from './DebugGroundOverlay';
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
import { calculateGroundY } from '../utils/movement';
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
import type { ActionType } from '@/blobbi/ui/lib/status-reactions';
import {
useCompanionActionMenu,
useBlobbiActions,
@@ -30,30 +35,19 @@ import {
type CompanionItem,
type ItemLandedData,
} from '../interaction';
import { useBlobbiSleepToggle } from '../interaction/useBlobbiSleepToggle';
import type { Position } from '../types/companion.types';
// DEBUG MODE - Set to true to debug ground contact
/** Set to true to show debug ground-contact lines. */
const DEBUG_GROUND_CONTACT = false;
/**
* Global companion layer.
*
* Renders the companion if:
* - User is logged in
* - User has set a current_companion in their profile
* - The companion data is loaded
*
* Entry animations are vertical:
* - Falls from top when navigating DOWN the sidebar
* - Rises from bottom (with inspection) when navigating UP the sidebar
*/
export function BlobbiCompanionLayer() {
const {
companion,
isVisible,
state,
motion,
eyeOffset,
eyeOffsetRef,
isEntering,
entryProgress,
entryState,
@@ -65,19 +59,20 @@ export function BlobbiCompanionLayer() {
endDrag,
triggerAttention,
} = useBlobbiCompanion();
const config = DEFAULT_COMPANION_CONFIG;
// Track the actual rendered position of the companion
// This accounts for entry animations, float offset, etc.
// ── Rendered position tracking ─────────────────────────────────────────────
// Tracks the actual visual position (including entry/float offsets) so
// the action menu and hanging items can position relative to Blobbi.
const [renderedPosition, setRenderedPosition] = useState<Position>(motion.position);
// Handle position updates from BlobbiCompanion
const handlePositionUpdate = useCallback((position: Position) => {
setRenderedPosition(position);
}, []);
// Callback for glancing at items (when Blobbi doesn't need them)
// ── Item reaction ──────────────────────────────────────────────────────────
const handleGlanceAtItem = useCallback((position: Position) => {
triggerAttention(position, {
duration: 800,
@@ -86,39 +81,31 @@ export function BlobbiCompanionLayer() {
isGlance: true,
});
}, [triggerAttention]);
// Callback for walking to items (when Blobbi needs them)
// For now, we just glance more intensely - full walking behavior
// would require deeper integration with the state machine
const handleWalkToItem = useCallback((position: Position) => {
// TODO: Implement actual walking behavior via useBlobbiCompanionState
// For now, trigger a longer attention to simulate interest
triggerAttention(position, {
duration: 1500,
priority: 'normal',
source: 'item-landed:need',
isGlance: false, // Use longer cooldown for "interested" attention
isGlance: false,
});
}, [triggerAttention]);
// Item reaction hook - determines if Blobbi needs items and how to react
const { reactToItemLanding } = useCompanionItemReaction({
isActive: isVisible && !isEntering,
onGlance: handleGlanceAtItem,
onWalkTo: handleWalkToItem,
});
// Handle when an item finishes falling and lands on the ground
const handleItemLanded = useCallback((data: ItemLandedData) => {
if (import.meta.env.DEV) {
console.log('[CompanionLayer] Item landed:', data.item.name, 'at', { x: data.x, y: data.y });
}
// React to the item landing based on Blobbi's needs
reactToItemLanding(data.item.category, { x: data.x, y: data.y });
}, [reactToItemLanding]);
// Action menu state
// ── Action menu ────────────────────────────────────────────────────────────
const {
menuState,
availableActions,
@@ -130,57 +117,56 @@ export function BlobbiCompanionLayer() {
isActive: isVisible,
stage: companion?.stage,
onItemClick: (item) => {
// Item was clicked in the hanging menu - this releases it
console.log('[CompanionLayer] Item released:', item);
if (import.meta.env.DEV) {
console.log('[CompanionLayer] Item released:', item);
}
},
});
// Get Blobbi actions from context
// This now works even when BlobbiPage is not mounted (uses built-in fallback)
const {
useItem: contextUseItem,
canUseItems,
isItemOnCooldown
const {
useItem: contextUseItem,
canUseItems,
isItemOnCooldown,
} = useBlobbiActions();
/**
* Handle item use - called when item contacts Blobbi or is clicked.
* Uses the BlobbiActionsContext to perform the actual item use.
* Returns success/failure to control whether item is removed from screen.
*
* Now works from any page (not just /blobbi) thanks to the built-in
* fallback in BlobbiActionsContext.
*/
// Standalone sleep/wake toggle — works without BlobbiPage mounted
const { toggleSleep } = useBlobbiSleepToggle();
// ── Item use with emotion override ─────────────────────────────────────────
const { actionOverride, triggerOverride } = useActionEmotionOverride();
const handleItemUse = useCallback(async (item: CompanionItem): Promise<{ success: boolean; error?: string }> => {
// Resolve the action from the item category
const action = CATEGORY_TO_ACTION[item.category];
if (!action) {
if (import.meta.env.DEV) {
console.warn('[CompanionLayer] No action for item category:', item.category);
}
return { success: false, error: `Cannot use ${item.category} items` };
}
if (!canUseItems) {
if (import.meta.env.DEV) {
console.warn('[CompanionLayer] Cannot use items - no companion selected');
}
return { success: false, error: 'No companion selected' };
}
// Trigger the temporary emotion override for visual feedback
triggerOverride(action as ActionType);
if (import.meta.env.DEV) {
console.log('[CompanionLayer] Using item:', item.name, 'with action:', action);
}
try {
const result = await contextUseItem(item.id, action, 1);
const result = await contextUseItem(item.id, action);
if (result.success) {
if (import.meta.env.DEV) {
console.log('[CompanionLayer] Item used successfully:', item.name, result.statsChanged);
}
// Close the menu after successful use
closeMenu();
return { success: true };
} else {
@@ -196,154 +182,130 @@ export function BlobbiCompanionLayer() {
}
return { success: false, error: errorMessage };
}
}, [canUseItems, contextUseItem, closeMenu]);
// Handle companion click
}, [canUseItems, contextUseItem, closeMenu, triggerOverride]);
// ── Companion click ────────────────────────────────────────────────────────
// ── Sleep action (direct, not item-based) ───────────────────────────────────
const handleSleepAction = useCallback(async () => {
closeMenu();
try {
await toggleSleep();
} catch (error) {
if (import.meta.env.DEV) {
console.error('[CompanionLayer] Sleep toggle failed:', error);
}
}
}, [toggleSleep, closeMenu]);
/** Intercept action selection: sleep is a direct action, others go through item flow. */
const handleActionClick = useCallback((action: Parameters<typeof selectAction>[0]) => {
if (action === 'sleep') {
handleSleepAction();
} else {
selectAction(action);
}
}, [handleSleepAction, selectAction]);
const handleCompanionClick = useCallback(() => {
// Don't open menu during entry animation
if (isEntering) return;
toggleMenu();
}, [isEntering, toggleMenu]);
// Handle click outside menu
const handleClickOutside = useCallback(() => {
closeMenu();
}, [closeMenu]);
// Don't render anything if not visible
// ── Status reaction ────────────────────────────────────────────────────────
// Resolves companion stats into a visual recipe (sleepy, hungry, dirty, etc.).
// The actionOverride from useActionEmotionOverride temporarily overrides
// the recipe when an item is used (e.g., feeding → happy face for 1.5s).
//
// Status reaction stays ENABLED during sleep so body effects (dirty) and
// extras (food icon) still resolve. The sleeping recipe overlay is applied
// on top to override the face while preserving compatible body effects.
const isSleeping = companion?.state === 'sleeping';
const companionStats = useMemo(() => companion?.stats ?? {
hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100,
}, [companion?.stats]);
const { recipe: statusRecipe, recipeLabel: statusRecipeLabel } = useStatusReaction({
stats: companionStats,
enabled: isVisible && companion?.stage !== 'egg',
actionOverride: isSleeping ? null : actionOverride,
});
// When sleeping, overlay the sleeping face on top of the status recipe.
// This keeps body effects (dirty, stink) and food icon while overriding
// eyes, mouth, and eyebrows with sleeping visuals.
const companionRecipe = isSleeping
? buildSleepingRecipe(statusRecipe)
: statusRecipe;
const companionRecipeLabel = isSleeping ? 'sleeping' : statusRecipeLabel;
// ── Early return ───────────────────────────────────────────────────────────
if (!isVisible || !companion) {
return null;
}
// Companion props
const companionProps = {
companion,
state,
motion,
eyeOffset,
isEntering,
entryProgress,
entryState,
wasResolvedFromStuck,
groundPosition,
viewport,
onStartDrag: startDrag,
onUpdateDrag: updateDrag,
onEndDrag: endDrag,
onClick: handleCompanionClick,
onPositionUpdate: handlePositionUpdate,
};
// Calculate ground position for debug line
// ── Render ─────────────────────────────────────────────────────────────────
const debugGroundY = calculateGroundY(viewport.height, config.size, config);
return (
<div
<div
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 9999 }}
aria-hidden="true"
>
{/* DEBUG: Visible ground line */}
{DEBUG_GROUND_CONTACT && (
<>
{/* Ground line where Blobbi's CONTAINER bottom should be */}
<div
style={{
position: 'fixed',
left: 0,
right: 0,
top: debugGroundY + config.size, // Container bottom
height: 2,
backgroundColor: 'red',
zIndex: 10002,
}}
/>
{/* Label for the ground line */}
<div
style={{
position: 'fixed',
right: 10,
top: debugGroundY + config.size + 4,
color: 'red',
fontSize: 12,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '2px 4px',
}}
>
Container bottom (groundY + size = {Math.round(debugGroundY + config.size)}px)
</div>
{/* Another line showing the actual viewport bottom minus padding */}
<div
style={{
position: 'fixed',
left: 0,
right: 0,
top: viewport.height - config.padding.bottom,
height: 2,
backgroundColor: 'blue',
zIndex: 10002,
}}
/>
<div
style={{
position: 'fixed',
right: 10,
top: viewport.height - config.padding.bottom + 4,
color: 'blue',
fontSize: 12,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '2px 4px',
}}
>
Viewport - padding = {viewport.height - config.padding.bottom}px (Target ground)
</div>
{/* Entry type indicator */}
{isEntering && (
<div
style={{
position: 'fixed',
left: 10,
top: 10,
color: entryState.entryType === 'fall' ? 'orange' : 'green',
fontSize: 14,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '4px 8px',
borderRadius: 4,
}}
>
Entry: {entryState.entryType.toUpperCase()} | Phase: {entryState.phase}
</div>
)}
</>
<DebugGroundOverlay
groundY={debugGroundY}
size={config.size}
viewportHeight={viewport.height}
paddingBottom={config.padding.bottom}
isEntering={isEntering}
entryState={entryState}
/>
)}
{/* Companion */}
<div className="pointer-events-auto">
<BlobbiCompanion
{...companionProps}
<BlobbiCompanion
companion={companion}
state={state}
motion={motion}
eyeOffsetRef={eyeOffsetRef}
isEntering={isEntering}
entryProgress={entryProgress}
entryState={entryState}
wasResolvedFromStuck={wasResolvedFromStuck}
groundPosition={groundPosition}
viewport={viewport}
onStartDrag={startDrag}
onUpdateDrag={updateDrag}
onEndDrag={endDrag}
onClick={handleCompanionClick}
recipe={companionRecipe}
recipeLabel={companionRecipeLabel}
onPositionUpdate={handlePositionUpdate}
debugMode={DEBUG_GROUND_CONTACT}
/>
</div>
{/* Action Menu - radial buttons around Blobbi */}
<CompanionActionMenu
isOpen={menuState.isOpen}
companionPosition={renderedPosition}
companionSize={config.size}
actions={availableActions}
selectedAction={menuState.selectedAction}
onActionClick={selectAction}
onActionClick={handleActionClick}
onClickOutside={handleClickOutside}
isSleeping={isSleeping}
/>
{/* Hanging Items - items displayed as hanging elements from top */}
<HangingItems
isVisible={menuState.isOpen && menuState.selectedAction !== null}
selectedAction={menuState.selectedAction}
@@ -1,197 +1,212 @@
/**
* BlobbiCompanionVisual
*
*
* Visual component for rendering the companion Blobbi.
* Supports external eye offset control for custom gaze behavior.
*
* Architecture:
* - Outer shell: handles per-frame updates (float, shadow, drag state) — rerenders freely
* - Float wrapper: owns translateY alignment + JS float offset (inline transform)
* - Sway wrapper: owns CSS rotation animation only (animate-blobbi-sway)
* Kept separate from float wrapper so CSS @keyframes don't override the
* inline translateY, which would make Blobbi float above the ground.
* - Inner MemoizedBlobbiVisual: renders the actual SVG — only rerenders when visual inputs change
* - Eye gaze is driven imperatively via ref (no React rerenders for gaze)
*/
import { useMemo, useRef } from 'react';
import { useMemo, memo, type RefObject } from 'react';
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
import { useEffectiveEmotion } from '@/blobbi/dev/useEmotionDev';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
import type { Blobbi } from '@/types/blobbi';
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiCompanionVisualProps {
/** Companion data */
companion: CompanionData;
/** Size in pixels */
size: number;
/** Eye offset for gaze direction */
eyeOffset: EyeOffset;
/** Facing direction (used for gaze, not for flipping) */
eyeOffsetRef: RefObject<EyeOffset>;
direction: CompanionDirection;
/** Whether the companion is being dragged */
isDragging: boolean;
/** Whether the companion is walking */
isWalking: boolean;
/** Floating animation offset for gentle sway */
floatOffset?: { x: number; y: number; rotation: number };
/** Whether Blobbi is on or near the ground (affects shadow visibility) */
isOnGround?: boolean;
/** Distance from ground in pixels (for shadow fade, 0 = on ground) */
distanceFromGround?: number;
/** Additional class names */
recipe?: BlobbiVisualRecipe;
recipeLabel?: string;
emotion?: BlobbiEmotion;
bodyEffects?: BodyEffectsSpec;
className?: string;
/** Debug mode - shows visual boundaries */
debugMode?: boolean;
}
/**
* Convert CompanionData to the Blobbi type for rendering.
*/
function toBlobiForVisual(companion: CompanionData): Blobbi {
return {
id: companion.d,
name: companion.name,
lifeStage: companion.stage,
state: 'active',
isSleeping: false,
stats: {
hunger: 100,
happiness: 100,
health: 100,
hygiene: 100,
energy: companion.energy,
},
baseColor: companion.visualTraits.baseColor,
secondaryColor: companion.visualTraits.secondaryColor,
eyeColor: companion.visualTraits.eyeColor,
pattern: companion.visualTraits.pattern,
specialMark: companion.visualTraits.specialMark,
size: companion.visualTraits.size,
seed: companion.seed ?? '',
tags: [],
// Include adult form info for proper rendering
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
};
// ─── Memoized Inner Visual ────────────────────────────────────────────────────
//
// STABILITY CONTRACT:
// This component is the boundary that protects the SVG DOM subtree from the
// companion rerender storm (~60 renders/s from motion/float RAF loops).
// It renders BlobbiAdultVisual / BlobbiBabyVisual with renderMode="companion".
//
// It MUST only rerender when actual visual content changes:
// blobbi, recipe, recipeLabel, emotion, bodyEffects, stage
//
// It MUST NOT receive or depend on per-frame values:
// eyeOffset value, floatOffset, isDragging, isWalking, position, animationTime
//
// The eyeOffsetRef is a stable React ref — its identity never changes,
// so it is safe to pass without triggering rerenders.
interface MemoizedBlobbiVisualProps {
stage: 'baby' | 'adult';
blobbi: Blobbi;
eyeOffsetRef: RefObject<EyeOffset>;
recipe?: BlobbiVisualRecipe;
recipeLabel?: string;
emotion: BlobbiEmotion;
bodyEffects?: BodyEffectsSpec;
}
const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
stage,
blobbi,
eyeOffsetRef,
recipe,
recipeLabel,
emotion,
bodyEffects,
}: MemoizedBlobbiVisualProps) {
if (stage === 'baby') {
return (
<BlobbiBabyVisual
blobbi={blobbi}
renderMode="companion"
lookMode="forward"
externalEyeOffsetRef={eyeOffsetRef}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
);
}
return (
<BlobbiAdultVisual
blobbi={blobbi}
renderMode="companion"
lookMode="forward"
externalEyeOffsetRef={eyeOffsetRef}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
);
}, (prev, next) => {
return (
prev.stage === next.stage &&
prev.blobbi === next.blobbi &&
prev.recipe === next.recipe &&
prev.recipeLabel === next.recipeLabel &&
prev.emotion === next.emotion &&
prev.bodyEffects === next.bodyEffects
);
});
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiCompanionVisual({
companion,
size,
eyeOffset,
eyeOffsetRef,
direction,
isDragging,
isWalking,
floatOffset = { x: 0, y: 0, rotation: 0 },
isOnGround = true,
distanceFromGround = 0,
recipe: recipeProp,
recipeLabel: recipeLabelProp,
emotion: emotionProp,
bodyEffects: bodyEffectsProp,
className,
debugMode = false,
}: BlobbiCompanionVisualProps) {
const containerRef = useRef<HTMLDivElement>(null);
const blobbi = useMemo(() => toBlobiForVisual(companion), [companion]);
// DEV ONLY: Get effective emotion from dev context
const effectiveEmotion = useEffectiveEmotion();
// Eye offset is now passed directly to the visual components via externalEyeOffset prop
// This is more reliable than DOM manipulation which can be overwritten by useBlobbiEyes
// Build transform for floating animation
// No flipping based on direction - Blobbi always faces the same way
const blobbi = useMemo(() => companionDataToBlobbi(companion), [companion]);
// DEV ONLY: Get effective emotion from dev context (overrides production emotions)
const devEmotion = useEffectiveEmotion();
const hasDevOverride = devEmotion !== 'neutral';
const effectiveRecipe = hasDevOverride ? undefined : recipeProp;
const effectiveRecipeLabel = hasDevOverride ? undefined : recipeLabelProp;
const effectiveEmotion = hasDevOverride ? devEmotion : (emotionProp ?? 'neutral');
const effectiveBodyEffects = hasDevOverride ? undefined : bodyEffectsProp;
// Float transform
const blobbiTransform = useMemo(() => {
const transforms: string[] = [];
if (floatOffset.x !== 0 || floatOffset.y !== 0) {
transforms.push(`translate(${floatOffset.x}px, ${floatOffset.y}px)`);
}
if (floatOffset.rotation !== 0) {
transforms.push(`rotate(${floatOffset.rotation}deg)`);
}
return transforms.length > 0 ? transforms.join(' ') : undefined;
}, [floatOffset]);
// Determine reaction state
const reaction = isDragging ? 'happy' : isWalking ? 'idle' : 'idle';
// Shadow visibility and appearance based on ground proximity
// Shadow should only appear when Blobbi is on or very near the ground
const SHADOW_FADE_DISTANCE = 30; // Shadow fully fades at this distance from ground
// Reaction state for CSS animations on the OUTER wrapper
// When sleeping, always idle — no swaying/happy animation
const isSleeping = companion.state === 'sleeping';
const reaction = isSleeping ? 'idle' : isDragging ? 'happy' : isWalking ? 'swaying' : 'idle';
// ── Shadow ─────────────────────────────────────────────────────────────────
const SHADOW_FADE_DISTANCE = 30;
const SHADOW_MAX_OPACITY = 0.35;
// Calculate shadow visibility based on actual ground distance, not just float offset
const showShadow = isOnGround && !isDragging && distanceFromGround < SHADOW_FADE_DISTANCE;
// Shadow fades as Blobbi gets farther from ground
// Also factor in the float animation offset for subtle breathing effect
const floatHeight = Math.abs(floatOffset.y);
const groundFadeRatio = Math.max(0, 1 - distanceFromGround / SHADOW_FADE_DISTANCE);
const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02); // Subtle fade during float
const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02);
const shadowOpacity = SHADOW_MAX_OPACITY * groundFadeRatio * floatFadeRatio;
const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio; // Slightly smaller when lifting
// Suppress unused variable warning for direction (kept for API compatibility)
const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio;
// direction is accepted for API completeness but not currently used for rendering
// (Blobbi does not flip based on facing direction). Suppress unused warning.
void direction;
return (
<div
ref={containerRef}
<div
className={cn('relative', className)}
style={{ width: size, height: size }}
>
{/* DEBUG: Container and alignment markers */}
{/* Debug alignment markers */}
{debugMode && (
<>
{/* Container outline - lime */}
<div
className="absolute inset-0 pointer-events-none"
style={{
border: '2px solid lime',
boxSizing: 'border-box',
}}
/>
{/* 88% line from top (where SVG body bottom should be before shift) - yellow */}
<div
className="absolute pointer-events-none"
style={{
top: `${size * 0.88}px`,
left: 0,
right: 0,
height: 2,
backgroundColor: 'yellow',
}}
/>
{/* 100% line (container bottom where body should touch after shift) - cyan */}
<div
className="absolute pointer-events-none"
style={{
bottom: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: 'cyan',
}}
/>
{/* Label showing the expected shift */}
<div
className="absolute pointer-events-none"
style={{
top: 2,
left: 2,
fontSize: 8,
color: 'white',
backgroundColor: 'black',
padding: '1px 2px',
}}
>
<div className="absolute inset-0 pointer-events-none" style={{ border: '2px solid lime', boxSizing: 'border-box' }} />
<div className="absolute pointer-events-none" style={{ top: `${size * 0.88}px`, left: 0, right: 0, height: 2, backgroundColor: 'yellow' }} />
<div className="absolute pointer-events-none" style={{ bottom: 0, left: 0, right: 0, height: 2, backgroundColor: 'cyan' }} />
<div className="absolute pointer-events-none" style={{ top: 2, left: 2, fontSize: 8, color: 'white', backgroundColor: 'black', padding: '1px 2px' }}>
shift: {size * 0.12}px
</div>
</>
)}
{/* Floor shadow - only visible when Blobbi is on/near the ground */}
{/* Hidden during: dragging, entry animations, falling, or when far from ground */}
{/* Floor shadow */}
{!debugMode && showShadow && shadowOpacity > 0.01 && (
<div
className="absolute pointer-events-none"
style={{
// Position shadow well below Blobbi to feel like it's on the floor
bottom: -20,
left: '50%',
width: size * 0.5,
@@ -200,53 +215,60 @@ export function BlobbiCompanionVisual({
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowOpacity}) 0%, rgba(0,0,0,${shadowOpacity * 0.5}) 40%, transparent 70%)`,
borderRadius: '50%',
filter: 'blur(4px)',
opacity: groundFadeRatio, // Additional opacity control for smooth fade
opacity: groundFadeRatio,
transition: 'opacity 0.15s ease-out, transform 0.1s ease-out',
}}
/>
)}
{/* Blobbi visual with floating transform */}
{/*
The Blobbi SVG has empty space: 15% at top (body starts at y=15), 12% at bottom (body ends at y=88).
To align the visible body bottom with the container bottom, we shift down by 12% of container size.
This is applied BEFORE the float transform so the ground position is correct.
{/*
Float wrapper — owns translateY alignment + JS float offset.
This is a separate element from the sway wrapper below so that
the CSS animation on the sway wrapper does not override the
inline transform here. (CSS @keyframes replace the entire
`transform` property while active, which would drop the
translateY alignment shift and cause Blobbi to float above
the ground during walking.)
*/}
<div
className="size-full"
style={{
// First apply the SVG alignment correction, then the float animation
// The 12% shift pushes the SVG down so its visible body bottom aligns with container bottom
transform: [
`translateY(${size * 0.12}px)`, // SVG body alignment correction
blobbiTransform, // Float animation (if any)
`translateY(${size * 0.12}px)`,
blobbiTransform,
].filter(Boolean).join(' ') || undefined,
transformOrigin: 'center bottom',
transition: isDragging ? 'none' : 'transform 0.05s ease-out',
// DEBUG: Show the shifted wrapper
...(debugMode ? { outline: '2px dashed magenta' } : {}),
}}
>
{companion.stage === 'baby' && (
<BlobbiBabyVisual
blobbi={blobbi}
reaction={reaction}
lookMode="forward"
externalEyeOffset={eyeOffset}
emotion={effectiveEmotion}
className="size-full"
/>
)}
{companion.stage === 'adult' && (
<BlobbiAdultVisual
blobbi={blobbi}
reaction={reaction}
lookMode="forward"
externalEyeOffset={eyeOffset}
emotion={effectiveEmotion}
className="size-full"
/>
)}
{/* Sway wrapper — CSS rotation only, no positioning transforms */}
<div
className={cn(
'size-full',
(reaction === 'swaying' || reaction === 'happy') && 'animate-blobbi-sway',
)}
style={{ transformOrigin: 'center bottom' }}
>
{companion.stage === 'egg' ? (
<BlobbiStageVisual
companion={companion as unknown as BlobbiCompanion}
size="sm"
animated={false}
className="size-full"
/>
) : (
<MemoizedBlobbiVisual
stage={companion.stage}
blobbi={blobbi}
eyeOffsetRef={eyeOffsetRef}
recipe={effectiveRecipe}
recipeLabel={effectiveRecipeLabel}
emotion={effectiveEmotion}
bodyEffects={effectiveBodyEffects}
/>
)}
</div>
</div>
</div>
);
@@ -0,0 +1,108 @@
/**
* DebugGroundOverlay — Debug-only visual overlay for ground contact debugging.
*
* Shows horizontal lines indicating:
* - Container bottom (where Blobbi's container ends)
* - Viewport bottom minus padding (target ground position)
* - Entry animation type and phase (during entry)
*
* Enabled by setting DEBUG_GROUND_CONTACT = true in BlobbiCompanionLayer.
*/
import type { EntryState } from '../types/companion.types';
interface DebugGroundOverlayProps {
groundY: number;
size: number;
viewportHeight: number;
paddingBottom: number;
isEntering: boolean;
entryState: EntryState;
}
export function DebugGroundOverlay({
groundY,
size,
viewportHeight,
paddingBottom,
isEntering,
entryState,
}: DebugGroundOverlayProps) {
return (
<>
{/* Ground line where Blobbi's CONTAINER bottom should be */}
<div
style={{
position: 'fixed',
left: 0,
right: 0,
top: groundY + size,
height: 2,
backgroundColor: 'red',
zIndex: 10002,
}}
/>
<div
style={{
position: 'fixed',
right: 10,
top: groundY + size + 4,
color: 'red',
fontSize: 12,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '2px 4px',
}}
>
Container bottom (groundY + size = {Math.round(groundY + size)}px)
</div>
{/* Viewport bottom minus padding */}
<div
style={{
position: 'fixed',
left: 0,
right: 0,
top: viewportHeight - paddingBottom,
height: 2,
backgroundColor: 'blue',
zIndex: 10002,
}}
/>
<div
style={{
position: 'fixed',
right: 10,
top: viewportHeight - paddingBottom + 4,
color: 'blue',
fontSize: 12,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '2px 4px',
}}
>
Viewport - padding = {viewportHeight - paddingBottom}px (Target ground)
</div>
{/* Entry type indicator */}
{isEntering && (
<div
style={{
position: 'fixed',
left: 10,
top: 10,
color: entryState.entryType === 'fall' ? 'orange' : 'green',
fontSize: 14,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '4px 8px',
borderRadius: 4,
}}
>
Entry: {entryState.entryType.toUpperCase()} | Phase: {entryState.phase}
</div>
)}
</>
);
}
@@ -233,17 +233,18 @@ export function updateDragPosition(motion: CompanionMotion, position: Position):
}
/**
* End dragging - let gravity take over.
* End dragging - hold position where dropped.
*/
export function endDrag(motion: CompanionMotion, groundY: number): CompanionMotion {
return {
...motion,
isDragging: false,
// If already at or below ground, snap to ground
isGrounded: motion.position.y >= groundY,
// Always treat as grounded so companion holds position where dropped
isGrounded: true,
position: {
...motion.position,
y: motion.position.y >= groundY ? groundY : motion.position.y,
// Clamp to ground if below it
y: Math.min(motion.position.y, groundY),
},
};
}
@@ -0,0 +1,43 @@
/**
* useActionEmotionOverride — Temporary emotion override when using items.
*
* When an item is used on the companion (e.g., feeding → happy), this hook
* provides a short-lived emotion override that takes precedence over the
* status reaction system. The override automatically clears after 1.5s.
*
* Used by BlobbiCompanionLayer to wrap item-use handlers with emotion feedback.
*/
import { useState, useCallback, useRef } from 'react';
import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
/** Duration of the action emotion override in milliseconds. */
const ACTION_OVERRIDE_DURATION_MS = 1500;
interface UseActionEmotionOverrideResult {
/** Current override emotion, or null if none active. Passed to useStatusReaction. */
actionOverride: BlobbiEmotion | null;
/** Trigger an override for the given action type. */
triggerOverride: (action: ActionType) => void;
}
export function useActionEmotionOverride(): UseActionEmotionOverrideResult {
const [actionOverride, setActionOverride] = useState<BlobbiEmotion | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerOverride = useCallback((action: ActionType) => {
// Clear any existing timer
if (timerRef.current) {
clearTimeout(timerRef.current);
}
setActionOverride(getActionEmotion(action));
timerRef.current = setTimeout(() => {
setActionOverride(null);
timerRef.current = null;
}, ACTION_OVERRIDE_DURATION_MS);
}, []);
return { actionOverride, triggerOverride };
}
@@ -12,7 +12,6 @@ import type {
CompanionData,
CompanionState,
CompanionMotion,
GazeState,
EyeOffset,
Position,
MovementBounds,
@@ -20,8 +19,17 @@ import type {
EntryType,
InspectionDirection,
} from '../types/companion.types';
/** Default motion state used before motion hook initializes */
const DEFAULT_MOTION: CompanionMotion = {
position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
direction: 'right',
isGrounded: true,
isDragging: false,
};
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
import { calculateMovementBounds, calculateGroundY, calculateRestingPosition } from '../utils/movement';
import { calculateMovementBounds, calculateGroundY } from '../utils/movement';
import { useBlobbiCompanionData } from './useBlobbiCompanionData';
import { useBlobbiCompanionState } from './useBlobbiCompanionState';
import { useBlobbiCompanionMotion } from './useBlobbiCompanionMotion';
@@ -50,10 +58,8 @@ interface UseBlobbiCompanionResult {
state: CompanionState;
/** Current motion state */
motion: CompanionMotion;
/** Current gaze state */
gaze: GazeState;
/** Smoothed eye offset for rendering */
eyeOffset: EyeOffset;
/** Ref-based eye offset for imperative gaze control (no rerenders) */
eyeOffsetRef: React.RefObject<EyeOffset>;
/** Whether entry animation is playing */
isEntering: boolean;
/** Entry animation progress (0-1) - legacy, use entryState for detailed control */
@@ -98,7 +104,8 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
// Track if first entry has completed (for position initialization)
const [hasEnteredOnce, setHasEnteredOnce] = useState(false);
// Track viewport size
// Track viewport size — listen to both window resize and visualViewport
// (mobile browsers fire visualViewport resize when URL bar shows/hides)
useEffect(() => {
const handleResize = () => {
setViewport({
@@ -108,7 +115,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
};
window.addEventListener('resize', handleResize, { passive: true });
return () => window.removeEventListener('resize', handleResize);
window.visualViewport?.addEventListener('resize', handleResize, { passive: true });
return () => {
window.removeEventListener('resize', handleResize);
window.visualViewport?.removeEventListener('resize', handleResize);
};
}, []);
// Calculate bounds and positions
@@ -128,10 +139,14 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
y: groundY,
}), [viewport.width, config.size, groundY]);
const restingPosition = useMemo(() =>
calculateRestingPosition(viewport.width, viewport.height, config.size, config),
[viewport.width, viewport.height, config]
);
// Shared motion ref - motion hook writes, state hook reads
// This solves the bidirectional dependency: state needs motion position,
// motion needs state/targetX. By using a ref, state can read current motion
// without creating a circular hook dependency.
const motionRef = useRef<CompanionMotion>({
...DEFAULT_MOTION,
position: groundPosition,
});
// Fetch companion data
const { companion, isLoading } = useBlobbiCompanionData();
@@ -200,7 +215,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
}, config.attention.postRouteDelay);
}, [findMainContentPosition, triggerAttention, config.attention.postRouteDuration, config.attention.postRouteDelay]);
// Determine if companion is sleeping
const companionSleeping = companion?.state === 'sleeping';
// State management
// Pass the shared motionRef so state can read live motion values
const {
state,
direction,
@@ -210,19 +229,15 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
onReachedTarget,
} = useBlobbiCompanionState({
isActive: isVisible,
motion: {
position: restingPosition,
velocity: { x: 0, y: 0 },
direction: 'right',
isGrounded: true,
isDragging: false
},
motionRef,
bounds,
attentionTarget: currentAttention,
isSleeping: companionSleeping,
});
// Motion management
// After entry completes, motion continues from groundPosition (where entry ended)
// Pass sharedMotionRef so state hook can read live motion values
const {
motion,
startDrag,
@@ -237,6 +252,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
targetX,
energy: companion?.energy ?? 50,
onReachedTarget,
sharedMotionRef: motionRef,
});
// Entry animation management (handles route changes and companion changes)
@@ -292,7 +308,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
}, [entryJustCompleted, wasResolvedFromStuck, setPosition, groundPosition, acknowledgeCompletion]);
// Gaze management - passes entry inspection direction for eye control during entry
const { gaze, eyeOffset } = useBlobbiCompanionGaze({
const { eyeOffsetRef } = useBlobbiCompanionGaze({
state: isEntering ? 'idle' : state,
direction: isEntering ? 'right' : direction,
companionPosition: motion.position,
@@ -310,8 +326,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
isVisible: shouldBeVisible,
state: isEntering ? 'idle' : state,
motion,
gaze,
eyeOffset,
eyeOffsetRef,
isEntering,
entryProgress: entryState.progress,
entryState,
@@ -4,22 +4,18 @@
* Fetches the current companion data from the user's Blobbonaut profile.
* This is the data layer - it handles fetching and provides companion data.
*
* IMPORTANT: This hook uses useBlobbonautProfile to ensure reactivity.
* When the profile is updated (e.g., companion selected/removed), this hook
* automatically receives the update via the shared query cache.
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
* useBlobbisCollection. This ensures:
* - Immediate reactivity when stats change (optimistic updates)
* - Projected decay is applied for accurate visual reactions
* - No duplicate queries or stale cache issues
*/
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import {
KIND_BLOBBI_STATE,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/lib/blobbi';
import { useBlobbisCollection } from '@/blobbi/core/hooks/useBlobbisCollection';
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
import type { CompanionData } from '../types/companion.types';
interface UseBlobbiCompanionDataResult {
@@ -36,79 +32,80 @@ interface UseBlobbiCompanionDataResult {
*
* Flow:
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
* 2. Read the currentCompanion from the profile
* 3. If it exists, fetch the corresponding kind 31124 (Blobbi State) event
* 4. Return the minimal data needed for rendering
* 2. Build a dList containing just the currentCompanion
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
* 4. Apply projected decay for accurate UI reactions
* 5. Return the companion data with projected stats
*
* Reactivity:
* - Uses the same query cache as useBlobbonautProfile
* - When profile is updated via updateProfileEvent(), this hook reacts immediately
* - No duplicate queries or stale cache issues
* - Uses the same query cache as BlobbiPage (blobbi-collection)
* - When Blobbi state is updated, optimistic updates flow through immediately
* - Projected decay recalculates every 60 seconds
* - No separate query or stale cache issues
*/
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// Use the shared profile hook - this ensures reactivity when profile changes
const { profile, isLoading: profileLoading } = useBlobbonautProfile();
// Extract current companion d-tag from the reactive profile
const currentCompanionD = profile?.currentCompanion;
// Fetch the Blobbi state if we have a current companion
const blobbiQuery = useQuery({
queryKey: ['companion-blobbi', user?.pubkey, currentCompanionD],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !currentCompanionD) return null;
const events = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [currentCompanionD],
}], { signal });
// Get the latest valid event
const validEvents = events
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
return parseBlobbiEvent(validEvents[0]);
},
enabled: !!user?.pubkey && !!currentCompanionD,
staleTime: 60_000, // 1 minute
gcTime: 5 * 60_000, // 5 minutes
});
// Build dList containing just the current companion (if set)
// This allows us to use the shared collection query cache
const dList = useMemo(() => {
if (!currentCompanionD) return undefined;
return [currentCompanionD];
}, [currentCompanionD]);
// Transform to CompanionData
// Use the shared collection query - same cache as BlobbiPage
// This ensures we get optimistic updates immediately
const {
companionsByD,
isLoading: collectionLoading,
} = useBlobbisCollection(dList);
// Get the BlobbiCompanion from the collection
const blobbi = currentCompanionD ? companionsByD[currentCompanionD] ?? null : null;
// Apply projected decay for accurate visual reactions
// This recalculates every 60 seconds while mounted
const projectedState = useProjectedBlobbiState(blobbi);
// Transform to CompanionData with projected stats
// When currentCompanionD becomes null/undefined, companion becomes null
const companion = useMemo((): CompanionData | null => {
// If no current companion is set in profile, return null immediately
// This ensures removal is reactive
if (!currentCompanionD) return null;
const blobbi = blobbiQuery.data;
if (!blobbi) return null;
// Only baby and adult can be companions
if (blobbi.stage === 'egg') return null;
// Use projected stats if available, otherwise fall back to base stats
const stats = projectedState?.stats ?? blobbi.stats;
return {
d: blobbi.d,
name: blobbi.name,
stage: blobbi.stage,
visualTraits: blobbi.visualTraits,
energy: blobbi.stats.energy ?? 100,
energy: stats.energy ?? 100,
stats: {
hunger: stats.hunger ?? 100,
happiness: stats.happiness ?? 100,
health: stats.health ?? 100,
hygiene: stats.hygiene ?? 100,
energy: stats.energy ?? 100,
},
state: blobbi.state,
// Include adult form info for proper rendering
adultType: blobbi.adultType,
seed: blobbi.seed,
};
}, [currentCompanionD, blobbiQuery.data]);
}, [currentCompanionD, blobbi, projectedState?.stats]);
return {
companion,
isLoading: profileLoading || (!!currentCompanionD && blobbiQuery.isLoading),
error: blobbiQuery.error ?? null,
isLoading: profileLoading || (!!currentCompanionD && collectionLoading),
error: null,
};
}
@@ -49,10 +49,8 @@ interface UseBlobbiCompanionGazeOptions {
}
interface UseBlobbiCompanionGazeResult {
/** Current gaze state */
gaze: GazeState;
/** Smoothed eye offset for rendering */
eyeOffset: EyeOffset;
/** Ref-based eye offset for imperative gaze control (no rerenders) */
eyeOffsetRef: React.RefObject<EyeOffset>;
}
/**
@@ -94,8 +92,11 @@ export function useBlobbiCompanionGaze({
attentionPosition,
entryInspectionDirection,
}: UseBlobbiCompanionGazeOptions): UseBlobbiCompanionGazeResult {
const [gaze, setGaze] = useState<GazeState>(createInitialGaze);
const [eyeOffset, setEyeOffset] = useState<EyeOffset>({ x: 0, y: 0 });
const [, setGaze] = useState<GazeState>(createInitialGaze);
// Eye offset is driven imperatively via ref — no React state needed.
// The RAF loop writes to eyeOffsetRef; useExternalEyeOffset reads from it.
/** Ref-based eye offset for imperative consumers (avoids per-frame React rerenders) */
const eyeOffsetRef = useRef<EyeOffset>({ x: 0, y: 0 });
const [mousePosition, setMousePosition] = useState<Position | null>(null);
// Use refs for values that shouldn't trigger re-renders
@@ -109,8 +110,25 @@ export function useBlobbiCompanionGaze({
const mouseFollowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mouseFollowCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Refs for frequently changing values used in animation loop
// This prevents RAF effect from being torn down on every position change
const directionRef = useRef(direction);
const companionPositionRef = useRef(companionPosition);
const companionSizeRef = useRef(companionSize);
const mousePositionRef = useRef(mousePosition);
const observationTargetRef = useRef(observationTarget);
const attentionPositionRef = useRef(attentionPosition);
const config = DEFAULT_COMPANION_CONFIG;
// Keep refs updated with latest values
useEffect(() => { directionRef.current = direction; }, [direction]);
useEffect(() => { companionPositionRef.current = companionPosition; }, [companionPosition]);
useEffect(() => { companionSizeRef.current = companionSize; }, [companionSize]);
useEffect(() => { mousePositionRef.current = mousePosition; }, [mousePosition]);
useEffect(() => { observationTargetRef.current = observationTarget; }, [observationTarget]);
useEffect(() => { attentionPositionRef.current = attentionPosition; }, [attentionPosition]);
// Clear all timers helper
const clearAllTimers = useCallback(() => {
if (randomGazeTimerRef.current) {
@@ -275,6 +293,9 @@ export function useBlobbiCompanionGaze({
}, [isActive, state, observationTarget, attentionPosition, entryInspectionDirection, config.gaze.randomInterval, config.gaze.mouseFollowCooldown, config.gaze.mouseFollowChance, config.gaze.mouseFollowDuration, clearAllTimers]);
// Animation loop for smooth eye movement
// IMPORTANT: This effect only depends on isActive to start/stop the loop.
// All other values are read from refs to prevent loop recreation on every
// position change (which caused jitter and stuck eyes after entry).
useEffect(() => {
if (!isActive) {
if (animationRef.current) {
@@ -285,6 +306,14 @@ export function useBlobbiCompanionGaze({
}
const animate = () => {
// Read current values from refs (not closure captures)
const currentPosition = companionPositionRef.current;
const currentSize = companionSizeRef.current;
const currentDirection = directionRef.current;
const currentMouse = mousePositionRef.current;
const currentObservation = observationTargetRef.current;
const currentAttention = attentionPositionRef.current;
// Calculate target offset based on current gaze mode
let targetOffset: EyeOffset;
@@ -294,19 +323,19 @@ export function useBlobbiCompanionGaze({
// During entry inspection - use the pre-set target offset from inspection direction
// This is set by the main effect when entryInspectionDirection changes
targetOffset = targetOffsetRef.current;
} else if (currentMode === 'attend-ui' && attentionPosition) {
} else if (currentMode === 'attend-ui' && currentAttention) {
// Look at UI element that appeared - calculate offset to that position
targetOffset = calculateEyeOffset(companionPosition, attentionPosition, companionSize);
} else if (currentMode === 'observe-target' && observationTarget) {
targetOffset = calculateEyeOffset(currentPosition, currentAttention, currentSize);
} else if (currentMode === 'observe-target' && currentObservation) {
// Look at observation target - calculate offset to that position
targetOffset = calculateEyeOffset(companionPosition, observationTarget, companionSize);
} else if (currentMode === 'follow-mouse' && mousePosition) {
targetOffset = calculateEyeOffset(currentPosition, currentObservation, currentSize);
} else if (currentMode === 'follow-mouse' && currentMouse) {
// Follow mouse cursor
targetOffset = calculateEyeOffset(companionPosition, mousePosition, companionSize);
targetOffset = calculateEyeOffset(currentPosition, currentMouse, currentSize);
} else if (currentMode === 'forward') {
// Look in movement direction - STRONGER offset for clear visual feedback
targetOffset = {
x: direction === 'right' ? 0.85 : -0.85,
x: currentDirection === 'right' ? 0.85 : -0.85,
y: 0.15, // Slightly down, looking at path ahead
};
} else {
@@ -329,10 +358,13 @@ export function useBlobbiCompanionGaze({
: currentMode === 'forward' ? 0.12
: 0.06;
setEyeOffset(prev => ({
x: smoothLerp(prev.x, targetOffset.x, smoothFactor),
y: smoothLerp(prev.y, targetOffset.y, smoothFactor),
}));
// Update the ref imperatively (no React rerender) — companion visual reads from this
const prevOffset = eyeOffsetRef.current;
const newOffset = {
x: smoothLerp(prevOffset.x, targetOffset.x, smoothFactor),
y: smoothLerp(prevOffset.y, targetOffset.y, smoothFactor),
};
eyeOffsetRef.current = newOffset;
animationRef.current = requestAnimationFrame(animate);
};
@@ -345,10 +377,9 @@ export function useBlobbiCompanionGaze({
animationRef.current = null;
}
};
}, [isActive, direction, companionPosition, mousePosition, companionSize, observationTarget, attentionPosition, entryInspectionDirection]);
}, [isActive]); // ONLY depend on isActive - all other values read from refs
return {
gaze,
eyeOffset,
eyeOffsetRef,
};
}
@@ -5,7 +5,7 @@
* This includes walking, gravity, and drag behavior.
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect, type MutableRefObject } from 'react';
import type {
CompanionState,
@@ -37,6 +37,12 @@ interface UseBlobbiCompanionMotionOptions {
energy: number;
/** Callback when target is reached */
onReachedTarget: () => void;
/**
* Shared ref to sync motion state with state hook.
* This allows the state hook to read live motion values without
* creating a circular dependency.
*/
sharedMotionRef?: MutableRefObject<CompanionMotion>;
}
interface UseBlobbiCompanionMotionResult {
@@ -63,6 +69,7 @@ export function useBlobbiCompanionMotion({
targetX,
energy,
onReachedTarget,
sharedMotionRef,
}: UseBlobbiCompanionMotionOptions): UseBlobbiCompanionMotionResult {
const [motion, setMotion] = useState<CompanionMotion>(() =>
createInitialMotion(initialX, groundY)
@@ -72,6 +79,13 @@ export function useBlobbiCompanionMotion({
const lastTimeRef = useRef<number>(0);
const config = DEFAULT_COMPANION_CONFIG;
// Sync motion to shared ref so state hook can read it
useEffect(() => {
if (sharedMotionRef) {
sharedMotionRef.current = motion;
}
}, [motion, sharedMotionRef]);
// Animation loop
useEffect(() => {
const animate = (time: number) => {
@@ -5,7 +5,7 @@
* This is the state layer - it handles state transitions and timing.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, type MutableRefObject } from 'react';
import type {
CompanionState,
@@ -21,14 +21,20 @@ import { DEFAULT_COMPANION_CONFIG, randomDuration } from '../core/companionConfi
interface UseBlobbiCompanionStateOptions {
/** Whether the companion is active and should be making decisions */
isActive: boolean;
/** Current motion state (used for position/dragging checks) */
motion: CompanionMotion;
/**
* Ref to current motion state (shared with motion hook).
* Using a ref allows state to read live motion values without
* creating a circular dependency between state and motion hooks.
*/
motionRef: MutableRefObject<CompanionMotion>;
/** Movement bounds */
bounds: MovementBounds;
/** Whether to force walking on first activation (after entry) */
forceInitialWalk?: boolean;
/** Current attention target (from UI attention system) */
attentionTarget?: AttentionTarget | null;
/** Whether the companion is sleeping (freezes all decisions/movement) */
isSleeping?: boolean;
}
interface UseBlobbiCompanionStateResult {
@@ -51,10 +57,11 @@ interface UseBlobbiCompanionStateResult {
*/
export function useBlobbiCompanionState({
isActive,
motion,
motionRef,
bounds,
forceInitialWalk = true,
attentionTarget,
isSleeping = false,
}: UseBlobbiCompanionStateOptions): UseBlobbiCompanionStateResult {
const [state, setState] = useState<CompanionState>('idle');
const [direction, setDirection] = useState<CompanionDirection>('right');
@@ -67,14 +74,11 @@ export function useBlobbiCompanionState({
const timerRef = useRef<number | null>(null);
const hasHadInitialWalk = useRef(false);
const motionRef = useRef(motion);
const lastObservationTimeRef = useRef<number>(0);
const config = DEFAULT_COMPANION_CONFIG;
// Keep motion ref updated
useEffect(() => {
motionRef.current = motion;
}, [motion]);
// motionRef is now passed in from the orchestrator and shared with motion hook
// No need for local ref or sync effect - just read directly from motionRef.current
// Clear timer on cleanup
useEffect(() => {
@@ -98,7 +102,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection('right');
setTargetX(targetX);
}, [bounds.maxX]);
}, [bounds.maxX, motionRef]);
/**
* Generate a random observation target on screen.
@@ -132,11 +136,11 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection(newDirection);
setTargetX(targetXPos);
}, [bounds, generateObservationTarget]);
}, [bounds, generateObservationTarget, motionRef]);
// Make a decision about what to do next
const makeDecision = useCallback(() => {
if (!isActive || motionRef.current.isDragging) {
if (!isActive || isSleeping || motionRef.current.isDragging) {
return;
}
@@ -172,7 +176,7 @@ export function useBlobbiCompanionState({
// Schedule next decision
const duration = transition.duration ?? randomDuration(config.idleTime);
timerRef.current = window.setTimeout(makeDecision, duration);
}, [isActive, bounds, state, config, startObservation]);
}, [isActive, isSleeping, bounds, state, config, startObservation, motionRef]);
// Handle reaching target
const onReachedTarget = useCallback(() => {
@@ -207,9 +211,22 @@ export function useBlobbiCompanionState({
}
}, [makeDecision, observationTarget, config.observation.lookDuration]);
// Start decision loop when active
// Force idle when sleeping - stop all movement/decisions immediately
useEffect(() => {
if (isActive && !motionRef.current.isDragging) {
if (isSleeping) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setState('idle');
setTargetX(null);
setObservationTarget(null);
}
}, [isSleeping]);
// Start decision loop when active (and not sleeping)
useEffect(() => {
if (isActive && !isSleeping && !motionRef.current.isDragging) {
// Clear any existing timer
if (timerRef.current) {
clearTimeout(timerRef.current);
@@ -238,19 +255,33 @@ export function useBlobbiCompanionState({
clearTimeout(timerRef.current);
}
};
}, [isActive, forceInitialWalk, startInitialWalk, makeDecision]);
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision, motionRef]);
// Pause decisions while dragging
// We poll isDragging via interval since motionRef changes don't trigger re-renders
useEffect(() => {
if (motion.isDragging) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
if (!isActive) return;
let wasDragging = false;
const checkDragging = () => {
const isDragging = motionRef.current.isDragging;
if (isDragging && !wasDragging) {
// Started dragging
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setState('idle');
setTargetX(null);
}
setState('idle');
setTargetX(null);
}
}, [motion.isDragging]);
wasDragging = isDragging;
};
// Check frequently for drag state changes
const interval = setInterval(checkDragging, 100);
return () => clearInterval(interval);
}, [isActive, motionRef]);
// Handle attention targets - interrupt current behavior when UI elements appear
useEffect(() => {
@@ -19,9 +19,8 @@
* idle -> rising -> inspecting -> entering -> complete
*
* Route change behavior:
* - Cancels current entry immediately
* - Waits 1 second
* - Restarts entry for the new page
* - Companion keeps its current position (no re-entry animation)
* - Only initial mount and companion changes trigger entry animations
*/
import { useState, useEffect, useRef, useCallback } from 'react';
@@ -310,20 +309,11 @@ export function useBlobbiEntryAnimation({
// Random entry type for new companion (fall or rise)
const entryType: EntryType = Math.random() < 0.5 ? 'fall' : 'rise';
startEntry(entryType);
} else if (routeChanged && companionId) {
// Route changed - determine direction for new route
const entryType = getEntryDirection(previousPath, pathname, sidebarOrder);
// Immediately hide Blobbi and cancel current entry
cancelEntry();
setIsHiddenForTransition(true);
// Wait 1 second, then start the new entry animation
routeChangeTimeoutRef.current = setTimeout(() => {
startEntry(entryType);
}, entryConfig.routeChangeRestartDelay);
} else if (routeChanged) {
// Route changed - companion keeps its position, no re-entry animation.
// Just update the ref so future changes compare against the new path.
}
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry, entryConfig.routeChangeRestartDelay]);
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry]);
/**
* Animation loop for FALL entry.
@@ -24,7 +24,7 @@ import {
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiStats,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import { checkItemCategoryNeed, type NeedCheckResult } from '../interaction/needDetection';
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import type { Position } from '../types/companion.types';
@@ -15,17 +15,15 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
import { useBlobbiItemUse } from './useBlobbiItemUse';
import {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
type BlobbiActionsContextInternal,
} from './BlobbiActionsProvider';
} from './BlobbiActionsContextDef';
// Re-export everything from the provider module for backward compatibility
// Re-export types and context from the def module for backward compatibility
export {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
@@ -64,13 +62,13 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
// Create stable useItem function that:
// 1. Uses registered function if available (from BlobbiPage)
// 2. Falls back to built-in hook if no registration
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Try registered function first (from BlobbiPage)
if (context?.registerRef.current) {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using registered item-use function');
}
return context.registerRef.current(itemId, action, quantity);
return context.registerRef.current(itemId, action);
}
// Check if fallback can handle it
@@ -88,7 +86,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using fallback item-use hook');
}
return fallbackItemUse.useItem(itemId, action, quantity);
return fallbackItemUse.useItem(itemId, action);
}, [context, fallbackItemUse]);
// Determine canUseItems: true if registered OR fallback can use
@@ -136,14 +134,14 @@ export function useBlobbiActionsRegistration(
useItemRef.current = useItemFn;
// Create a stable wrapper that delegates to the ref
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
if (!useItemRef.current) {
return {
success: false,
error: 'Item use function not available',
};
}
return useItemRef.current(itemId, action, quantity);
return useItemRef.current(itemId, action);
}, []);
// Update refs and notify only when canUseItems actually changes
@@ -0,0 +1,75 @@
/**
* BlobbiActionsContextDef
*
* Lightweight context definition and types for the Blobbi actions system.
* Separated from the provider component to avoid react-refresh warnings.
*/
import { createContext } from 'react';
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of using an item via the context.
*/
export interface UseItemResult {
/** Whether the use was successful */
success: boolean;
/** Stats that changed (key = stat name, value = delta) */
statsChanged?: Record<string, number>;
/** Error message if failed */
error?: string;
}
/**
* Function signature for using an item (always uses once).
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
) => Promise<UseItemResult>;
/**
* Context value for Blobbi actions (consumer side).
*/
export interface BlobbiActionsContextValue {
/**
* Use an item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
/** Whether an item use operation is currently in progress */
isUsingItem: boolean;
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
* Internal context value (includes registration functions).
*/
export interface BlobbiActionsContextInternal {
/** Register item-use functionality (called by BlobbiPage) */
registerRef: React.MutableRefObject<UseItemFunction | null>;
/** Whether items can currently be used (via registration) */
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
subscribe: (callback: () => void) => () => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
@@ -10,75 +10,13 @@
* BlobbiPage, both of which are lazy-loaded.
*/
import { createContext, useCallback, useMemo, useRef, type ReactNode } from 'react';
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of using an item via the context.
*/
export interface UseItemResult {
/** Whether the use was successful */
success: boolean;
/** Stats that changed (key = stat name, value = delta) */
statsChanged?: Record<string, number>;
/** Error message if failed */
error?: string;
}
/**
* Function signature for using an item.
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
quantity?: number
) => Promise<UseItemResult>;
/**
* Context value for Blobbi actions (consumer side).
*/
export interface BlobbiActionsContextValue {
/**
* Use an inventory item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
/** Whether an item use operation is currently in progress */
isUsingItem: boolean;
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
* Internal context value (includes registration functions).
*/
export interface BlobbiActionsContextInternal {
/** Register item-use functionality (called by BlobbiPage) */
registerRef: React.MutableRefObject<UseItemFunction | null>;
/** Whether items can currently be used (via registration) */
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
subscribe: (callback: () => void) => () => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
import {
BlobbiActionsContext,
type UseItemFunction,
type BlobbiActionsContextInternal,
} from './BlobbiActionsContextDef';
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -100,7 +38,6 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps)
const registerRef = useRef<UseItemFunction | null>(null);
const canUseItemsRegisteredRef = useRef<boolean>(false);
const isUsingItemRegisteredRef = useRef<boolean>(false);
// Subscribers for manual notification
const subscribersRef = useRef<Set<() => void>>(new Set());
@@ -35,6 +35,8 @@ interface CompanionActionMenuProps {
onActionClick: (action: CompanionMenuAction) => void;
/** Callback for clicking outside the menu */
onClickOutside?: () => void;
/** Whether Blobbi is currently sleeping (affects sleep button label) */
isSleeping?: boolean;
}
// Layout configuration
@@ -90,6 +92,7 @@ export function CompanionActionMenu({
selectedAction,
onActionClick,
onClickOutside,
isSleeping = false,
}: CompanionActionMenuProps) {
if (!isOpen) return null;
@@ -122,6 +125,11 @@ export function CompanionActionMenu({
const isSelected = selectedAction === action.id;
const delay = index * MENU_CONFIG.staggerDelay;
// Sleep action toggles label/emoji based on sleeping state
const isSleepAction = action.id === 'sleep';
const displayEmoji = isSleepAction && isSleeping ? '\u2600\uFE0F' : action.emoji;
const displayLabel = isSleepAction && isSleeping ? 'Wake up' : action.label;
return (
<button
key={action.id}
@@ -155,15 +163,15 @@ export function CompanionActionMenu({
e.stopPropagation();
onActionClick(action.id);
}}
title={action.label}
aria-label={action.label}
title={displayLabel}
aria-label={displayLabel}
>
<span
className="text-xl select-none"
role="img"
aria-hidden="true"
>
{action.emoji}
{displayEmoji}
</span>
</button>
);
@@ -1,26 +1,24 @@
/**
* HangingItems
*
* Displays inventory items as hanging elements from the top of the screen.
* Displays available items as hanging elements from the top of the screen.
* Each item appears as a circle connected to the top by a thin vertical line,
* creating a playful, spatial feel.
*
* Items are reusable abilities sourced from the shop catalog — they are
* always available and not consumed on use.
*
* State Model:
* - Container states: hidden → opening → open → closing → hidden
* - Hanging items = available inventory that can still be released
* - Hanging items = catalog items available for the selected action
* - Released/dropped items = instances currently in the world (tracked with unique IDs)
* - Multiple instances of the same item type can exist simultaneously on the ground
*
* Key Design Principle:
* The hanging row represents "releasable quantity" - clicking releases ONE instance
* and immediately decrements the visible quantity. A new hanging copy remains if
* quantity > 1. The released instance tracks separately with a unique instance ID.
*
* Features:
* - Smooth open/close slide animations (items descend/ascend)
* - Thin vertical lines from the top of screen
* - Circular containers for hanging items
* - Click releases item: one instance falls, remaining quantity stays hanging
* - Click releases item: one instance falls to the ground
* - Multiple dropped instances of same item type can exist
* - Contact detection: items auto-use when touching Blobbi
* - Click-to-use: click landed items to use them
@@ -119,7 +117,7 @@ interface HangingItemsProps {
onItemUse?: (item: CompanionItem) => Promise<ItemUseAttemptResult>;
/**
* Callback when an item is collected by Blobbi (contact).
* @deprecated Use onItemUse instead for proper item consumption flow.
* @deprecated Use onItemUse instead for proper item-use flow.
*/
onItemCollected?: (item: CompanionItem) => void;
/**
@@ -156,7 +154,7 @@ const HANGING_CONFIG = {
baseFallDistance: 500,
/** Ground offset from bottom of viewport */
defaultGroundOffset: 40,
/** Size of quantity badge */
/** Size of badge (unused — kept for config consistency) */
badgeSize: 20,
/** Size of landed item hitbox for contact detection */
landedItemSize: 40,
@@ -406,7 +404,7 @@ export function HangingItems({
// Track how many instances of each item type have been released (not yet used)
// Key: item.id (type ID), Value: count of released instances
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
// Counter for generating unique instance IDs
const instanceCounterRef = useRef(0);
@@ -566,7 +564,7 @@ export function HangingItems({
// Start the loop
animationRef.current = requestAnimationFrame(animate);
}, []);
}, [calculateFallDuration]);
// Cleanup animation on unmount
useEffect(() => {
@@ -670,7 +668,7 @@ export function HangingItems({
});
// Also remove from zone tracking
itemsInZoneRef.current.delete(instanceId);
// Decrement the released count for this item type (since the instance is now consumed)
// Decrement the released count for this item type (instance removed from screen)
setReleasedCountByItemId(prev => {
const next = new Map(prev);
const currentCount = next.get(item.id) || 0;
@@ -985,15 +983,9 @@ export function HangingItems({
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
};
// Calculate hanging items with their remaining quantities
// An item appears in the hanging row if (quantity - releasedCount) > 0
const hangingItems = items
.map(item => {
const releasedCount = releasedCountByItemId.get(item.id) || 0;
const remainingQuantity = item.quantity - releasedCount;
return { ...item, quantity: remainingQuantity };
})
.filter(item => item.quantity > 0);
// All items are always visible — they are abilities, not consumable inventory.
// No quantity filtering needed.
const hangingItems = items;
// Should we render the hanging container?
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
@@ -1033,7 +1025,7 @@ export function HangingItems({
>
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
<p className="text-sm text-muted-foreground text-center">
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
</p>
</div>
</div>
@@ -1102,8 +1094,8 @@ export function HangingItems({
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
}}
onClick={() => handleItemClick(item, itemX)}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
title={item.name}
aria-label={`${item.name}. Click to release.`}
>
{/* Item emoji */}
<span
@@ -1114,24 +1106,6 @@ export function HangingItems({
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-semibold rounded-full",
"shadow-md"
)}
style={{
minWidth: HANGING_CONFIG.badgeSize,
height: HANGING_CONFIG.badgeSize,
padding: '0 5px',
}}
>
{item.quantity}
</span>
</button>
</div>
);
+1 -1
View File
@@ -76,10 +76,10 @@ export { useBlobbiItemUse } from './useBlobbiItemUse';
// Context
export {
BlobbiActionsContext,
BlobbiActionsProvider,
useBlobbiActions,
useBlobbiActionsRegistration,
} from './BlobbiActionsContext';
export { BlobbiActionsProvider } from './BlobbiActionsProvider';
// Components
export { CompanionActionMenu } from './CompanionActionMenu';
@@ -10,7 +10,7 @@
* - Returns both boolean need and priority level for potential future use
*/
import type { BlobbiStats } from '@/lib/blobbi';
import type { BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
// ─── Need Thresholds ──────────────────────────────────────────────────────────
@@ -66,7 +66,6 @@ const CATEGORY_TO_PRIMARY_STAT: Record<ShopItemCategory, (keyof BlobbiStats)[]>
toy: ['happiness'],
hygiene: ['hygiene'],
medicine: ['health'],
accessory: [], // Accessories don't address needs
};
// ─── Need Detection Functions ─────────────────────────────────────────────────
+2 -2
View File
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
/**
* Normalized item representation for the companion UI.
* This is a simplified view of inventory items optimized for rendering.
* This is a simplified view of shop catalog items optimized for rendering.
*/
export interface CompanionItem {
/** Unique item ID (matches shop item ID) */
@@ -74,7 +74,7 @@ export interface CompanionItem {
emoji: string;
/** Item category */
category: ShopItemCategory;
/** Quantity available in inventory */
/** Availability (always Infinity — items are reusable abilities) */
quantity: number;
/** Item effects when used */
effect?: ItemEffect;
@@ -27,21 +27,17 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
canUseItemForStage,
getStageRestrictionMessage,
@@ -59,7 +55,7 @@ import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
import type { UseItemFunction } from './BlobbiActionsProvider';
import type { UseItemFunction } from './BlobbiActionsContextDef';
// ─── Configuration ────────────────────────────────────────────────────────────
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const queryClient = useQueryClient();
// Fetch profile if not provided
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
const { profile: fetchedProfile } = useBlobbonautProfile();
const profile = options.profile ?? fetchedProfile;
// Per-item cooldown tracking (ref to avoid re-renders)
@@ -188,29 +184,58 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr, user?.pubkey, profile?.currentCompanion, options.companion]);
// Update companion in query cache
const updateCompanionInCache = useCallback((_event: NostrEvent) => {
// Update companion in query cache - optimistic update for immediate UI refresh
const updateCompanionInCache = useCallback((event: NostrEvent) => {
if (!user?.pubkey || !profile?.currentCompanion) return;
// Invalidate and update the companion query
queryClient.invalidateQueries({
queryKey: ['companion-blobbi', user.pubkey, profile.currentCompanion]
});
// Parse the new event to get the updated companion
const parsed = parseBlobbiEvent(event);
if (!parsed) {
// Fallback to invalidation if parsing fails
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
return;
}
// Optimistically update the blobbi-collection cache
// This ensures the companion layer sees the update immediately
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] } | undefined>(
// Use partial key match - React Query will find any matching query
['blobbi-collection', user.pubkey],
(prev) => {
if (!prev) return prev;
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
},
);
// Also invalidate to trigger background refetch (ensures consistency)
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
// Core mutation for using items
// Core mutation for using items (always uses once)
const mutation = useMutation({
mutationFn: async ({
itemId,
action,
quantity = 1,
}: {
itemId: string;
action: InventoryAction;
quantity?: number;
}): Promise<{ statsChanged: Record<string, number> }> => {
// ─── Validation ───
if (!user?.pubkey) {
@@ -228,11 +253,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error('No companion selected');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
@@ -252,15 +272,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
}
// 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');
@@ -288,17 +299,13 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects ───
// ─── Apply Item Effects (single use) ───
const isEggCompanion = companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = applyStat(currentHealth, healthDelta);
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
@@ -308,15 +315,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
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);
}
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -331,11 +331,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
}
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -383,36 +380,19 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
updateCompanionInCache(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(profile.allTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return { statsChanged };
},
onSuccess: (_, { itemId, action, quantity = 1 }) => {
onSuccess: (_, { itemId, action }) => {
const shopItem = getShopItemById(itemId);
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
});
// Track daily mission progress
@@ -437,7 +417,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
// Wrapper function that matches UseItemFunction signature and includes cooldown check
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Check cooldown first
if (isItemOnCooldown(itemId)) {
if (import.meta.env.DEV) {
@@ -450,7 +430,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
}
try {
const result = await mutation.mutateAsync({ itemId, action, quantity });
const result = await mutation.mutateAsync({ itemId, action });
return {
success: true,
statsChanged: result.statsChanged,
@@ -0,0 +1,183 @@
/**
* useBlobbiSleepToggle — Standalone sleep/wake toggle for the companion.
*
* This hook mirrors the essential logic of BlobbiPage's `handleRest` but
* works independently — it fetches fresh event data from relays, publishes
* the state change, and updates the TanStack Query cache directly.
*
* This eliminates the dependency on BlobbiPage being mounted. The companion
* sleep button works on any page.
*/
import { useCallback, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { trackDailyMissionProgress } from '@/blobbi/actions/lib/daily-mission-tracker';
export interface UseBlobbiSleepToggleResult {
/** Toggle sleep/wake state. Resolves when published. */
toggleSleep: () => Promise<void>;
/** Whether a toggle is currently in progress. */
isPending: boolean;
}
export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const { profile } = useBlobbonautProfile();
// Track pending state via ref to avoid re-renders.
// We only use this for the guard (no duplicate calls), not for rendering.
const pendingRef = useRef(false);
/** Fetch the latest companion event directly from relays. */
const fetchFreshCompanion = useCallback(async (
pubkey: string,
dTag: string,
): Promise<BlobbiCompanion | null> => {
const events = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [pubkey],
'#d': [dTag],
}]);
const validEvents = events
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr]);
/** Optimistically update the TanStack cache so the companion reacts immediately. */
const updateCache = useCallback((event: import('@nostrify/nostrify').NostrEvent, pubkey: string) => {
const parsed = parseBlobbiEvent(event);
if (!parsed) return;
// Optimistically update ALL blobbi-collection queries for this user.
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
// partial matching to find all entries regardless of dList shape.
// No invalidation needed — we fetched fresh from relays before mutating,
// so the optimistic update is the correct state.
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
const matchingQueries = queryClient.getQueriesData<CollectionData>({
queryKey: ['blobbi-collection', pubkey],
});
for (const [queryKey, data] of matchingQueries) {
if (!data) continue;
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
queryClient.setQueryData<CollectionData>(queryKey, {
companionsByD: newCompanionsByD,
companions: Object.values(newCompanionsByD),
});
}
}, [queryClient]);
const toggleSleep = useCallback(async () => {
if (pendingRef.current) return;
if (!user?.pubkey || !profile?.currentCompanion) {
if (import.meta.env.DEV) {
console.warn('[SleepToggle] No user or no current companion');
}
return;
}
pendingRef.current = true;
try {
// Fetch the freshest event from relays (read-modify-write)
const companion = await fetchFreshCompanion(user.pubkey, profile.currentCompanion);
if (!companion) {
toast({
title: 'Cannot change state',
description: 'Companion not found. Please try again.',
variant: 'destructive',
});
return;
}
const isCurrentlySleeping = companion.state === 'sleeping';
const newState = isCurrentlySleeping ? 'active' : 'sleeping';
// Apply accumulated decay before the state change
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now,
});
const nowStr = now.toString();
// Streak updates (putting to sleep/waking counts as care activity)
const streakUpdates = getStreakTagUpdates(companion) ?? {};
const newTags = updateBlobbiTags(companion.allTags, {
state: newState,
hunger: decayResult.stats.hunger.toString(),
happiness: decayResult.stats.happiness.toString(),
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
energy: decayResult.stats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
tags: newTags,
});
// Optimistic cache update + background invalidation
updateCache(event, user.pubkey);
toast({
title: isCurrentlySleeping ? 'Woke up!' : 'Resting...',
description: isCurrentlySleeping
? 'Your Blobbi is now awake and active!'
: 'Your Blobbi is taking a rest.',
});
// Track daily mission progress (only when putting to sleep)
if (!isCurrentlySleeping) {
trackDailyMissionProgress('sleep', 1, user.pubkey);
}
} catch (error) {
console.error('[SleepToggle] Failed:', error);
toast({
title: 'Failed to update',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
pendingRef.current = false;
}
}, [user?.pubkey, profile?.currentCompanion, fetchFreshCompanion, publishEvent, updateCache]);
return {
toggleSleep,
isPending: false, // ref-based, so always false for render — prevents unnecessary re-renders
};
}
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { StorageItem } from '@/lib/blobbi';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
}
/**
* Resolve inventory items for a specific action/category.
* Resolve available items for a specific action/category from the shop catalog.
*
* Items are sourced from the full shop catalog — all items are
* available as reusable abilities/tools, filtered only by stage.
*
* Uses the centralized `canUseItemForStage` function to ensure consistent
* stage-based filtering across all UIs:
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
* filters out all egg-only items from the companion interaction system.
*/
function resolveItemsForAction(
storage: StorageItem[],
action: CompanionMenuAction,
stage: 'egg' | 'baby' | 'adult'
): CompanionItem[] {
@@ -89,13 +90,10 @@ function resolveItemsForAction(
// Sleep action has no items
if (!category) return [];
const allItems = getLiveShopItems();
const items: CompanionItem[] = [];
for (const storageItem of storage) {
if (storageItem.quantity <= 0) continue;
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== category) continue;
// Use centralized stage-based filtering
@@ -104,17 +102,17 @@ function resolveItemsForAction(
// - Food/Toys: only for baby/adult (excluded for eggs)
// - Medicine: must have health effect
// - Hygiene: must have hygiene or happiness effect
const usability = canUseItemForStage(storageItem.itemId, stage);
const usability = canUseItemForStage(shopItem.id, stage);
if (!usability.canUse) {
continue;
}
items.push({
id: storageItem.itemId,
id: shopItem.id,
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: storageItem.quantity,
quantity: Infinity,
effect: shopItem.effect,
});
}
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
return;
}
// Resolve items for this action
const items = resolveItemsForAction(profile.storage, action, stage);
// Resolve items for this action from the catalog (not inventory)
const items = resolveItemsForAction(action, stage);
setMenuState(prev => ({
...prev,
@@ -42,7 +42,6 @@ export interface ItemUseResult {
export type UseItemCallback = (
itemId: string,
action: InventoryAction,
quantity: number
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
/**
@@ -67,14 +66,14 @@ export interface UseCompanionItemUseResult {
isUsingItem: boolean;
/** Get the action type for an item category */
getActionForCategory: (category: ShopItemCategory) => InventoryAction | null;
/** Get the inventory action for a menu action */
/** Get the care action for a menu action */
getInventoryAction: (menuAction: CompanionMenuAction) => InventoryAction | null;
}
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* Map item categories to inventory actions.
* Map item categories to care actions.
* This is the canonical mapping for how items are used.
*/
export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null> = {
@@ -82,18 +81,17 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
toy: 'play',
medicine: 'medicine',
hygiene: 'clean',
accessory: null, // Accessories are cosmetic, not usable
};
/**
* Map menu actions to inventory actions (they match by design).
* Map menu actions to item-based care actions (they match by design).
*/
export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, InventoryAction | null> = {
feed: 'feed',
play: 'play',
medicine: 'medicine',
clean: 'clean',
sleep: null, // Sleep is a special action, not an inventory action
sleep: null, // Sleep is a special action, not item-based
};
// ─── Hook Implementation ──────────────────────────────────────────────────────
@@ -109,8 +107,8 @@ export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, Invent
* Usage:
* ```tsx
* const { useItem, isUsingItem } = useCompanionItemUse({
* onUseItem: async (itemId, action, qty) => {
* return await executeUseItem({ itemId, action, quantity: qty });
* onUseItem: async (itemId, action) => {
* return await executeUseItem({ itemId, action });
* },
* onSuccess: (result) => removeItemFromScreen(result.item),
* onFailure: (result) => keepItemOnScreen(result.item),
@@ -135,7 +133,7 @@ export function useCompanionItemUse({
}, []);
/**
* Get the inventory action for a menu action.
* Get the care action for a menu action.
*/
const getInventoryAction = useCallback((menuAction: CompanionMenuAction): InventoryAction | null => {
return MENU_ACTION_TO_INVENTORY_ACTION[menuAction];
@@ -188,7 +186,7 @@ export function useCompanionItemUse({
try {
// Execute the use callback
const useResult = await onUseItem(item.id, inventoryAction, 1);
const useResult = await onUseItem(item.id, inventoryAction);
if (useResult.success) {
const result: ItemUseResult = {
@@ -5,7 +5,8 @@
* decoupled from app-specific concerns.
*/
import type { BlobbiVisualTraits } from '@/lib/blobbi';
import type { BlobbiVisualTraits, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiState } from '@/blobbi/core/types/blobbi';
// ─── Companion State Machine ──────────────────────────────────────────────────
@@ -176,6 +177,10 @@ export interface CompanionData {
visualTraits: BlobbiVisualTraits;
/** Current energy level (0-100) - affects walking speed */
energy: number;
/** Current stats for status-based visual reactions */
stats: BlobbiStats;
/** Current companion state (e.g., 'sleeping') */
state?: BlobbiState;
/** Adult evolution form type (e.g., 'catti', 'pupp', 'buni') - only for adults */
adultType?: string;
/** Deterministic seed for deriving traits */
@@ -318,8 +323,6 @@ export interface CompanionContextValue {
state: CompanionState;
/** Current motion state */
motion: CompanionMotion;
/** Current gaze state */
gaze: GazeState;
/** Start dragging the companion */
startDrag: () => void;
/** Update drag position */
+6 -7
View File
@@ -44,13 +44,12 @@ export function calculateFloatAnimation(time: number, isMoving: boolean): FloatO
// Multiple frequencies create a bouncy, charming walk
const t = time / 1000; // Convert to seconds for easier frequency tuning
// Primary bob - quick rhythmic bounce (about 2 bounces per second)
const primaryBob = Math.sin(t * 12) * 3;
// Secondary bob - slower wave that adds variation
const secondaryBob = Math.sin(t * 5 + 0.5) * 1.5;
// Slight lift during walk - don't stay on ground
const baseLift = -2;
const yOffset = baseLift + primaryBob * 0.5 + secondaryBob * 0.3;
// Vertical bob oscillates symmetrically around zero so Blobbi's base
// stays anchored to the ground line. The original baseLift = -2 was
// removed because it biased the offset permanently upward.
const primaryBob = Math.sin(t * 12) * 2; // Reduced from *3: less vertical energy
const secondaryBob = Math.sin(t * 5 + 0.5) * 1;
const yOffset = primaryBob * 0.4 + secondaryBob * 0.25;
// Horizontal sway - playful side-to-side motion
const primarySway = Math.sin(t * 6) * 2;
@@ -1,24 +1,31 @@
import { useCallback } from 'react';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { toast } from './useToast';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
BLOBBONAUT_PROFILE_KINDS,
getBlobbonautQueryDValues,
buildMigrationTags,
generatePetId10,
getCanonicalBlobbiD,
isValidBlobbiEvent,
isValidBlobbonautEvent,
isLegacyBlobbonautKind,
migratePetInHas,
updateBlobbonautTags,
parseBlobbiEvent,
parseBlobbonautEvent,
parseStorageTags,
type BlobbiCompanion,
type BlobbonautProfile,
type StorageItem,
} from '@/lib/blobbi';
} from '../lib/blobbi';
/**
* Result of a successful migration.
@@ -52,10 +59,6 @@ export interface EnsureCanonicalOptions {
updateCompanionEvent: (event: NostrEvent) => void;
/** Callback to update localStorage selection if it was pointing to legacy d */
updateStoredSelectedD?: (newD: string) => void;
/** Callback to invalidate companion query */
invalidateCompanion?: () => void;
/** Callback to invalidate profile query */
invalidateProfile?: () => void;
}
/**
@@ -111,6 +114,7 @@ export interface EnsureCanonicalResult {
* ```
*/
export function useBlobbiMigration() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -134,8 +138,6 @@ export function useBlobbiMigration() {
updateProfileEvent,
updateCompanionEvent,
updateStoredSelectedD,
invalidateCompanion,
invalidateProfile,
} = options;
if (!user?.pubkey) {
@@ -190,7 +192,8 @@ export function useBlobbiMigration() {
tags: profileTags,
});
// Update query caches
// Update query caches (optimistic — no invalidation needed since we
// fetch fresh from relays before every mutation)
updateProfileEvent(profileEvent);
updateCompanionEvent(canonicalEvent);
@@ -200,10 +203,6 @@ export function useBlobbiMigration() {
updateStoredSelectedD(canonicalD);
}
// Invalidate queries to refetch fresh data
invalidateCompanion?.();
invalidateProfile?.();
toast({
title: 'Pet upgraded!',
description: `${companion.name} has been migrated to the new format.`,
@@ -237,29 +236,102 @@ export function useBlobbiMigration() {
}
}, [user?.pubkey, publishEvent]);
/**
* Fetch the freshest companion event directly from relays, bypassing cache.
* This is the read step of the read-modify-write pattern.
*/
const fetchFreshCompanion = useCallback(async (
pubkey: string,
dTag: string,
): Promise<BlobbiCompanion | null> => {
const events = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [pubkey],
'#d': [dTag],
}]);
const validEvents = events
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr]);
/**
* Fetch the freshest profile event directly from relays, bypassing cache.
*/
const fetchFreshProfile = useCallback(async (
pubkey: string,
): Promise<BlobbonautProfile | null> => {
const dValues = getBlobbonautQueryDValues(pubkey);
const events = await nostr.query([{
kinds: [...BLOBBONAUT_PROFILE_KINDS],
authors: [pubkey],
'#d': dValues,
}]);
const validEvents = events.filter(isValidBlobbonautEvent);
if (validEvents.length === 0) return null;
// Prefer current kind over legacy
const currentKindEvents = validEvents.filter(e => e.kind === KIND_BLOBBONAUT_PROFILE);
if (currentKindEvents.length > 0) {
const sorted = currentKindEvents.sort((a, b) => b.created_at - a.created_at);
return parseBlobbonautEvent(sorted[0]) ?? null;
}
const legacyKindEvents = validEvents.filter(e => isLegacyBlobbonautKind(e));
if (legacyKindEvents.length > 0) {
const sorted = legacyKindEvents.sort((a, b) => b.created_at - a.created_at);
return parseBlobbonautEvent(sorted[0]) ?? null;
}
return null;
}, [nostr]);
/**
* Ensure a Blobbi is in canonical format before performing an action.
*
* CRITICAL: This fetches fresh data from relays (read-modify-write pattern)
* instead of using potentially stale cache data. This prevents state resets
* caused by publishing over a newer event with stale cached data.
*
* If the companion is legacy, it will be migrated first.
* Returns the canonical companion to use for the action.
*
* Flow:
* 1. Check if Blobbi is legacy
* 2. If legacy: migrate Blobbi
* 3. Return the resolved canonical Blobbi
* 1. Fetch fresh companion + profile from relays
* 2. Check if Blobbi is legacy
* 3. If legacy: migrate Blobbi
* 4. Return the resolved canonical Blobbi with fresh data
*
* All interaction handlers should call this before publishing events.
*/
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
options: EnsureCanonicalOptions
): Promise<EnsureCanonicalResult | null> => {
const { companion, profile } = options;
if (!user?.pubkey) return null;
const { companion: cachedCompanion, profile: cachedProfile } = options;
// Fetch fresh data from relays (read step of read-modify-write)
const [freshCompanion, freshProfile] = await Promise.all([
fetchFreshCompanion(user.pubkey, cachedCompanion.d),
fetchFreshProfile(user.pubkey),
]);
// Use fresh data, falling back to cached only if relay fetch returned nothing
const companion = freshCompanion ?? cachedCompanion;
const profile = freshProfile ?? cachedProfile;
// Check if the companion needs migration
if (companion.isLegacy) {
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
const migrationResult = await migrateLegacyBlobbi(options);
// Use fresh data in migration options
const migrationOptions = { ...options, companion, profile };
const migrationResult = await migrateLegacyBlobbi(migrationOptions);
if (!migrationResult) {
// Migration failed, cannot proceed with action
@@ -279,7 +351,7 @@ export function useBlobbiMigration() {
};
}
// Companion is already canonical, return profile as-is
// Companion is already canonical, return fresh data
return {
wasMigrated: false,
companion,
@@ -288,7 +360,7 @@ export function useBlobbiMigration() {
profileAllTags: profile.allTags,
profileStorage: profile.storage,
};
}, [migrateLegacyBlobbi]);
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
return {
/** Migrate a legacy Blobbi to canonical format */
@@ -3,13 +3,13 @@ import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
KIND_BLOBBI_STATE,
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiCompanion,
} from '@/lib/blobbi';
} from '../lib/blobbi';
/** Maximum number of d-tags per query chunk to avoid relay issues */
const CHUNK_SIZE = 20;
@@ -132,7 +132,10 @@ export function useBlobbisCollection(dList: string[] | undefined) {
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Helper to invalidate and refetch after publishing
// Helper to invalidate and refetch after publishing.
// NOTE: In most mutation paths this is no longer needed — the read-modify-write
// pattern (fetch fresh → mutate → optimistic update) keeps the cache correct.
// Only call this when the set of d-tags itself changes (e.g. adoption, deletion).
const invalidate = useCallback(() => {
if (user?.pubkey && queryKeyDTags) {
queryClient.invalidateQueries({
@@ -141,36 +144,38 @@ export function useBlobbisCollection(dList: string[] | undefined) {
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Update a single companion event in the query cache (optimistic update)
// Update a single companion event in the query cache (optimistic update).
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
// and companion layer cache stay in sync (they use different d-tag lists).
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (!parsed || !user?.pubkey) return;
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
['blobbi-collection', user.pubkey, queryKeyDTags],
(prev) => {
if (!prev) {
return {
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
};
}
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
}
);
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
const matchingQueries = queryClient.getQueriesData<CollectionData>({
queryKey: ['blobbi-collection', user.pubkey],
});
for (const [queryKey, data] of matchingQueries) {
if (!data) continue;
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
queryClient.setQueryData<CollectionData>(queryKey, {
companionsByD: newCompanionsByD,
companions: Object.values(newCompanionsByD),
});
}
// If no existing queries matched (first load), set our own query key
if (matchingQueries.length === 0) {
queryClient.setQueryData<CollectionData>(
['blobbi-collection', user.pubkey, queryKeyDTags],
{
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
},
);
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Memoize return values for stability
@@ -190,7 +195,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
isStale: query.isStale,
/** Query error if any */
error: query.error,
/** Invalidate and refetch the collection */
/** Invalidate and refetch the collection (use only when d-tag set changes, not after mutations) */
invalidate,
/** Optimistically update a single companion in the cache */
updateCompanionEvent,
@@ -12,8 +12,8 @@
import { useState, useEffect, useMemo } from 'react';
import type { BlobbiCompanion, BlobbiStats } from '@/lib/blobbi';
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/lib/blobbi-decay';
import type { BlobbiCompanion, BlobbiStats } from '../lib/blobbi';
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/blobbi/core/lib/blobbi-decay';
/** UI refresh interval in milliseconds (60 seconds) */
const UI_REFRESH_INTERVAL_MS = 60_000;
@@ -50,32 +50,6 @@ export interface DecayInput {
// ─── Constants: Decay Rates ───────────────────────────────────────────────────
/**
* Egg stage decay rates (per hour).
*
* Design goal: Needs attention every 2-3 hours.
*
* Notes:
* - hunger and energy are fixed at 100 for eggs
* - hygiene decays at 8/hr reaches warning (75) in ~3.1 hours
* - health has conditional decay based on hygiene
* - happiness depends on health and hygiene state
*/
const EGG_DECAY = {
hygiene: -8.0, // Base hygiene decay
health: {
base: -1.0, // Base health decay
hygieneBelow70: -2.0, // Extra if hygiene < 70
hygieneBelow40: -3.0, // Extra if hygiene < 40
},
happiness: {
// Happiness is calculated after health/hygiene are updated
healthyAndClean: 2.0, // health >= 70 AND hygiene >= 70
moderate: -2.0, // health >= 40 AND hygiene >= 40
poor: -4.0, // otherwise
},
} as const;
/**
* Baby stage decay rates (per hour).
*
@@ -214,6 +188,23 @@ function hoursFromSeconds(seconds: number): number {
return seconds / 3600;
}
/**
* Round a stat delta toward zero (truncate fractional part).
*
* CRITICAL: We use Math.trunc() instead of Math.floor() because:
* - Math.floor(-0.5) = -1 (rounds down, applying decay even with tiny elapsed time)
* - Math.trunc(-0.5) = 0 (rounds toward zero, no decay applied)
*
* This prevents the bug where any action within seconds of the last action
* would still apply -1 decay even though insufficient time passed.
*
* @param delta - Calculated stat change (can be positive or negative)
* @returns Integer delta to apply
*/
function roundDelta(delta: number): number {
return Math.trunc(delta);
}
// ─── Stage-Specific Decay Calculators ─────────────────────────────────────────
/**
@@ -224,44 +215,15 @@ function hoursFromSeconds(seconds: number): number {
*/
function calculateEggDecay(
stats: Partial<BlobbiStats>,
elapsedHours: number
_elapsedHours: number
): BlobbiStats {
// Get current values
let hygiene = getStat(stats, 'hygiene');
let health = getStat(stats, 'health');
let happiness = getStat(stats, 'happiness');
// Calculate hygiene decay first
const hygieneDelta = EGG_DECAY.hygiene * elapsedHours;
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
// Calculate health decay (depends on current hygiene)
let healthDelta = EGG_DECAY.health.base * elapsedHours;
if (hygiene < 70) {
healthDelta += EGG_DECAY.health.hygieneBelow70 * elapsedHours;
}
if (hygiene < 40) {
healthDelta += EGG_DECAY.health.hygieneBelow40 * elapsedHours;
}
health = clamp(health + Math.floor(healthDelta));
// Calculate happiness (depends on updated health and hygiene)
let happinessDelta: number;
if (health >= 70 && hygiene >= 70) {
happinessDelta = EGG_DECAY.happiness.healthyAndClean * elapsedHours;
} else if (health >= 40 && hygiene >= 40) {
happinessDelta = EGG_DECAY.happiness.moderate * elapsedHours;
} else {
happinessDelta = EGG_DECAY.happiness.poor * elapsedHours;
}
happiness = clamp(happiness + Math.floor(happinessDelta));
// Eggs do not decay — all stats remain fixed until hatching.
return {
hunger: 100, // Fixed for eggs
energy: 100, // Fixed for eggs
hygiene,
health,
happiness,
hunger: 100,
energy: 100,
hygiene: getStat(stats, 'hygiene'),
health: getStat(stats, 'health'),
happiness: getStat(stats, 'happiness'),
};
}
@@ -289,10 +251,10 @@ function calculateBabyDecay(
const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + Math.floor(hungerDelta));
happiness = clamp(happiness + Math.floor(happinessDelta));
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
energy = clamp(energy + Math.floor(energyDelta));
hunger = clamp(hunger + roundDelta(hungerDelta));
happiness = clamp(happiness + roundDelta(happinessDelta));
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
energy = clamp(energy + roundDelta(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = BABY_DECAY.health.base * elapsedHours;
@@ -319,7 +281,7 @@ function calculateBabyDecay(
healthDelta += BABY_DECAY.health.regenRate * elapsedHours;
}
health = clamp(health + Math.floor(healthDelta));
health = clamp(health + roundDelta(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
@@ -348,10 +310,10 @@ function calculateAdultDecay(
const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + Math.floor(hungerDelta));
happiness = clamp(happiness + Math.floor(happinessDelta));
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
energy = clamp(energy + Math.floor(energyDelta));
hunger = clamp(hunger + roundDelta(hungerDelta));
happiness = clamp(happiness + roundDelta(happinessDelta));
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
energy = clamp(energy + roundDelta(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = ADULT_DECAY.health.base * elapsedHours;
@@ -378,7 +340,7 @@ function calculateAdultDecay(
healthDelta += ADULT_DECAY.health.regenRate * elapsedHours;
}
health = clamp(health + Math.floor(healthDelta));
health = clamp(health + roundDelta(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
@@ -391,8 +353,8 @@ function calculateAdultDecay(
* This is a pure, deterministic function that:
* 1. Calculates elapsed time from lastDecayAt to now
* 2. Applies stage-specific decay rates
* 3. Floors all stat deltas before application
* 4. Clamps final stats to 0-100 range
* 3. Truncates all stat deltas toward zero before application (prevents micro-decay from tiny elapsed times)
* 4. Clamps final stats to 1-100 range
* 5. Returns updated stats without side effects
*
* @param input - Decay input parameters from persisted state
@@ -518,6 +480,12 @@ export function getStatsNeedingAttention(
// ─── Visible Stats Helper ─────────────────────────────────────────────────────
/**
* Visibility threshold: stats at or above this value are hidden in the UI.
* Only stats below this threshold are displayed.
*/
export const STAT_VISIBILITY_THRESHOLD = 70;
/**
* Get the stats that should be visible for a given stage.
* Eggs only show health, hygiene, happiness.
@@ -532,15 +500,18 @@ export function getVisibleStats(stage: BlobbiStage): (keyof BlobbiStats)[] {
/**
* Get visible stats with their values for display.
* Stats at or above STAT_VISIBILITY_THRESHOLD are filtered out.
*/
export function getVisibleStatsWithValues(
stage: BlobbiStage,
stats: Partial<BlobbiStats>
): Array<{ stat: keyof BlobbiStats; value: number; status: 'critical' | 'warning' | 'normal' }> {
const visibleStats = getVisibleStats(stage);
return visibleStats.map(stat => ({
stat,
value: stats[stat] ?? 100,
status: getStatStatus(stage, stat, stats[stat] ?? 100),
}));
return visibleStats
.map(stat => ({
stat,
value: stats[stat] ?? 100,
status: getStatStatus(stage, stat, stats[stat] ?? 100),
}))
.filter(entry => entry.value < STAT_VISIBILITY_THRESHOLD);
}
@@ -110,7 +110,7 @@ export function toEggGraphicVisualBlobbi(
companion: BlobbiCompanion,
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
): EggVisualBlobbi {
const { visualTraits, stage, allTags } = companion;
const { visualTraits, stage, allTags = [] } = companion;
return {
// Colors pass through directly (already CSS hex values)
@@ -34,7 +34,7 @@ export type TagCategory =
| 'state' // Lifecycle state (stage, state, timestamps)
| 'progression' // Progress tracking (experience, care_streak)
| 'task' // Task system (task, task_completed, state_started_at)
| 'social' // Social flags (visible_to_others, breeding_ready)
| 'social' // Social flags (breeding_ready)
| 'evolution' // Evolution-specific (adult_type)
| 'extension'; // Extension tags (theme, crossover_app)
@@ -509,19 +509,6 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [
// ═══════════════════════════════════════════════════════════════════════════
// SOCIAL / FLAG TAGS
// ═══════════════════════════════════════════════════════════════════════════
{
tag: 'visible_to_others',
description: 'Whether the Blobbi is publicly visible to other users',
category: 'social',
required: false,
stages: ['egg', 'baby', 'adult'],
persistent: true,
source: 'user',
regenerable: false,
format: 'true | false',
defaultValue: 'true',
notes: 'User preference. Persists across stages.',
},
{
tag: 'breeding_ready',
description: 'Whether the Blobbi is eligible for breeding',
@@ -253,8 +253,6 @@ export interface BlobbiCompanion {
lastDecayAt: number | undefined;
/** Stats (0-100) */
stats: Partial<BlobbiStats>;
/** Whether the Blobbi is publicly visible */
visibleToOthers: boolean;
/** Generation number */
generation: number | undefined;
/** Breeding eligibility */
@@ -290,7 +288,7 @@ export interface BlobbiCompanion {
}
/**
* Stored item in user's profile inventory
* Stored item in user's profile (from purchases)
*/
export interface StorageItem {
itemId: string; // Must match a ShopItem.id
@@ -318,7 +316,7 @@ export interface BlobbonautProfile {
coins: number;
/** Petting level (interaction counter) */
pettingLevel: number;
/** Purchased items inventory */
/** Purchased items storage */
storage: StorageItem[];
/** All tags preserved for republishing */
allTags: string[][];
@@ -939,7 +937,6 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
hygiene: parseNumericTag(tags, 'hygiene'),
energy: parseNumericTag(tags, 'energy'),
},
visibleToOthers: parseBooleanTag(tags, 'visible_to_others', true),
generation: parseNumericTag(tags, 'generation'),
breedingReady: parseBooleanTag(tags, 'breeding_ready', false),
experience: parseNumericTag(tags, 'experience'),
@@ -979,7 +976,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
event,
d,
currentCompanion: getTagValue(tags, 'current_companion'),
onboardingDone: parseBooleanTag(tags, 'onboarding_done', false),
onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false)
|| parseBooleanTag(tags, 'onboarding_done', false),
name: getTagValue(tags, 'name'),
has: getTagValues(tags, 'has'),
coins: parseNumericTag(tags, 'coins') ?? 0,
@@ -999,7 +997,7 @@ export function buildBlobbonautTags(pubkey: string): string[][] {
return [
['d', getCanonicalBlobbonautD(pubkey)],
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
['onboarding_done', 'false'],
['blobbi_onboarding_done', 'false'],
['pettingLevel', '0'],
];
}
@@ -1036,7 +1034,6 @@ export function buildEggTags(
['stage', 'egg'],
['state', 'active'],
['seed', seed],
['visible_to_others', 'true'],
['generation', '1'],
['breeding_ready', 'false'],
['experience', '0'],
@@ -1084,7 +1081,7 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([
// Progression tags
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
// Social/flag tags
'visible_to_others', 'breeding_ready',
'breeding_ready',
// Task system tags (removed after stage transitions)
'state_started_at', 'task', 'task_completed',
// Evolution tags (adult only)
@@ -1142,7 +1139,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
* These tags are controlled by the application and may be overwritten.
*/
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
'd', 'b', 'name', 'current_companion', 'onboarding_done', 'has', 'storage',
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
// Legacy player progress tags (preserved for compatibility)
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
@@ -1369,17 +1366,44 @@ export function profileNeedsPettingLevelNormalization(profile: BlobbonautProfile
}
/**
* Build updated tags for normalizing a profile to include pettingLevel.
* Preserves all existing tags and adds pettingLevel: 0 if missing.
* Check if a profile uses the legacy `onboarding_done` tag instead of the
* new `blobbi_onboarding_done` tag. Returns true if migration is needed.
*/
export function profileNeedsOnboardingTagMigration(profile: BlobbonautProfile): boolean {
const hasNewTag = profile.allTags.some(([name]) => name === 'blobbi_onboarding_done');
const hasOldTag = profile.allTags.some(([name]) => name === 'onboarding_done');
// Needs migration if: has old tag but not the new one
return !hasNewTag && hasOldTag;
}
/**
* Build updated tags for normalizing a profile.
* Handles:
* - Adding pettingLevel: 0 if missing
* - Migrating onboarding_done blobbi_onboarding_done
*
* Preserves all existing tags except the ones being migrated.
*/
export function buildNormalizedProfileTags(profile: BlobbonautProfile): string[][] {
if (!profileNeedsPettingLevelNormalization(profile)) {
return profile.allTags;
let tags = profile.allTags;
let changed = false;
// Normalize pettingLevel
if (profileNeedsPettingLevelNormalization(profile)) {
tags = updateBlobbonautTags(tags, { pettingLevel: '0' });
changed = true;
}
return updateBlobbonautTags(profile.allTags, {
pettingLevel: '0',
});
// Migrate onboarding_done → blobbi_onboarding_done
if (profileNeedsOnboardingTagMigration(profile)) {
const oldValue = tags.find(([name]) => name === 'onboarding_done')?.[1] ?? 'false';
// Remove old tag, add new tag
tags = tags.filter(([name]) => name !== 'onboarding_done');
tags = updateBlobbonautTags(tags, { blobbi_onboarding_done: oldValue });
changed = true;
}
return changed ? tags : profile.allTags;
}
// ─── Query Helpers ────────────────────────────────────────────────────────────
@@ -1463,7 +1487,7 @@ export function buildMigrationTags(
// Progression tags
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
// Social/flag tags
'visible_to_others', 'generation', 'breeding_ready',
'generation', 'breeding_ready',
// Personality tags (preserve if they exist, do NOT generate)
'personality', 'trait', 'favorite_food', 'voice_type', 'mood',
// Evolution tags
+242
View File
@@ -0,0 +1,242 @@
// src/types/blobbi.ts
/**
* Minimal, clean Blobbi domain types for the new project.
*
* Goal:
* - keep the model small and portable
* - support egg / baby / adult rendering
* - support sleep state
* - support visual customization
* - avoid dragging old project complexity into the new app
*/
/* ────────────────────────────────────────────────────────────────────────── *
* Core lifecycle / state
* ────────────────────────────────────────────────────────────────────────── */
export type BlobbiLifeStage = 'egg' | 'baby' | 'adult';
export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving';
/* ────────────────────────────────────────────────────────────────────────── *
* Visual traits
* ────────────────────────────────────────────────────────────────────────── */
export type BlobbiPattern = 'solid' | 'spotted' | 'striped' | 'gradient';
export type BlobbiSpecialMark = 'none' | 'star' | 'heart' | 'sparkle' | 'blush';
export type BlobbiSize = 'small' | 'medium' | 'large';
export interface BlobbiVisualTraits {
/**
* Main body/base color.
* Example: "#8B5CF6"
*/
baseColor?: string;
/**
* Secondary/accent color, usually used in gradients or details.
*/
secondaryColor?: string;
/**
* Eye / pupil color.
*/
eyeColor?: string;
/**
* Optional pattern used by egg or future visual systems.
*/
pattern?: BlobbiPattern;
/**
* Optional visual mark.
*/
specialMark?: BlobbiSpecialMark;
/**
* Optional size hint for rendering.
*/
size?: BlobbiSize;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Basic stats
* Keep only what is useful right now for UI and simple interactions.
* ────────────────────────────────────────────────────────────────────────── */
export interface BlobbiStats {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Stage-specific fields
* ────────────────────────────────────────────────────────────────────────── */
export interface BlobbiEggData {
incubationTime?: number;
incubationProgress?: number;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface BlobbiBabyData {
// Reserved for future baby-specific fields
}
export interface BlobbiAdultData {
evolutionForm?: string;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Main Blobbi entity
* ────────────────────────────────────────────────────────────────────────── */
export interface Blobbi extends BlobbiVisualTraits {
/**
* Stable unique identifier.
*/
id: string;
/**
* Display name.
*/
name: string;
/**
* Current lifecycle stage.
*/
lifeStage: BlobbiLifeStage;
/**
* Current activity state.
*/
state: BlobbiState;
/**
* Optional convenience boolean for UI code that still expects this.
* Prefer using `state === "sleeping"` in new code.
*/
isSleeping?: boolean;
/**
* Basic gameplay / care stats.
*/
stats: BlobbiStats;
/**
* Ownership / identity metadata.
*/
ownerPubkey?: string;
seed?: string;
/**
* Timestamps.
* Keep them simple for now; decide later whether the project will
* standardize on seconds or milliseconds everywhere.
*/
createdAt?: number;
birthTime?: number;
hatchTime?: number;
lastInteraction?: number;
/**
* Progression.
*/
experience?: number;
generation?: number;
careStreak?: number;
crossoverApp?: string | null;
themeVariant?: string;
/**
* Optional raw tags for Nostr-backed or metadata-driven rendering.
*/
tags?: string[][];
/**
* Optional stage-specific buckets.
* This keeps the root model clean while leaving room to grow.
*/
egg?: BlobbiEggData;
baby?: BlobbiBabyData;
adult?: BlobbiAdultData;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Defaults / helpers
* ────────────────────────────────────────────────────────────────────────── */
export const DEFAULT_BLOBBI_STATS: BlobbiStats = {
hunger: 100,
happiness: 100,
health: 100,
hygiene: 100,
energy: 100,
};
export const DEFAULT_BLOBBI_STATE: BlobbiState = 'active';
export const DEFAULT_BLOBBI_LIFE_STAGE: BlobbiLifeStage = 'egg';
export function createDefaultBlobbi(overrides: Partial<Blobbi> = {}): Blobbi {
const state = overrides.state ?? DEFAULT_BLOBBI_STATE;
return {
id: overrides.id ?? 'blobbi-1',
name: overrides.name ?? 'Blobbi',
lifeStage: overrides.lifeStage ?? DEFAULT_BLOBBI_LIFE_STAGE,
state,
isSleeping: overrides.isSleeping ?? state === 'sleeping',
stats: overrides.stats ?? { ...DEFAULT_BLOBBI_STATS },
baseColor: overrides.baseColor,
secondaryColor: overrides.secondaryColor,
eyeColor: overrides.eyeColor,
pattern: overrides.pattern,
specialMark: overrides.specialMark,
size: overrides.size,
ownerPubkey: overrides.ownerPubkey,
seed: overrides.seed,
createdAt: overrides.createdAt,
birthTime: overrides.birthTime,
hatchTime: overrides.hatchTime,
lastInteraction: overrides.lastInteraction,
experience: overrides.experience ?? 0,
generation: overrides.generation ?? 1,
careStreak: overrides.careStreak ?? 0,
crossoverApp: overrides.crossoverApp ?? null,
themeVariant: overrides.themeVariant,
tags: overrides.tags ?? [],
egg: overrides.egg,
baby: overrides.baby,
adult: overrides.adult,
};
}
/* ────────────────────────────────────────────────────────────────────────── *
* Type guards
* ────────────────────────────────────────────────────────────────────────── */
export function isEggBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'egg';
}
export function isBabyBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'baby';
}
export function isAdultBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'adult';
}
export function isBlobbiSleeping(blobbi: Blobbi): boolean {
return blobbi.state === 'sleeping' || blobbi.isSleeping === true;
}
+7 -18
View File
@@ -22,7 +22,7 @@ import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion, BlobbiStage, BlobbiState, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbiStage, BlobbiState, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -58,8 +58,6 @@ export interface BlobbiDevUpdates {
breedingReady?: boolean;
/** Generation number */
generation?: number;
/** Visibility to others */
visibleToOthers?: boolean;
}
// ─── Stat Presets ─────────────────────────────────────────────────────────────
@@ -189,7 +187,6 @@ export function BlobbiDevEditor({
const [careStreak, setCareStreak] = useState(companion.careStreak ?? 0);
const [breedingReady, setBreedingReady] = useState(companion.breedingReady);
const [generation, setGeneration] = useState(companion.generation ?? 1);
const [visibleToOthers, setVisibleToOthers] = useState(companion.visibleToOthers);
// Reset state when companion changes or modal opens
const resetToCompanion = useCallback(() => {
@@ -207,7 +204,6 @@ export function BlobbiDevEditor({
setCareStreak(companion.careStreak ?? 0);
setBreedingReady(companion.breedingReady);
setGeneration(companion.generation ?? 1);
setVisibleToOthers(companion.visibleToOthers);
}, [companion]);
// Check if there are any changes
@@ -224,10 +220,9 @@ export function BlobbiDevEditor({
experience !== (companion.experience ?? 0) ||
careStreak !== (companion.careStreak ?? 0) ||
breedingReady !== companion.breedingReady ||
generation !== (companion.generation ?? 1) ||
visibleToOthers !== companion.visibleToOthers
generation !== (companion.generation ?? 1)
);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion]);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion]);
// Apply preset
const applyPreset = useCallback((preset: StatPreset) => {
@@ -270,11 +265,10 @@ export function BlobbiDevEditor({
if (careStreak !== (companion.careStreak ?? 0)) updates.careStreak = careStreak;
if (breedingReady !== companion.breedingReady) updates.breedingReady = breedingReady;
if (generation !== (companion.generation ?? 1)) updates.generation = generation;
if (visibleToOthers !== companion.visibleToOthers) updates.visibleToOthers = visibleToOthers;
await onApply(updates);
onClose();
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion, onApply, onClose]);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion, onApply, onClose]);
// Handle close
const handleClose = useCallback(() => {
@@ -533,15 +527,10 @@ export function BlobbiDevEditor({
onCheckedChange={setBreedingReady}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-sm">Visible to Others</Label>
<Switch
checked={visibleToOthers}
onCheckedChange={setVisibleToOthers}
/>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
+3 -1
View File
@@ -9,7 +9,7 @@ import { Theater } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useEmotionDev } from './EmotionDevContext';
import { useEmotionDev } from './useEmotionDev';
import { isLocalhostDev } from './index';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
@@ -32,6 +32,8 @@ interface BlobbiEmotionPanelProps {
const EMOTIONS: Array<{ value: BlobbiEmotion; label: string; emoji: string }> = [
{ value: 'neutral', label: 'Default', emoji: '😊' },
{ value: 'sad', label: 'Sad', emoji: '😢' },
{ value: 'boring', label: 'Boring', emoji: '😑' },
{ value: 'dirty', label: 'Dirty', emoji: '💩' },
{ value: 'happy', label: 'Extra Happy', emoji: '😄' },
{ value: 'angry', label: 'Angry', emoji: '😠' },
{ value: 'surprised', label: 'Surprised', emoji: '😲' },
+2 -54
View File
@@ -10,26 +10,10 @@
* - Is purely for visual testing/debugging
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { useState, useCallback, type ReactNode } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
// ─── Types ────────────────────────────────────────────────────────────────────
interface EmotionDevContextValue {
/** Current dev emotion override (null = use default/neutral) */
devEmotion: BlobbiEmotion | null;
/** Set the dev emotion override */
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
/** Clear the dev emotion override (back to neutral) */
clearDevEmotion: () => void;
/** Whether dev emotion is active */
isDevEmotionActive: boolean;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
import { EmotionDevContext, type EmotionDevContextValue } from './useEmotionDev';
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -68,40 +52,4 @@ export function EmotionDevProvider({ children }: EmotionDevProviderProps) {
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to access dev emotion state.
* Returns null values in production for safety.
*/
export function useEmotionDev(): EmotionDevContextValue {
const context = useContext(EmotionDevContext);
// Outside localhost dev or if no provider, return safe defaults
if (!isLocalhostDev() || !context) {
return {
devEmotion: null,
setDevEmotion: () => {},
clearDevEmotion: () => {},
isDevEmotionActive: false,
};
}
return context;
}
/**
* Get the effective emotion for a Blobbi.
* In dev mode with an override, returns the dev emotion.
* Otherwise returns the provided emotion or 'neutral'.
*/
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
const { devEmotion, isDevEmotionActive } = useEmotionDev();
// Dev override takes precedence (only in localhost dev)
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
return devEmotion;
}
return baseEmotion ?? 'neutral';
}
+2 -1
View File
@@ -35,5 +35,6 @@ export { BlobbiDevEditor, type BlobbiDevUpdates } from './BlobbiDevEditor';
export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
// Emotion testing tools
export { EmotionDevProvider, useEmotionDev, useEffectiveEmotion } from './EmotionDevContext';
export { EmotionDevProvider } from './EmotionDevContext';
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
+3 -17
View File
@@ -7,15 +7,15 @@
* IMPORTANT: This hook should only be used in development mode.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
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, BlobbiStage } from '@/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/blobbi/core/lib/blobbi';
import type { BlobbiDevUpdates } from './BlobbiDevEditor';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -24,8 +24,6 @@ interface UseBlobbiDevUpdateParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
interface DevUpdateResult {
@@ -50,11 +48,9 @@ function generateBlobbiContent(name: string, stage: BlobbiStage): string {
export function useBlobbiDevUpdate({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiDevUpdateParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updates: BlobbiDevUpdates): Promise<DevUpdateResult> => {
@@ -140,10 +136,6 @@ export function useBlobbiDevUpdate({
tagUpdates.generation = updates.generation.toString();
changedFields.push('generation');
}
if (updates.visibleToOthers !== undefined) {
tagUpdates.visible = updates.visibleToOthers ? 'true' : 'false';
changedFields.push('visible');
}
// Always update last_interaction and last_decay_at
tagUpdates.last_interaction = now.toString();
@@ -173,12 +165,6 @@ export function useBlobbiDevUpdate({
// ─── Update Caches ───
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate collection queries
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
return {
previousStage: companion.stage,
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface EmotionDevContextValue {
/** Current dev emotion override (null = use default/neutral) */
devEmotion: BlobbiEmotion | null;
/** Set the dev emotion override */
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
/** Clear the dev emotion override (back to neutral) */
clearDevEmotion: () => void;
/** Whether dev emotion is active */
isDevEmotionActive: boolean;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
// ─── Hooks ────────────────────────────────────────────────────────────────────
/**
* Hook to access dev emotion state.
* Returns null values in production for safety.
*/
export function useEmotionDev(): EmotionDevContextValue {
const context = useContext(EmotionDevContext);
// Outside localhost dev or if no provider, return safe defaults
if (!isLocalhostDev() || !context) {
return {
devEmotion: null,
setDevEmotion: () => {},
clearDevEmotion: () => {},
isDevEmotionActive: false,
};
}
return context;
}
/**
* Get the effective emotion for a Blobbi.
* In dev mode with an override, returns the dev emotion.
* Otherwise returns the provided emotion or 'neutral'.
*/
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
const { devEmotion, isDevEmotionActive } = useEmotionDev();
// Dev override takes precedence (only in localhost dev)
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
return devEmotion;
}
return baseEmotion ?? 'neutral';
}
+788 -143
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import type { EggVisualBlobbi } from '../types/egg.types';
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
@@ -12,6 +12,42 @@ import { cn } from '../lib/cn';
*/
export type EggReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
/**
* Status effects for egg-stage visual feedback.
* These are simpler than adult/baby expressions since eggs don't have faces.
*/
export interface EggStatusEffects {
/** Dirty state: shows sweat droplet and dust underneath */
dirty?: boolean;
/** Health state: shows floating dizzy spirals around egg */
sick?: boolean;
/** Happy state: shows subtle sparkles around egg */
happy?: boolean;
}
/**
* Tour visual states that the egg can display.
* Driven by the tour orchestration layer, not by EggGraphic itself.
*
* - idle: no tour effects
* - show_hatch_card: initial crack visible + auto-wiggle every 2.5s
* - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap
* - crack_stage_1: crack expands (click 1)
* - crack_stage_2: crack expands more (click 2)
* - crack_stage_3: final crack (click 3)
* - opening: shell splits open
* - hatching: bright light + reveal
*/
export type EggTourVisualState =
| 'idle'
| 'show_hatch_card'
| 'glowing_waiting_click'
| 'crack_stage_1'
| 'crack_stage_2'
| 'crack_stage_3'
| 'opening'
| 'hatching';
interface EggGraphicProps {
blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties
sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size
@@ -21,6 +57,46 @@ interface EggGraphicProps {
cracking?: boolean;
warmth?: number; // 0-100, affects the glow (fallback if no blobbi)
forceInlineSvg?: boolean; // New prop to guarantee inline SVG
/** Status effects for egg-stage visual feedback */
statusEffects?: EggStatusEffects;
/** Tour visual state - driven externally by the tour orchestration layer */
tourVisualState?: EggTourVisualState;
/** Callback when the egg is clicked during an interactive tour step */
onTourEggClick?: () => void;
}
/**
* Create a spiral path for sick/dizzy effects.
* Generates a true Archimedean spiral that starts from center and winds outward.
* Based on the spiral algorithm from eyes/effects.ts.
*
* @param cx - Center X coordinate
* @param cy - Center Y coordinate
* @param radius - Outer radius of the spiral
* @param clockwise - If true, winds clockwise; if false, winds counter-clockwise (default: true)
*/
function createEggSpiralPath(cx: number, cy: number, radius: number, clockwise: boolean = true): string {
const points: string[] = [];
const turns = 2; // Number of complete rotations
const steps = 40; // Smoothness of the spiral
// Direction multiplier: 1 for clockwise, -1 for counter-clockwise
const direction = clockwise ? 1 : -1;
for (let i = 0; i <= steps; i++) {
const angle = direction * (i / steps) * turns * 2 * Math.PI;
const r = (i / steps) * radius;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
points.push(`M ${x.toFixed(2)} ${y.toFixed(2)}`);
} else {
points.push(`L ${x.toFixed(2)} ${y.toFixed(2)}`);
}
}
return points.join(' ');
}
// Legacy fallback function for special marks (kept for compatibility)
@@ -64,6 +140,9 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
cracking = false,
warmth = 50,
forceInlineSvg: _forceInlineSvg = false,
statusEffects,
tourVisualState = 'idle',
onTourEggClick,
}) => {
// sizeVariant controls ONLY internal scaling/details, NOT layout dimensions
// Parent container controls actual rendered width/height via slot
@@ -98,6 +177,66 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
const scale = fillScale[sizeVariant] || fillScale.medium;
// Tap-to-wiggle interaction state
const [isTapWiggling, setIsTapWiggling] = useState(false);
const handleEggClick = useCallback(() => {
// Tour interactive steps: forward click to tour controller
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
setIsTapWiggling(true);
onTourEggClick();
return;
}
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
setIsTapWiggling(true);
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
const handleWiggleEnd = useCallback(() => {
setIsTapWiggling(false);
}, []);
// Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states
const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click';
const autoWiggleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!shouldAutoWiggle) {
if (autoWiggleTimerRef.current) {
clearInterval(autoWiggleTimerRef.current);
autoWiggleTimerRef.current = null;
}
return;
}
// Trigger an immediate wiggle, then repeat every 2.5s
setIsTapWiggling(true);
autoWiggleTimerRef.current = setInterval(() => {
setIsTapWiggling((prev) => {
if (!prev) return true;
return prev;
});
}, 2500);
return () => {
if (autoWiggleTimerRef.current) {
clearInterval(autoWiggleTimerRef.current);
autoWiggleTimerRef.current = null;
}
};
}, [shouldAutoWiggle]);
// Tour: whether the egg should show crack overlay
// The crack stays visible during 'opening' so the shell fades out WITH its cracks intact.
// Only 'idle' and 'hatching' (shell already gone) hide the crack.
const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching';
// Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding)
// Level 0: small central crack (show_hatch_card, glowing_waiting_click)
// Level 1: crack expands left/right with small branches (crack_stage_1)
// Level 2: crack expands further toward edges, more branches (crack_stage_2)
// Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening)
const tourCrackLevel = tourVisualState === 'crack_stage_1' ? 1
: tourVisualState === 'crack_stage_2' ? 2
: (tourVisualState === 'crack_stage_3' || tourVisualState === 'opening') ? 3
: 0;
// Divine color constants
const DIVINE_PRIMARY_GREEN = '#55C4A2';
const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9';
@@ -378,30 +517,54 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
}}
>
{/* Glow effect based on warmth - relative sizing */}
<div
className={cn(
'absolute rounded-full blur-xl transition-all duration-1000',
animated && 'animate-pulse'
)}
style={{
width: '120%',
height: '120%',
background: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
zIndex: 0,
}}
/>
{(() => {
const isGlowingTour = tourVisualState === 'glowing_waiting_click'
|| tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2'
|| tourVisualState === 'crack_stage_3';
const isHatchLight = tourVisualState === 'opening' || tourVisualState === 'hatching';
return (
<div
className={cn(
'absolute rounded-full blur-xl transition-all duration-1000',
animated && !isGlowingTour && !isHatchLight && 'animate-pulse',
isGlowingTour && 'animate-egg-tour-glow',
isHatchLight && 'animate-egg-tour-glow',
)}
style={{
width: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
height: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
background: isHatchLight
? `radial-gradient(circle, #fff 0%, ${glowColor} 40%, transparent 70%)`
: isGlowingTour
? `radial-gradient(circle, ${glowColor} 0%, ${glowColor}80 30%, transparent 70%)`
: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
zIndex: 0,
}}
/>
);
})()}
{/* Main egg shape - uses percentage-based sizing */}
<div
onClick={handleEggClick}
onAnimationEnd={(e) => {
if (e.animationName === 'egg-tap-wiggle') handleWiggleEnd();
}}
className={cn(
'relative transition-all duration-500 z-10',
// Reaction-based animations (music/sing)
(reaction === 'listening' || reaction === 'swaying' || reaction === 'happy') && 'animate-egg-sway',
reaction === 'singing' && 'animate-egg-bounce',
'relative transition-all duration-500 z-10 cursor-pointer',
// Tap wiggle (highest priority after cracking)
isTapWiggling && !cracking && 'animate-egg-tap-wiggle',
// Reaction-based animations (music/sing) - only when not tap-wiggling
!isTapWiggling && (reaction === 'listening' || reaction === 'swaying' || reaction === 'happy') && 'animate-egg-sway',
!isTapWiggling && reaction === 'singing' && 'animate-egg-bounce',
// Warmth effect only when animated AND warm
animated && actualWarmth > 60 && 'animate-egg-warmth',
// Cracking overrides other animations
cracking && 'animate-egg-crack'
// Cracking overrides other animations (legacy prop or tour crack stages)
// During 'opening' the shell runs its own open animation, so suppress the shake
(cracking || (tourCrackLevel >= 1 && tourVisualState !== 'opening')) && 'animate-egg-crack',
// Opening/hatching: fade out the egg shell (crack overlay stays inside and fades with it)
tourVisualState === 'opening' && 'animate-egg-tour-open',
tourVisualState === 'hatching' && 'opacity-0',
)}
style={{
width: '80%',
@@ -412,7 +575,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
inset -0.5em -0.5em 1em ${shadow}33,
inset 0.5em 0.5em 1em ${highlight}26
`,
filter: cracking ? 'brightness(1.1)' : 'brightness(1)',
filter: (cracking || tourCrackLevel >= 1) ? 'brightness(1.1)' : 'brightness(1)',
}}
>
{/* Highlight on the egg - uses color variants instead of white */}
@@ -470,133 +633,181 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
renderLegacySpecialMark(effectiveSpecialMark)
))}
{/* Crack pattern based on docs/aprovado.svg when cracking is true */}
{cracking && (
<svg
className="absolute inset-0 pointer-events-none w-full h-full"
viewBox="0 0 120 125"
preserveAspectRatio="xMidYMid meet"
style={{
height: '100%',
}}
>
{/* Main horizontal crack (adapted from aprovado.svg) */}
<path
d="M10 62
L20 60
L30 64
L40 59
L50 65
L60 58
L70 66
L80 57
L90 67
L100 59
L110 65"
stroke="rgba(0, 0, 0, 0.6)"
strokeWidth="2"
fill="none"
strokeLinecap="round"
/>
{/* Crack pattern - stage-specific paths that grow outward from center */}
{(cracking || tourShowCrack) && (() => {
// Legacy cracking shows full crack; tour uses progressive stage-specific paths
const level = cracking ? 3 : tourCrackLevel;
return (
<svg
className="absolute inset-0 pointer-events-none w-full h-full transition-opacity duration-300"
viewBox="0 0 120 125"
preserveAspectRatio="xMidYMid meet"
style={{ height: '100%' }}
>
{/*
Stage-specific crack paths.
Each level has its OWN distinct paths that expand outward from the egg center.
The crack grows from a small central cluster to full-width fracture.
{/* Secondary cracks (adapted from aprovado.svg) */}
<path
d="M30 64 L28 70"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M50 65 L53 71"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M60 58 L57 52"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M80 57 L82 50"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M90 67 L95 72"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M100 59 L97 53"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M110 65 L113 69"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
Viewbox center is roughly (60, 62).
Level 0: tiny central crack (~3-4 small connected segments near center)
Level 1: extends left/right from center, first branches
Level 2: reaches further toward edges, more fracture detail
Level 3: crack reaches near shell edges, dense branching
*/}
{/* Additional micro-cracks for detail */}
<path
d="M40 59 L38 55"
stroke="rgba(0, 0, 0, 0.25)"
strokeWidth="0.8"
strokeLinecap="round"
/>
<path
d="M70 66 L73 70"
stroke="rgba(0, 0, 0, 0.25)"
strokeWidth="0.8"
strokeLinecap="round"
/>
<path
d="M20 60 L18 56"
stroke="rgba(0, 0, 0, 0.2)"
strokeWidth="0.6"
strokeLinecap="round"
/>
{/* ── Level 0: Small central crack ── */}
{/* A few short connected segments clustered around the center of the egg */}
{level === 0 && (<>
{/* Main tiny crack: ~15px wide, centered */}
<path
d="M53 63 L57 60 L63 64 L67 61"
stroke="rgba(0, 0, 0, 0.5)"
strokeWidth="1.2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Tiny upward branch from center */}
<path
d="M57 60 L56 57"
stroke="rgba(0, 0, 0, 0.35)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Tiny downward branch */}
<path
d="M63 64 L65 67"
stroke="rgba(0, 0, 0, 0.35)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Subtle highlight alongside main crack */}
<path
d="M54 64 L58 61 L64 65"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.6"
fill="none"
strokeLinecap="round"
/>
</>)}
{/* Crack highlights for depth (following the main crack pattern) */}
<path
d="M10 63
L20 61
L30 65
L40 60
L50 66
L60 59
L70 67
L80 58
L90 68
L100 60
L110 66"
stroke="rgba(255, 255, 255, 0.15)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* ── Level 1: Medium crack expanding from center ── */}
{/* Crack extends ~30px wide, first real branches appear */}
{level === 1 && (<>
{/* Main crack: wider than level 0, extends left and right */}
<path
d="M42 61 L48 64 L53 60 L60 65 L67 59 L73 63 L78 60"
stroke="rgba(0, 0, 0, 0.55)"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M43 62 L49 65 L54 61 L61 66 L68 60 L74 64"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.6"
fill="none"
strokeLinecap="round"
/>
{/* Branch: upward left */}
<path d="M48 64 L46 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branch: upward from center-right */}
<path d="M67 59 L65 54" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branch: downward right */}
<path d="M73 63 L76 68" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
{/* Small micro-branch */}
<path d="M53 60 L51 56" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.7" strokeLinecap="round" />
</>)}
{/* Secondary crack highlights */}
<path
d="M30 65 L28 71"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth="0.4"
strokeLinecap="round"
/>
<path
d="M60 59 L57 53"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth="0.4"
strokeLinecap="round"
/>
</svg>
)}
{/* ── Level 2: Larger crack reaching toward sides ── */}
{/* Crack extends ~60px wide, more branching detail */}
{level === 2 && (<>
{/* Main crack: extends well toward both sides */}
<path
d="M30 63 L37 60 L44 65 L52 59 L60 64 L68 58 L76 63 L83 59 L90 64"
stroke="rgba(0, 0, 0, 0.6)"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M31 64 L38 61 L45 66 L53 60 L61 65 L69 59 L77 64 L84 60"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.7"
fill="none"
strokeLinecap="round"
/>
{/* Branches: left side */}
<path d="M37 60 L34 55" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
<path d="M44 65 L41 71" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branches: center */}
<path d="M52 59 L50 53" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
<path d="M60 64 L63 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branches: right side */}
<path d="M68 58 L66 52" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
<path d="M76 63 L79 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
<path d="M83 59 L86 54" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
{/* Micro-cracks */}
<path d="M50 53 L48 50" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M63 70 L66 73" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
</>)}
{/* ── Level 3: Full crack reaching shell edges ── */}
{/* Crack spans nearly the full width, dense fracture network */}
{level >= 3 && (<>
{/* Main crack: nearly full width of egg */}
<path
d="M15 62 L23 59 L32 64 L40 58 L50 65 L60 57 L70 64 L80 58 L88 63 L96 59 L105 64"
stroke="rgba(0, 0, 0, 0.65)"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M16 63 L24 60 L33 65 L41 59 L51 66 L61 58 L71 65 L81 59 L89 64 L97 60"
stroke="rgba(255, 255, 255, 0.13)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Heavy branches: left region */}
<path d="M23 59 L19 53" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M32 64 L28 72" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M28 72 L25 76" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.9" strokeLinecap="round" />
{/* Heavy branches: center-left */}
<path d="M40 58 L37 51" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M50 65 L47 73" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M37 51 L35 47" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
{/* Heavy branches: center */}
<path d="M60 57 L58 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M60 57 L63 68" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
{/* Heavy branches: center-right */}
<path d="M70 64 L73 71" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M80 58 L83 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M83 50 L86 46" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
{/* Heavy branches: right region */}
<path d="M88 63 L91 70" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M96 59 L99 52" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M105 64 L109 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
{/* Micro-cracks (tertiary detail) */}
<path d="M47 73 L44 77" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M73 71 L76 75" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M58 50 L55 46" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M19 53 L17 49" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
<path d="M99 52 L102 48" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
</>)}
</svg>
);
})()}
{/* Title display for special eggs */}
{blobbi?.title && (
@@ -657,6 +868,440 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
/>
</>
)}
{/* ── Status Effects ─────────────────────────────────────────────── */}
{/* Dirty effect: sweat droplet + dust at lower shell edges
Placement rules for egg:
- Sweat droplet stays at upper-left (outside egg, not on face)
- Dust particles only at lower outer shell edges
- Avoid center-front placement entirely
*/}
{statusEffects?.dirty && (
<>
{/* Sweat droplet - upper left outside egg shell */}
<div
className="absolute animate-egg-sweat-drop"
style={{
top: '15%',
left: '5%',
width: '0.6em',
height: '0.9em',
background: 'linear-gradient(180deg, rgba(147, 197, 253, 0.9) 0%, rgba(59, 130, 246, 0.7) 100%)',
borderRadius: '50% 50% 50% 50% / 30% 30% 70% 70%',
zIndex: 20,
}}
/>
{/* Dust particles underneath (back layer) - at lower edges */}
<div
className="absolute animate-egg-dust"
style={{
bottom: '-5%',
left: '20%',
width: '60%',
height: '0.4em',
display: 'flex',
justifyContent: 'space-between',
zIndex: 5,
}}
>
{/* Left edge particle */}
<div
style={{
width: '0.3em',
height: '0.3em',
background: 'rgba(87, 83, 78, 0.7)',
borderRadius: '50%',
}}
className="animate-egg-dust-particle"
/>
{/* Right edge particle */}
<div
style={{
width: '0.28em',
height: '0.28em',
background: 'rgba(87, 83, 78, 0.65)',
borderRadius: '50%',
animationDelay: '0.4s',
}}
className="animate-egg-dust-particle"
/>
</div>
{/* Front-layer dust - at lower-left and lower-right edges
More visible than back layer, stronger colors */}
{/* Lower-left edge particle - larger, more visible */}
<div
className="absolute animate-egg-dust-particle"
style={{
bottom: '12%',
left: '6%',
width: '0.28em',
height: '0.28em',
background: 'rgba(63, 63, 70, 0.8)',
borderRadius: '50%',
zIndex: 25,
animationDelay: '0.1s',
}}
/>
{/* Lower-right edge particle */}
<div
className="absolute animate-egg-dust-particle"
style={{
bottom: '15%',
right: '5%',
width: '0.25em',
height: '0.25em',
background: 'rgba(63, 63, 70, 0.75)',
borderRadius: '50%',
zIndex: 25,
animationDelay: '0.5s',
}}
/>
{/* Very bottom left particle */}
<div
className="absolute animate-egg-dust-particle"
style={{
bottom: '5%',
left: '18%',
width: '0.22em',
height: '0.22em',
background: 'rgba(68, 64, 60, 0.7)',
borderRadius: '50%',
zIndex: 25,
animationDelay: '0.8s',
}}
/>
{/* Very bottom right particle */}
<div
className="absolute animate-egg-dust-particle"
style={{
bottom: '3%',
right: '20%',
width: '0.2em',
height: '0.2em',
background: 'rgba(68, 64, 60, 0.65)',
borderRadius: '50%',
zIndex: 25,
animationDelay: '1.1s',
}}
/>
</>
)}
{/* Sick effect: layered dizzy spirals around and across egg
Creates a magical/dizzy atmosphere with multiple spiral layers:
- Outer spirals: floating around the egg shell
- Inner spirals: across the egg body itself
- Mixed colors: gray (primary) + white (accents)
- Varying sizes, speeds, and rotation directions
All use true Archimedean spiral paths matching Blobbi dizzy eyes
*/}
{statusEffects?.sick && (
<>
{/* ═══ OUTER SPIRALS (floating around egg) ═══ */}
{/* Outer 1 - top left, large, gray, COUNTER-CLOCKWISE path */}
<svg
className="absolute"
style={{
top: '0%',
left: '-5%',
width: '1.1em',
height: '1.1em',
zIndex: 20,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 8, false)}
stroke="#4b5563"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
opacity="0.65"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="2.5s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Outer 2 - right side, medium, gray, CLOCKWISE path */}
<svg
className="absolute"
style={{
top: '25%',
right: '-6%',
width: '0.95em',
height: '0.95em',
zIndex: 20,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 8, true)}
stroke="#6b7280"
strokeWidth="1.4"
fill="none"
strokeLinecap="round"
opacity="0.6"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="360 10 10"
to="0 10 10"
dur="2s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Outer 3 - bottom left, small, white accent, COUNTER-CLOCKWISE path */}
<svg
className="absolute"
style={{
bottom: '15%',
left: '-4%',
width: '0.7em',
height: '0.7em',
zIndex: 20,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 8, false)}
stroke="white"
strokeWidth="1.3"
fill="none"
strokeLinecap="round"
opacity="0.5"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="360 10 10"
to="0 10 10"
dur="3s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Outer 4 - bottom right, tiny, gray, CLOCKWISE path */}
<svg
className="absolute"
style={{
bottom: '8%',
right: '-3%',
width: '0.6em',
height: '0.6em',
zIndex: 20,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 8, true)}
stroke="#9ca3af"
strokeWidth="1.2"
fill="none"
strokeLinecap="round"
opacity="0.5"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="3.5s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* ═══ INNER SPIRALS (across egg body) ═══ */}
{/* Inner 1 - upper egg, small, white, COUNTER-CLOCKWISE path */}
<svg
className="absolute"
style={{
top: '18%',
left: '22%',
width: '0.55em',
height: '0.55em',
zIndex: 15,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 7, false)}
stroke="white"
strokeWidth="1.2"
fill="none"
strokeLinecap="round"
opacity="0.4"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="4s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Inner 2 - mid-right egg, tiny, gray, CLOCKWISE path */}
<svg
className="absolute"
style={{
top: '40%',
right: '18%',
width: '0.5em',
height: '0.5em',
zIndex: 15,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 7, true)}
stroke="#6b7280"
strokeWidth="1.1"
fill="none"
strokeLinecap="round"
opacity="0.35"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="360 10 10"
to="0 10 10"
dur="3s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Inner 3 - lower-center egg, small, white accent */}
<svg
className="absolute"
style={{
bottom: '28%',
left: '35%',
width: '0.45em',
height: '0.45em',
zIndex: 15,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 7, false)}
stroke="white"
strokeWidth="1"
fill="none"
strokeLinecap="round"
opacity="0.35"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="3.5s"
repeatCount="indefinite"
/>
</g>
</svg>
</>
)}
{/* Happy effect: subtle sparkles around egg */}
{statusEffects?.happy && (
<>
{/* Sparkle 1 - top */}
<div
className="absolute animate-egg-sparkle"
style={{
top: '5%',
left: '45%',
width: '0.5em',
height: '0.5em',
zIndex: 20,
}}
>
<svg viewBox="0 0 20 20" className="w-full h-full">
<path
d="M10 0 L10 20 M0 10 L20 10 M3 3 L17 17 M17 3 L3 17"
stroke="rgba(251, 191, 36, 0.8)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
{/* Sparkle 2 - right */}
<div
className="absolute animate-egg-sparkle"
style={{
top: '25%',
right: '0%',
width: '0.4em',
height: '0.4em',
zIndex: 20,
animationDelay: '0.4s',
}}
>
<svg viewBox="0 0 20 20" className="w-full h-full">
<path
d="M10 0 L10 20 M0 10 L20 10"
stroke="rgba(251, 191, 36, 0.7)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
{/* Sparkle 3 - left */}
<div
className="absolute animate-egg-sparkle"
style={{
top: '40%',
left: '0%',
width: '0.35em',
height: '0.35em',
zIndex: 20,
animationDelay: '0.8s',
}}
>
<svg viewBox="0 0 20 20" className="w-full h-full">
<path
d="M10 0 L10 20 M0 10 L20 10"
stroke="rgba(251, 191, 36, 0.6)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
</>
)}
</div>
</div>
);
+1 -1
View File
@@ -12,7 +12,7 @@
import './styles/egg-animations.css';
// Components
export { EggGraphic, type EggReactionState } from './components/EggGraphic';
export { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from './components/EggGraphic';
export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer';
// Hooks
+514 -1
View File
@@ -82,6 +82,118 @@
animation: egg-crack-shake 0.5s ease-in-out infinite;
}
/* Tap interaction: rock side-to-side and hop */
@keyframes egg-tap-wiggle {
0% {
transform: translateY(0) rotate(0deg);
}
15% {
transform: translateY(-2px) rotate(-6deg);
}
30% {
transform: translateY(-6px) rotate(5deg);
}
45% {
transform: translateY(-3px) rotate(-4deg);
}
60% {
transform: translateY(-1px) rotate(3deg);
}
75% {
transform: translateY(0) rotate(-1.5deg);
}
90% {
transform: translateY(0) rotate(0.5deg);
}
100% {
transform: translateY(0) rotate(0deg);
}
}
.animate-egg-tap-wiggle {
animation: egg-tap-wiggle 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* ==========================================
Egg Status Effect Animations
========================================== */
/* Sweat droplet - slides down and fades */
@keyframes egg-sweat-drop {
0% {
opacity: 0;
transform: translateY(-2px);
}
20% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(4px);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
.animate-egg-sweat-drop {
animation: egg-sweat-drop 2s ease-in-out infinite;
}
/* Dust particles - gentle float up */
@keyframes egg-dust-particle {
0%, 100% {
opacity: 0.3;
transform: translateY(0);
}
50% {
opacity: 0.6;
transform: translateY(-3px);
}
}
.animate-egg-dust-particle {
animation: egg-dust-particle 2s ease-in-out infinite;
}
/* Dizzy spirals - rotate and float */
@keyframes egg-spiral {
0% {
opacity: 0.4;
transform: rotate(0deg) translateY(0);
}
50% {
opacity: 0.8;
transform: rotate(180deg) translateY(-4px);
}
100% {
opacity: 0.4;
transform: rotate(360deg) translateY(0);
}
}
.animate-egg-spiral {
animation: egg-spiral 3s ease-in-out infinite;
}
/* Happy sparkles - twinkle effect */
@keyframes egg-sparkle {
0%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.animate-egg-sparkle {
animation: egg-sparkle 1.5s ease-in-out infinite;
}
/* ==========================================
Special Mark Animations
========================================== */
@@ -208,6 +320,49 @@
transform: translateZ(0);
}
/* ==========================================
Tour Visual State Animations
========================================== */
/* Shell opening: scale up slightly then fade out with blur */
@keyframes egg-tour-open {
0% {
transform: scale(1);
opacity: 1;
filter: brightness(1.1);
}
40% {
transform: scale(1.05);
opacity: 0.9;
filter: brightness(1.4);
}
100% {
transform: scale(1.15);
opacity: 0;
filter: brightness(2) blur(4px);
}
}
.animate-egg-tour-open {
animation: egg-tour-open 1.2s ease-in-out forwards;
}
/* Pulsing glow for the "waiting for click" tour state */
@keyframes egg-tour-glow {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
.animate-egg-tour-glow {
animation: egg-tour-glow 2s ease-in-out infinite;
}
/* ==========================================
Responsive adjustments
========================================== */
@@ -234,7 +389,14 @@
.animate-egg-sway,
.animate-egg-bounce,
.animate-egg-warmth,
.animate-egg-crack {
.animate-egg-crack,
.animate-egg-tap-wiggle,
.animate-egg-sweat-drop,
.animate-egg-dust-particle,
.animate-egg-spiral,
.animate-egg-sparkle,
.animate-egg-tour-glow,
.animate-egg-tour-open {
animation: none !important;
}
}
@@ -276,3 +438,354 @@
filter: grayscale(1) contrast(1.5) !important;
}
}
/* ==========================================
Onboarding Hatching Ceremony Animations
========================================== */
/* Soft breathing pulse for the egg before interaction */
@keyframes egg-onboard-breathe {
0%, 100% {
transform: scale(1);
filter: brightness(1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.08));
}
50% {
transform: scale(1.015);
filter: brightness(1.03) drop-shadow(0 0 30px rgba(255, 255, 255, 0.15));
}
}
.animate-egg-onboard-breathe {
animation: egg-onboard-breathe 3s ease-in-out infinite;
}
/* Screen-filling radial glow that expands from center on hatch */
@keyframes onboard-glow-expand {
0% {
opacity: 0;
transform: scale(0.3);
}
30% {
opacity: 1;
}
100% {
opacity: 0.85;
transform: scale(2.5);
}
}
.animate-onboard-glow-expand {
animation: onboard-glow-expand 1.8s ease-out forwards;
}
/* Gentle lingering glow fade after hatch - holds then fades */
@keyframes onboard-glow-linger {
0% {
opacity: 0.85;
}
15% {
opacity: 0.85;
}
100% {
opacity: 0;
}
}
.animate-onboard-glow-linger {
animation: onboard-glow-linger 7s ease-out forwards;
}
/* Sentimental text fade in - very slow, dreamlike */
@keyframes onboard-text-reveal {
0% {
opacity: 0;
transform: translateY(12px);
filter: blur(4px);
}
100% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.animate-onboard-text-reveal {
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Delayed text reveal for secondary text */
.animate-onboard-text-reveal-delay {
opacity: 0;
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.6s forwards;
}
/* Soft fade out for transition between phases */
@keyframes onboard-soft-fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.animate-onboard-soft-fade-out {
animation: onboard-soft-fade-out 0.8s ease-out forwards;
}
/* Soft fade in */
@keyframes onboard-soft-fade-in {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-onboard-soft-fade-in {
animation: onboard-soft-fade-in 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Floating particles that drift upward from the egg */
@keyframes onboard-particle-rise {
0% {
opacity: 0;
transform: translateY(0) scale(0.5);
}
20% {
opacity: 0.8;
}
100% {
opacity: 0;
transform: translateY(-120px) scale(0.2);
}
}
/* Sparkle twinkle - stays in place, pulses brightness */
@keyframes onboard-sparkle-twinkle {
0%, 100% {
opacity: 0;
transform: scale(0.5);
}
15% {
opacity: 1;
transform: scale(1.2);
}
30% {
opacity: 0.6;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 0.3;
transform: scale(0.6);
}
85% {
opacity: 0.9;
transform: scale(1.1);
}
}
/* Sparkle drift - gentle floating motion */
@keyframes onboard-sparkle-drift {
0% {
opacity: 0;
transform: translateY(0) scale(0.3);
}
20% {
opacity: 1;
transform: translateY(-8px) scale(1);
}
80% {
opacity: 0.8;
transform: translateY(-25px) scale(0.9);
}
100% {
opacity: 0;
transform: translateY(-40px) scale(0.4);
}
}
/* Egg entrance - subtle float up from darkness */
@keyframes egg-onboard-entrance {
0% {
opacity: 0;
transform: translateY(30px) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-egg-onboard-entrance {
animation: egg-onboard-entrance 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Egg shake intensifying - for crack stages */
@keyframes egg-onboard-shake-light {
0%, 100% { transform: translateX(0) rotate(0deg); }
25% { transform: translateX(-3px) rotate(-2deg); }
75% { transform: translateX(3px) rotate(2deg); }
}
@keyframes egg-onboard-shake-medium {
0%, 100% { transform: translateX(0) rotate(0deg); }
20% { transform: translateX(-5px) rotate(-3deg); }
40% { transform: translateX(4px) rotate(2deg); }
60% { transform: translateX(-4px) rotate(-2deg); }
80% { transform: translateX(5px) rotate(3deg); }
}
@keyframes egg-onboard-shake-heavy {
0%, 100% { transform: translateX(0) rotate(0deg); }
10% { transform: translateX(-6px) rotate(-4deg); }
20% { transform: translateX(5px) rotate(3deg); }
30% { transform: translateX(-7px) rotate(-3deg); }
40% { transform: translateX(6px) rotate(4deg); }
50% { transform: translateX(-5px) rotate(-2deg); }
60% { transform: translateX(7px) rotate(3deg); }
70% { transform: translateX(-6px) rotate(-4deg); }
80% { transform: translateX(5px) rotate(2deg); }
90% { transform: translateX(-4px) rotate(-3deg); }
}
.animate-egg-onboard-shake-light {
animation: egg-onboard-shake-light 0.4s ease-in-out;
}
.animate-egg-onboard-shake-medium {
animation: egg-onboard-shake-medium 0.5s ease-in-out;
}
.animate-egg-onboard-shake-heavy {
animation: egg-onboard-shake-heavy 0.6s ease-in-out;
}
/* Final burst - egg explodes into light */
@keyframes egg-onboard-burst {
0% {
transform: scale(1);
opacity: 1;
filter: brightness(1);
}
30% {
transform: scale(1.08);
filter: brightness(1.5);
}
60% {
transform: scale(1.15);
opacity: 0.8;
filter: brightness(2.5);
}
100% {
transform: scale(1.3);
opacity: 0;
filter: brightness(4) blur(8px);
}
}
.animate-egg-onboard-burst {
animation: egg-onboard-burst 1.2s ease-in-out forwards;
}
/* Screen flash on hatch */
@keyframes onboard-screen-flash {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.animate-onboard-screen-flash {
animation: onboard-screen-flash 2s ease-out forwards;
}
/* Gentle continue prompt pulse */
@keyframes onboard-continue-pulse {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 0.7;
}
}
.animate-onboard-continue-pulse {
animation: onboard-continue-pulse 2.5s ease-in-out infinite;
}
/* Slow rotating golden incandescence behind hatched blobbi */
@keyframes onboard-golden-rotate {
0% {
transform: rotate(0deg) scale(1);
}
25% {
transform: rotate(90deg) scale(1.06);
}
50% {
transform: rotate(180deg) scale(1);
}
75% {
transform: rotate(270deg) scale(1.06);
}
100% {
transform: rotate(360deg) scale(1);
}
}
.animate-onboard-golden-rotate {
animation: onboard-golden-rotate 20s linear infinite;
}
/* Golden glow fade-in */
@keyframes onboard-golden-fadein {
0% {
opacity: 0;
transform: scale(0.7);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.animate-onboard-golden-fadein {
animation: onboard-golden-fadein 2.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Reduced motion overrides for onboarding */
@media (prefers-reduced-motion: reduce) {
.animate-egg-onboard-breathe,
.animate-onboard-glow-expand,
.animate-onboard-glow-linger,
.animate-onboard-text-reveal,
.animate-onboard-text-reveal-delay,
.animate-onboard-soft-fade-out,
.animate-onboard-soft-fade-in,
.animate-egg-onboard-entrance,
.animate-egg-onboard-shake-light,
.animate-egg-onboard-shake-medium,
.animate-egg-onboard-shake-heavy,
.animate-egg-onboard-burst,
.animate-onboard-screen-flash,
.animate-onboard-continue-pulse,
.animate-onboard-golden-rotate,
.animate-onboard-golden-fadein {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}
@@ -16,7 +16,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { BLOBBI_ADOPTION_COST } from '@/lib/blobbi';
import { BLOBBI_ADOPTION_COST } from '@/blobbi/core/lib/blobbi';
import { formatCompactNumber } from '@/lib/utils';
import type { BlobbiEggPreview } from '../lib/blobbi-preview';
@@ -19,7 +19,7 @@ import { cn, formatCompactNumber } from '@/lib/utils';
import {
BLOBBI_PREVIEW_REROLL_COST,
BLOBBI_ADOPTION_COST,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import type { BlobbiEggPreview } from '../lib/blobbi-preview';
import { previewToBlobbiCompanion } from '../lib/blobbi-preview';
@@ -0,0 +1,961 @@
/**
* BlobbiHatchingCeremony - Immersive hatching experience for every new egg
*
* Flow:
* 1. Dark screen, egg silently created in background
* 2. Huge breathing egg appears. No text. No UI.
* 3. Click egg 4 times through crack stages with intensifying shakes
* 4. Final click -> egg bursts into light, actual hatch mutation fires
* 5. Flash clears -> hatched baby blobbi revealed center screen with glow/sparkles
* 6. Typewriter dialog appears below blobbi (click to complete line / advance)
* 7. Naming prompt, then ceremony complete
*/
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
INITIAL_BLOBBONAUT_COINS,
STAT_MAX,
buildBlobbonautTags,
updateBlobbonautTags,
updateBlobbiTags,
type BlobbonautProfile,
type BlobbiCompanion,
} from '@/blobbi/core/lib/blobbi';
import {
generateEggPreview,
previewToEventTags,
previewToBlobbiCompanion,
type BlobbiEggPreview,
} from '../lib/blobbi-preview';
// ─── Dialog Lines ─────────────────────────────────────────────────────────────
const BIRTH_DIALOG: string[] = [
'Something stirs...',
'A tiny life has chosen you. It knows only warmth, and your presence.',
];
const NAMING_DIALOG = 'Every life deserves a name.\nWhat will you call this one?';
// ─── Phase Machine ────────────────────────────────────────────────────────────
type CeremonyPhase =
| 'loading'
| 'egg'
| 'crack_1'
| 'crack_2'
| 'crack_3'
| 'hatching' // egg burst + hatch mutation
| 'reveal' // flash clearing, baby blobbi fading in with glow
| 'dialog' // typewriter dialog lines
| 'naming'
| 'complete';
// ─── Typewriter Hook ──────────────────────────────────────────────────────────
function useTypewriter(fullText: string, active: boolean, speed = 35) {
const [displayed, setDisplayed] = useState('');
const [done, setDone] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const indexRef = useRef(0);
// Reset when text changes
useEffect(() => {
setDisplayed('');
setDone(false);
indexRef.current = 0;
}, [fullText]);
// Run typewriter
useEffect(() => {
if (!active || done) return;
intervalRef.current = setInterval(() => {
indexRef.current++;
const next = fullText.slice(0, indexRef.current);
setDisplayed(next);
if (indexRef.current >= fullText.length) {
setDone(true);
if (intervalRef.current) clearInterval(intervalRef.current);
}
}, speed);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [active, done, fullText, speed]);
const complete = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
setDisplayed(fullText);
setDone(true);
}, [fullText]);
return { displayed, done, complete };
}
// Module-level guard: prevents duplicate egg creation if the component remounts
// (e.g. React strict mode, parent re-render causing unmount/remount).
// Tracks pubkeys that have already started setup in this browser session.
const setupInFlightFor = new Set<string>();
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiHatchingCeremonyProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
updateCompanionEvent: (event: NostrEvent) => void;
invalidateProfile: () => void;
invalidateCompanion: () => void;
setStoredSelectedD: (d: string) => void;
onComplete?: () => void;
/** If provided, skip egg creation and start from the cracking phase with this existing egg. */
existingCompanion?: BlobbiCompanion | null;
/** If true, only create the egg and skip the hatching ceremony. The egg stays an egg. */
eggOnly?: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiHatchingCeremony({
profile,
updateProfileEvent,
updateCompanionEvent,
invalidateProfile,
invalidateCompanion,
setStoredSelectedD,
onComplete,
existingCompanion,
eggOnly = false,
}: BlobbiHatchingCeremonyProps) {
const isExistingEgg = !!existingCompanion;
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { data: authorData } = useAuthor(user?.pubkey);
// ── Core state ──
const [phase, setPhase] = useState<CeremonyPhase>('loading');
const [preview, setPreview] = useState<BlobbiEggPreview | null>(null);
const [name, setName] = useState(existingCompanion?.name ?? '');
const [isNaming, setIsNaming] = useState(false);
const [eggVisible, setEggVisible] = useState(false);
// Reveal phase state
const [blobbiVisible, setBlobbiVisible] = useState(false);
const [showFlash, setShowFlash] = useState(false);
const [, setShowRevealGlow] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
// Dialog state
const [dialogLineIndex, setDialogLineIndex] = useState(0);
const [dialogActive, setDialogActive] = useState(false);
const [namingVisible, setNamingVisible] = useState(false);
// Refs
const setupAttempted = useRef(false);
const profileRef = useRef(profile);
profileRef.current = profile;
const previewRef = useRef(preview);
previewRef.current = preview;
const nameInputRef = useRef<HTMLInputElement>(null);
const eggContainerRef = useRef<HTMLDivElement>(null);
const entrancePlayed = useRef(false);
const eggTagsRef = useRef<string[][] | null>(null);
// ── Companion visuals ──
const eggCompanion = useMemo(
() => preview ? previewToBlobbiCompanion(preview) : null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[preview?.d],
);
// Baby companion (same visual data but stage=baby)
const babyCompanion = useMemo((): BlobbiCompanion | null => {
if (!eggCompanion) return null;
return { ...eggCompanion, stage: 'baby', state: 'active' };
}, [eggCompanion]);
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
// ── Typewriter for current dialog line ──
const currentDialogText = phase === 'dialog' ? (BIRTH_DIALOG[dialogLineIndex] ?? '') : '';
const dialogTypewriter = useTypewriter(currentDialogText, dialogActive);
const namingTypewriter = useTypewriter(NAMING_DIALOG, namingVisible);
// ── Fast-path setup for existing eggs (no publishing needed) ──
useEffect(() => {
if (!isExistingEgg || setupAttempted.current || !existingCompanion) return;
setupAttempted.current = true;
// Build a minimal preview from the existing companion
const fakePreview: BlobbiEggPreview = {
d: existingCompanion.d,
petId: existingCompanion.d,
ownerPubkey: user?.pubkey ?? '',
name: existingCompanion.name,
stage: 'egg',
state: 'active',
seed: existingCompanion.seed ?? '',
stats: {
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
happiness: existingCompanion.stats.happiness ?? STAT_MAX,
health: existingCompanion.stats.health ?? STAT_MAX,
hygiene: existingCompanion.stats.hygiene ?? STAT_MAX,
energy: existingCompanion.stats.energy ?? STAT_MAX,
},
visualTraits: existingCompanion.visualTraits,
createdAt: Math.floor(Date.now() / 1000),
};
setPreview(fakePreview);
previewRef.current = fakePreview;
eggTagsRef.current = existingCompanion.allTags;
setPhase('egg');
setTimeout(() => setEggVisible(true), 200);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isExistingEgg, existingCompanion?.d]);
// ── Silent setup: create profile + egg (new egg flow only) ──
useEffect(() => {
if (isExistingEgg) return; // Skip for existing eggs
if (setupAttempted.current || !user?.pubkey) return;
// Module-level guard: if another mount already started setup for this pubkey, skip
if (setupInFlightFor.has(user.pubkey)) return;
setupAttempted.current = true;
setupInFlightFor.add(user.pubkey);
const setup = async () => {
try {
const currentProfile = profileRef.current;
let latestProfileTags: string[][] | null = currentProfile?.allTags ?? null;
// 1. Create profile if needed
if (!currentProfile) {
const suggestedName =
authorData?.metadata?.display_name ||
authorData?.metadata?.name ||
'Blobbonaut';
const baseTags = buildBlobbonautTags(user.pubkey);
const tagsWithName = [
...baseTags,
['name', suggestedName],
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
];
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: tagsWithName,
});
updateProfileEvent(profileEvent);
invalidateProfile();
latestProfileTags = tagsWithName;
}
// 2. Generate and publish egg
const eggPreview = generateEggPreview(user.pubkey, 'Egg');
setPreview(eggPreview);
previewRef.current = eggPreview;
const eggTags = previewToEventTags(eggPreview);
eggTagsRef.current = eggTags;
const eggEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: 'A new Blobbi egg!',
tags: eggTags,
created_at: eggPreview.createdAt,
});
updateCompanionEvent(eggEvent);
// 3. Update profile with has[] entry
if (latestProfileTags) {
const existingHas = latestProfileTags
.filter(([k]) => k === 'has')
.map(([, v]) => v);
const newHas = [...existingHas, eggPreview.d];
const updatedTags = updateBlobbonautTags(latestProfileTags, {
has: newHas,
});
const updatedProfileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(updatedProfileEvent);
}
setStoredSelectedD(eggPreview.d);
invalidateProfile();
invalidateCompanion();
setPhase('egg');
setTimeout(() => setEggVisible(true), 200);
} catch (error) {
console.error('[HatchingCeremony] Setup failed:', error);
toast({
title: 'Something went wrong',
description: 'Failed to set up your Blobbi. Please try again.',
variant: 'destructive',
});
} finally {
// Clear module-level guard so future adoptions can create new eggs
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
}
};
const timer = setTimeout(setup, 600);
return () => {
clearTimeout(timer);
// If the timer was cleared before setup ran, release the guard
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.pubkey]);
useEffect(() => {
if (profile) profileRef.current = profile;
}, [profile]);
// eggOnly mode: auto-complete after the egg is shown (skip hatching)
useEffect(() => {
if (!eggOnly || !eggVisible) return;
const timer = setTimeout(() => {
setPhase('complete');
onComplete?.();
}, 1500);
return () => clearTimeout(timer);
}, [eggOnly, eggVisible, onComplete]);
// Play entrance animation once
useEffect(() => {
if (eggVisible && !entrancePlayed.current && eggContainerRef.current) {
entrancePlayed.current = true;
const el = eggContainerRef.current;
el.classList.add('animate-egg-onboard-entrance');
const onEnd = () => {
el.classList.remove('animate-egg-onboard-entrance');
el.removeEventListener('animationend', onEnd);
};
el.addEventListener('animationend', onEnd);
}
}, [eggVisible]);
// ── Shake (DOM-only, no re-render) ──
const triggerShake = useCallback((cls: string) => {
const el = eggContainerRef.current;
if (!el) return;
el.classList.remove(
'animate-egg-onboard-shake-light',
'animate-egg-onboard-shake-medium',
'animate-egg-onboard-shake-heavy',
);
void el.offsetWidth;
el.classList.add(cls);
}, []);
// ── Execute the actual hatch: egg -> baby ──
const executeHatch = useCallback(async () => {
const tags = eggTagsRef.current;
if (!tags) return;
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const babyTags = updateBlobbiTags(tags, {
stage: 'baby',
state: 'active',
hunger: STAT_MAX.toString(),
happiness: STAT_MAX.toString(),
health: STAT_MAX.toString(),
hygiene: STAT_MAX.toString(),
energy: STAT_MAX.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
const babyName = previewRef.current?.name ?? 'Egg';
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: `${babyName} is a baby Blobbi.`,
tags: babyTags,
});
eggTagsRef.current = babyTags;
updateCompanionEvent(event);
invalidateCompanion();
}, [publishEvent, updateCompanionEvent, invalidateCompanion]);
// ── Egg click ──
const handleEggClick = useCallback(() => {
if (phase === 'egg') {
triggerShake('animate-egg-onboard-shake-light');
setPhase('crack_1');
} else if (phase === 'crack_1') {
triggerShake('animate-egg-onboard-shake-medium');
setPhase('crack_2');
} else if (phase === 'crack_2') {
triggerShake('animate-egg-onboard-shake-heavy');
setPhase('crack_3');
} else if (phase === 'crack_3') {
// Final click -> hatch!
setPhase('hatching');
setShowFlash(true);
// Fire the actual hatch mutation
executeHatch().catch(console.error);
// After flash, reveal the baby
setTimeout(() => {
setShowFlash(false);
setShowRevealGlow(true);
setPhase('reveal');
// Fade in blobbi
setTimeout(() => setBlobbiVisible(true), 400);
// After blobbi settles, start dialog
setTimeout(() => {
setPhase('dialog');
setDialogLineIndex(0);
setDialogActive(true);
}, 2200);
}, 1400);
}
}, [phase, triggerShake, executeHatch]);
// ── Dialog click: complete line or advance ──
const handleDialogClick = useCallback(() => {
if (phase !== 'dialog') return;
if (!dialogTypewriter.done) {
// Complete the current line instantly
dialogTypewriter.complete();
return;
}
// Advance to next line
const nextIndex = dialogLineIndex + 1;
if (nextIndex < BIRTH_DIALOG.length) {
setDialogActive(false);
setDialogLineIndex(nextIndex);
// Small pause before next line starts
setTimeout(() => setDialogActive(true), 150);
} else {
// All lines done -> naming
setDialogActive(false);
setTimeout(() => {
setPhase('naming');
setTimeout(() => {
setNamingVisible(true);
setTimeout(() => nameInputRef.current?.focus(), 600);
}, 200);
}, 400);
}
}, [phase, dialogTypewriter, dialogLineIndex]);
// ── Complete ceremony ──
const completeCeremony = useCallback(async (finalName: string) => {
try {
// Update egg/baby name if changed
const currentTags = eggTagsRef.current;
if (currentTags && finalName !== (previewRef.current?.name ?? 'Egg')) {
const namedTags = updateBlobbiTags(currentTags, { name: finalName });
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: `${finalName} is a baby Blobbi.`,
tags: namedTags,
});
updateCompanionEvent(event);
}
// Mark onboarding done
const currentProfile = profileRef.current;
if (currentProfile) {
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
blobbi_onboarding_done: 'true',
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(profileEvent);
}
invalidateProfile();
invalidateCompanion();
} catch (error) {
console.error('[HatchingCeremony] Failed to persist completion:', error);
}
}, [publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
// ── Naming submit ──
const handleNameSubmit = useCallback(async () => {
if (isNaming || !name.trim()) return;
setIsNaming(true);
try {
await completeCeremony(name.trim());
setNamingVisible(false);
// Fade to white, then complete
setTimeout(() => {
setFadeOut(true);
setTimeout(() => {
setPhase('complete');
onComplete?.();
}, 2200);
}, 600);
} catch (error) {
console.error('[HatchingCeremony] Naming failed:', error);
toast({
title: 'Failed to save name',
description: 'Your Blobbi was created, but the name could not be saved.',
variant: 'destructive',
});
setFadeOut(true);
setTimeout(() => {
setPhase('complete');
onComplete?.();
}, 2200);
} finally {
setIsNaming(false);
}
}, [name, isNaming, completeCeremony, onComplete]);
// ── Tour visual state for EggGraphic crack rendering ──
const tourVisualState = useMemo(() => {
switch (phase) {
case 'crack_1': return 'crack_stage_1' as const;
case 'crack_2': return 'crack_stage_2' as const;
case 'crack_3': return 'crack_stage_3' as const;
case 'hatching': return 'opening' as const;
default: return 'idle' as const;
}
}, [phase]);
// ── Render ──
const isEggPhase = phase === 'egg' || phase === 'crack_1' || phase === 'crack_2' || phase === 'crack_3';
const isHatching = phase === 'hatching';
const showBaby = phase === 'reveal' || phase === 'dialog' || phase === 'naming';
if (phase === 'loading') {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)' }}
>
<div
className="absolute size-32 rounded-full opacity-20 animate-pulse"
style={{ background: `radial-gradient(circle, ${eggColor}40 0%, transparent 70%)` }}
/>
</div>
);
}
return (
<div
className="fixed inset-0 z-50 overflow-hidden select-none"
style={{
background: showBaby
? 'radial-gradient(ellipse at 50% 45%, rgb(60,140,180) 0%, rgb(70,160,195) 25%, rgb(85,175,205) 50%, rgb(100,190,210) 75%, rgb(115,195,195) 100%)'
: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)',
transition: 'background 2s ease-out',
}}
onClick={phase === 'dialog' ? handleDialogClick : undefined}
>
{/* ── Ambient background glow (egg phase only) ── */}
{!showBaby && (
<div
className="absolute inset-0 transition-opacity"
style={{
transitionDuration: '3000ms',
background: `radial-gradient(ellipse at 50% 50%, ${eggColor}30 0%, transparent 60%)`,
opacity: (isEggPhase || isHatching) ? 0.07 : 0.05,
}}
/>
)}
{/* ── Floating particles (egg phase) ── */}
{isEggPhase && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="absolute rounded-full"
style={{
width: 2 + (i % 3),
height: 2 + (i % 3),
left: `${20 + (i * 12) % 60}%`,
bottom: '40%',
backgroundColor: `${eggColor}40`,
animation: `onboard-particle-rise ${4 + i * 0.7}s ease-out ${i * 0.8}s infinite`,
}}
/>
))}
</div>
)}
{/* ── The Egg ── */}
{(isEggPhase || isHatching) && eggCompanion && (
<div className="absolute inset-0 flex items-center justify-center">
<div
ref={eggContainerRef}
className={cn(
'cursor-pointer relative',
eggVisible ? '' : 'opacity-0',
eggVisible && isEggPhase && 'animate-egg-onboard-breathe',
isHatching && 'animate-egg-onboard-burst',
)}
onClick={isEggPhase ? handleEggClick : undefined}
>
<div
className="absolute -inset-12 rounded-full blur-2xl transition-opacity duration-1000"
style={{
background: `radial-gradient(circle, ${eggColor}50 0%, transparent 70%)`,
opacity: phase === 'crack_3' ? 0.5 : phase === 'crack_2' ? 0.35 : phase === 'crack_1' ? 0.25 : 0.15,
}}
/>
<BlobbiStageVisual
companion={eggCompanion}
size="lg"
animated
className="size-56 sm:size-64 md:size-72"
tourVisualState={tourVisualState}
/>
</div>
</div>
)}
{/* ── Screen flash ── */}
{showFlash && (
<div
className="absolute inset-0 bg-white animate-onboard-screen-flash pointer-events-none"
style={{ zIndex: 80 }}
/>
)}
{/* ── Hatched baby blobbi with golden incandescence ── */}
{showBaby && babyCompanion && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{ paddingBottom: '18%' }}
>
{/* Rotating golden incandescence */}
<div className={cn(
'absolute animate-onboard-golden-fadein',
blobbiVisible ? '' : 'opacity-0',
)}>
<div
className="animate-onboard-golden-rotate"
style={{
width: 900,
height: 900,
background: `conic-gradient(
from 0deg,
rgba(255, 250, 230, 0.18) 0deg,
rgba(255, 245, 210, 0.50) 50deg,
rgba(255, 250, 235, 0.22) 100deg,
rgba(255, 248, 220, 0.15) 150deg,
rgba(255, 245, 210, 0.48) 210deg,
rgba(255, 250, 230, 0.20) 270deg,
rgba(255, 248, 220, 0.15) 320deg,
rgba(255, 250, 230, 0.18) 360deg
)`,
borderRadius: '50%',
filter: 'blur(30px)',
}}
/>
</div>
{/* Bright white-gold shine directly behind blobbi */}
<div
className={cn(
'absolute rounded-full transition-opacity duration-1000',
blobbiVisible ? 'opacity-100' : 'opacity-0',
)}
style={{
width: 320,
height: 320,
background: 'radial-gradient(circle, rgba(255,255,245,0.70) 0%, rgba(255,250,225,0.30) 40%, transparent 70%)',
}}
/>
{/* Wider golden halo */}
<div
className={cn(
'absolute rounded-full transition-opacity [transition-duration:2000ms]',
blobbiVisible ? 'opacity-100' : 'opacity-0',
)}
style={{
width: 700,
height: 700,
background: 'radial-gradient(circle, rgba(255, 248, 210, 0.40) 0%, rgba(255, 240, 190, 0.18) 40%, transparent 65%)',
filter: 'blur(15px)',
}}
/>
{/* ── Sparkles everywhere ── */}
{/* Inner ring - bright twinkling sparkles */}
{Array.from({ length: 20 }).map((_, i) => {
const angle = (i / 20) * Math.PI * 2;
const r = 80 + (i % 4) * 35;
const size = 4 + (i % 3) * 3;
return (
<div
key={`inner-${i}`}
className="absolute"
style={{
width: size,
height: size,
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
borderRadius: '50%',
background: i % 2 === 0
? 'radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.4) 40%, transparent 70%)'
: 'radial-gradient(circle, rgba(255,240,130,1) 0%, rgba(255,220,80,0.3) 50%, transparent 70%)',
animation: `onboard-sparkle-twinkle ${1.5 + (i % 6) * 0.5}s ease-in-out ${i * 0.15}s infinite`,
}}
/>
);
})}
{/* Outer ring - larger, slower sparkles */}
{Array.from({ length: 16 }).map((_, i) => {
const angle = (i / 16) * Math.PI * 2 + 0.3;
const r = 170 + (i % 3) * 50;
const size = 5 + (i % 4) * 3;
return (
<div
key={`outer-${i}`}
className="absolute"
style={{
width: size,
height: size,
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
borderRadius: '50%',
background: i % 3 === 0
? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 60%)'
: 'radial-gradient(circle, rgba(255,235,120,0.85) 0%, transparent 60%)',
animation: `onboard-sparkle-twinkle ${2.5 + (i % 5) * 0.7}s ease-in-out ${i * 0.25}s infinite`,
}}
/>
);
})}
{/* Scattered wide-field sparkles */}
{Array.from({ length: 24 }).map((_, i) => {
const x = (Math.sin(i * 2.7 + 1.3) * 0.5 + 0.5) * 80 + 10;
const y = (Math.cos(i * 3.1 + 0.7) * 0.5 + 0.5) * 70 + 10;
const size = 3 + (i % 3) * 2;
return (
<div
key={`field-${i}`}
className="absolute"
style={{
width: size,
height: size,
left: `${x}%`,
top: `${y}%`,
borderRadius: '50%',
background: i % 4 === 0
? 'radial-gradient(circle, rgba(255,255,255,0.95) 0%, transparent 70%)'
: 'radial-gradient(circle, rgba(255,240,160,0.8) 0%, transparent 70%)',
animation: `onboard-sparkle-twinkle ${2 + (i % 7) * 0.6}s ease-in-out ${i * 0.18}s infinite`,
}}
/>
);
})}
{/* Drifting light motes rising from below */}
{Array.from({ length: 10 }).map((_, i) => {
const x = (Math.sin(i * 1.9) * 0.5 + 0.5) * 70 + 15;
return (
<div
key={`drift-${i}`}
className="absolute"
style={{
width: 5 + (i % 3) * 3,
height: 5 + (i % 3) * 3,
left: `${x}%`,
bottom: '20%',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(255,250,200,0.9) 0%, rgba(255,230,120,0.3) 50%, transparent 100%)',
animation: `onboard-sparkle-drift ${4 + i * 0.5}s ease-out ${i * 0.5}s infinite`,
}}
/>
);
})}
{/* The baby blobbi */}
<div className={cn(
'relative transition-opacity duration-1000',
blobbiVisible ? 'opacity-100' : 'opacity-0',
)}>
<BlobbiStageVisual
companion={babyCompanion}
size="lg"
animated
className="size-[30rem] sm:size-[36rem] md:size-[44rem]"
/>
</div>
</div>
)}
{/* ── Dialog text (no box, blur behind) ── */}
{phase === 'dialog' && (
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
<div className="relative max-w-md w-full text-center">
{/* Soft feathered backdrop with shadow */}
<div
className="absolute -inset-32"
style={{
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
}}
/>
{/* Speaker */}
<div className="relative">
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
???
</p>
{/* Typewriter text */}
<p className="text-base sm:text-lg text-white leading-relaxed font-light min-h-[3em]">
{dialogTypewriter.displayed}
{!dialogTypewriter.done && (
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
)}
</p>
{/* Advance indicator */}
{dialogTypewriter.done && (
<div className="mt-4 animate-onboard-continue-pulse">
<span className="text-xs text-white/30">&#9660;</span>
</div>
)}
</div>
</div>
</div>
)}
{/* ── Naming ── */}
{phase === 'naming' && (
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
<div className={cn(
'relative max-w-md w-full text-center',
namingVisible ? 'animate-onboard-soft-fade-in' : 'opacity-0',
)}>
{/* Soft feathered backdrop with shadow */}
<div
className="absolute -inset-32"
style={{
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
}}
/>
<div className="relative">
{/* Speaker */}
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
???
</p>
{/* Typewriter question */}
<p className="text-base sm:text-lg text-white/85 leading-relaxed font-light mb-6 min-h-[1.5em] whitespace-pre-line">
{namingTypewriter.displayed}
{!namingTypewriter.done && (
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
)}
</p>
{/* Input + confirm (appear after typewriter done) */}
{namingTypewriter.done && (
<div className="space-y-3 animate-onboard-soft-fade-in">
<Input
ref={nameInputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="..."
maxLength={32}
autoFocus
className={cn(
'text-center text-lg font-light h-12',
'bg-white/10 border-transparent text-white placeholder:text-white/30',
'focus:bg-white/[0.25] focus:border-transparent focus:ring-0 focus:outline-none',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'focus:shadow-[0_0_15px_rgba(255,255,255,0.15),0_0_40px_rgba(255,250,230,0.08)]',
'transition-all duration-300',
'rounded-full transition-shadow duration-500',
)}
onKeyDown={(e) => {
if (e.key === 'Enter' && name.trim()) handleNameSubmit();
}}
/>
{name.trim() && (
<Button
onClick={handleNameSubmit}
disabled={isNaming}
className={cn(
'max-w-[12rem] mx-auto h-10 px-8 text-sm font-light tracking-wide',
'bg-white/15 hover:bg-white/22 text-white/80 border-transparent',
'rounded-full transition-all duration-300',
'focus-visible:ring-0 focus-visible:ring-offset-0',
)}
variant="ghost"
>
That&apos;s the one.
</Button>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* ── Fade to white on completion ── */}
{fadeOut && (
<div
className="absolute inset-0 bg-white pointer-events-none"
style={{
zIndex: 90,
animation: 'blobbi-fade-to-white 2s ease-in forwards',
}}
/>
)}
</div>
);
}
@@ -1,32 +1,19 @@
/**
* BlobbiOnboardingFlow - Main component that orchestrates the onboarding steps
*
* This component renders the appropriate onboarding step based on the user's
* actual profile state. The initial step is derived from whether the profile
* exists - not hardcoded.
*
* MODES:
* 1. Full onboarding (default): Auto profile creation Adoption question Preview
* 2. Adoption only (adoptionOnly=true): Skip directly to Preview for existing profiles
*
* IMPORTANT: This component should only be rendered when:
* - User has no profile (auto-creates profile using kind 0 name)
* - User has profile but no pets (shows adoption)
* - User wants to adopt another Blobbi (adoptionOnly mode)
*
* Profile creation is now automatic - no manual name entry step is needed.
* BlobbiOnboardingFlow - Immersive hatching ceremony for every new Blobbi
*
* Every new egg goes through the hatching ceremony - whether it's a user's
* first Blobbi or their tenth. The ceremony creates the egg silently in the
* background and presents a wordless, emotional hatching experience.
*
* The `adoptionOnly` prop is accepted for API compatibility but no longer
* changes the flow - every egg gets the full ceremony.
*/
import { useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useBlobbiOnboarding } from '../hooks/useBlobbiOnboarding';
import { BlobbiAdoptionStep } from './BlobbiAdoptionStep';
import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
import { Loader2 } from 'lucide-react';
import { BlobbiHatchingCeremony } from './BlobbiHatchingCeremony';
import type { BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
interface BlobbiOnboardingFlowProps {
/** Current profile (null if doesn't exist) */
@@ -43,9 +30,11 @@ interface BlobbiOnboardingFlowProps {
setStoredSelectedD: (d: string) => void;
/** Called when onboarding is complete */
onComplete?: () => void;
/**
* If true, skip profile creation and adoption question, go directly to preview.
* Use this for "Adopt another Blobbi" flow for existing users.
/** If provided, skip egg creation and use this existing egg for the ceremony. */
existingCompanion?: BlobbiCompanion | null;
/**
* Accepted for API compatibility. Every new egg goes through the ceremony.
* @deprecated No longer changes the flow.
*/
adoptionOnly?: boolean;
}
@@ -58,98 +47,20 @@ export function BlobbiOnboardingFlow({
invalidateCompanion,
setStoredSelectedD,
onComplete,
adoptionOnly = false,
existingCompanion,
adoptionOnly,
}: BlobbiOnboardingFlowProps) {
const [showAdoptConfirmDialog, setShowAdoptConfirmDialog] = useState(false);
const {
state,
actions,
coins,
} = useBlobbiOnboarding({
profile,
updateProfileEvent,
updateCompanionEvent,
invalidateProfile,
invalidateCompanion,
setStoredSelectedD,
onComplete,
adoptionOnly,
});
// Debug logging
console.log('[BlobbiOnboardingFlow] Rendering:', {
hasProfile: !!profile,
profileName: profile?.name,
step: state.step,
hasPreview: !!state.preview,
adoptionOnly,
});
// Handle adopt button click - show confirmation dialog
const handleAdoptClick = () => {
setShowAdoptConfirmDialog(true);
};
// Handle confirm adoption
const handleConfirmAdopt = async () => {
await actions.adoptPreview();
setShowAdoptConfirmDialog(false);
};
// ─── Step: Auto Profile Creation ──────────────────────────────────────────────
// Shows a loading state while profile is being auto-created
if (state.step === 'creating-profile') {
return (
<div className="flex flex-col items-center justify-center min-h-[300px] gap-4 p-8">
<Loader2 className="size-10 text-primary animate-spin" />
<p className="text-muted-foreground text-center">
Setting up your profile...
</p>
</div>
);
}
// ─── Step: Adoption Question ──────────────────────────────────────────────────
// Shown when profile exists but user has no pets yet
if (state.step === 'adoption-question') {
return (
<BlobbiAdoptionStep
blobbonautName={state.blobbonautName || profile?.name}
onStartAdoption={actions.startAdoptionPreview}
/>
);
}
// ─── Step: Egg Preview ────────────────────────────────────────────────────────
// Shown when user is previewing/choosing an egg to adopt
if (state.step === 'preview' && state.preview) {
return (
<>
<BlobbiEggPreviewCard
preview={state.preview}
coins={coins}
isFirstPreview={state.isFirstPreview}
isProcessing={state.isProcessing}
actionInProgress={state.actionInProgress === 'reroll' ? 'reroll' : state.actionInProgress === 'adopt' ? 'adopt' : null}
onReroll={actions.rerollPreview}
onAdopt={handleAdoptClick}
onNameChange={actions.setPreviewName}
/>
<BlobbiAdoptionConfirmDialog
open={showAdoptConfirmDialog}
onOpenChange={setShowAdoptConfirmDialog}
preview={state.preview}
coins={coins}
isAdopting={state.isProcessing && state.actionInProgress === 'adopt'}
onConfirm={handleConfirmAdopt}
/>
</>
);
}
// Fallback (shouldn't happen if parent logic is correct)
console.warn('[BlobbiOnboardingFlow] Unexpected state - no matching step');
return null;
return (
<BlobbiHatchingCeremony
profile={profile}
updateProfileEvent={updateProfileEvent}
updateCompanionEvent={updateCompanionEvent}
invalidateProfile={invalidateProfile}
invalidateCompanion={invalidateCompanion}
setStoredSelectedD={setStoredSelectedD}
onComplete={onComplete}
existingCompanion={existingCompanion}
eggOnly={adoptionOnly}
/>
);
}
@@ -31,7 +31,7 @@ import {
buildBlobbonautTags,
updateBlobbonautTags,
type BlobbonautProfile,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import {
generateEggPreview,
@@ -456,15 +456,18 @@ export function useBlobbiOnboarding({
updateCompanionEvent(eggEvent);
// 2. Update profile: deduct coins, add to has, set current_companion
// 2. Update profile: deduct coins, add to has list
// NOTE: We do NOT set current_companion here because the adopted Blobbi
// is still an egg. The companion mechanic only becomes available after hatching.
// Eggs should never be auto-assigned as the floating companion.
// NOTE: blobbi_onboarding_done is NOT set here — adoption alone does not
// complete onboarding. It is set when the first-hatch tour finishes.
const newCoins = coins - BLOBBI_ADOPTION_COST;
const newHas = [...profile.has, preview.d];
const profileUpdates: Record<string, string | string[]> = {
coins: newCoins.toString(),
has: newHas,
current_companion: preview.d,
onboarding_done: 'true',
};
const updatedProfileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
+5 -9
View File
@@ -1,19 +1,15 @@
/**
* Blobbi Onboarding Module
*
* Provides components and hooks for the Blobbi onboarding flow:
* 1. Auto profile creation (using kind 0 name)
* 2. Adoption question
* 3. Egg preview with reroll/adopt
*
* Every new egg goes through the immersive hatching ceremony:
* dark screen, huge egg, click-to-hatch, sentimental birth reveal, naming.
*/
// Components
export { BlobbiAdoptionStep } from './components/BlobbiAdoptionStep';
export { BlobbiEggPreviewCard } from './components/BlobbiEggPreviewCard';
export { BlobbiAdoptionConfirmDialog } from './components/BlobbiAdoptionConfirmDialog';
export { BlobbiOnboardingFlow } from './components/BlobbiOnboardingFlow';
export { BlobbiHatchingCeremony } from './components/BlobbiHatchingCeremony';
// Hooks
// Hooks (used internally; kept exported for potential external use)
export { useBlobbiOnboarding } from './hooks/useBlobbiOnboarding';
export type {
OnboardingStep,
+1 -3
View File
@@ -16,7 +16,7 @@ import {
getLocalDayString,
type BlobbiVisualTraits,
type BlobbiStats,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -135,7 +135,6 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] {
['stage', preview.stage],
['state', preview.state],
['seed', preview.seed],
['visible_to_others', 'true'],
['generation', '1'],
['breeding_ready', 'false'],
['experience', '0'],
@@ -181,7 +180,6 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
isLegacy: false,
lastInteraction: preview.createdAt,
lastDecayAt: preview.createdAt,
visibleToOthers: true,
generation: 1,
breedingReady: false,
experience: 0,
@@ -1,26 +1,24 @@
import { useMemo, useState } from 'react';
import { Package, Loader2, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { ShopItem } from '../types/shop.types';
import { getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn } from '@/lib/utils';
import { ItemEffectDisplay } from './ItemEffectDisplay';
@@ -31,20 +29,174 @@ interface BlobbiInventoryModalProps {
profile: BlobbonautProfile | null;
/** The current companion (needed for stage-based restrictions) */
companion: BlobbiCompanion | null;
/** Called when user wants to use an item. Opens the use flow. */
onUseItem?: (itemId: string, quantity: number) => void;
/** Called when user wants to use an item. Always uses once. */
onUseItem?: (itemId: string) => void;
/** Whether an item is currently being used */
isUsingItem?: boolean;
}
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
// ── Shared items content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
export function BlobbiInventoryContent({
profile: _profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryContentProps) {
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
const stage = companion?.stage ?? 'egg';
const allItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const item of allItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [companion?.stage]);
const isEmpty = inventoryItems.length === 0;
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
onUseItem(item.itemId);
};
return (
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for your Blobbi's current stage.
</p>
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + 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={cn(
"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.canUse && "grayscale"
)}>
{item.icon}
</div>
</div>
{/* Item Info - Name and Type */}
<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 capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleUseItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
{isUsingItem ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{item.type}
</Badge>
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ── Standalone Inventory Modal (kept for backwards compatibility) ──
export function BlobbiInventoryModal({
open,
onOpenChange,
@@ -53,75 +205,6 @@ export function BlobbiInventoryModal({
onUseItem,
isUsingItem = false,
}: BlobbiInventoryModalProps) {
// State for use confirmation dialog
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showUseDialog, setShowUseDialog] = useState(false);
// Resolve storage items with their metadata and usability from the shop catalog
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
// Check if item can be used for current stage
const usability = canUseItemForStage(storageItem.itemId, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
const isEmpty = inventoryItems.length === 0;
// Handlers for use dialog
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowUseDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || !onUseItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset state
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseUseDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
// 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-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
@@ -132,12 +215,7 @@ export function BlobbiInventoryModal({
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</div>
<div className="min-w-0">
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground">
{isEmpty ? 'No items yet' : `${inventoryItems.length} ${inventoryItems.length === 1 ? 'item' : 'items'}`}
</p>
</div>
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
</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" />
@@ -147,289 +225,15 @@ export function BlobbiInventoryModal({
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Visit the shop to purchase items for your Blobbi. Items you buy will appear here.
</p>
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Quantity + 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={cn(
"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.canUse && "grayscale"
)}>
{item.icon}
</div>
</div>
{/* Item Info - Name and Type */}
<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 capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Quantity Badge */}
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
×{item.quantity}
</Badge>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleSelectItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
Use
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{item.type}
</Badge>
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason on mobile */}
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<BlobbiInventoryContent
profile={profile}
companion={companion}
onUseItem={onUseItem}
isUsingItem={isUsingItem}
/>
</div>
</DialogContent>
{/* Use Item Confirmation Dialog */}
{selectedItem && companion && (
<InventoryUseConfirmDialog
open={showUseDialog}
onOpenChange={handleCloseUseDialog}
item={selectedItem}
companion={companion}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
)}
</Dialog>
);
}
// ─── Use Confirmation Dialog ──────────────────────────────────────────────────
interface InventoryUseConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function InventoryUseConfirmDialog({
open,
onOpenChange,
item,
companion,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: InventoryUseConfirmDialogProps) {
// Calculate total effect for the selected quantity by simulating sequential application
// This matches the actual behavior when items are used (clamping at each step)
const totalEffect = useMemo(() => {
if (!item.effect) return null;
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
// Apply effects N times in sequence with clamping at each step
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));
}
}
}
// Calculate actual deltas (may be less than effect * quantity due to clamping)
const result: Record<string, number> = {};
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
result[stat] = delta;
}
}
return Object.keys(result).length > 0 ? result : null;
}, [item.effect, companion.stats, quantity]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>Use Item</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 */}
{totalEffect && (
<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>
<ItemEffectDisplay effect={totalEffect} variant="badges" />
</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>
);
}
@@ -12,7 +12,6 @@ import {
import { Input } from '@/components/ui/input';
import type { ShopItem } from '../types/shop.types';
import { ItemEffectDisplay } from './ItemEffectDisplay';
import { formatCompactNumber } from '@/lib/utils';
interface BlobbiPurchaseDialogProps {
@@ -150,13 +149,6 @@ export function BlobbiPurchaseDialog({
)}
</div>
{/* Effects Summary */}
{item.effect && Object.keys(item.effect).length > 0 && (
<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">Effects per item</h4>
<ItemEffectDisplay effect={item.effect} variant="grid" />
</div>
)}
</div>
<DialogFooter>
+305 -135
View File
@@ -1,178 +1,348 @@
import { useState } from 'react';
import { ShoppingBag, Utensils, Gamepad2, Heart, Droplets, Palette, X } from 'lucide-react';
import { useState, useMemo } from 'react';
import { ShoppingBag, Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { BlobbiShopItemRow } from './BlobbiShopItemRow';
import { BlobbiPurchaseDialog } from './BlobbiPurchaseDialog';
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
import type { BlobbonautProfile } from '@/lib/blobbi';
import { getShopItemsByType } from '../lib/blobbi-shop-items';
import type { ShopItem } from '../types/shop.types';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn, formatCompactNumber } from '@/lib/utils';
type TopTab = 'items' | 'shop';
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
canUse: boolean;
reason?: string;
}
interface BlobbiShopModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
profile: BlobbonautProfile | null;
/** Initial tab to open on. Defaults to "items". */
initialTab?: TopTab;
// ── Inventory props (passed through) ──
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
const CATEGORIES: Array<{
type: ShopItemCategory;
label: string;
icon: React.ReactNode;
}> = [
{ type: 'food', label: 'Food', icon: <Utensils className="size-4" /> },
{ type: 'toy', label: 'Toys', icon: <Gamepad2 className="size-4" /> },
{ type: 'medicine', label: 'Medicine', icon: <Heart className="size-4" /> },
{ type: 'hygiene', label: 'Hygiene', icon: <Droplets className="size-4" /> },
{ type: 'accessory', label: 'Accessories', icon: <Palette className="size-4" /> },
];
export function BlobbiShopModal({ open, onOpenChange, profile }: BlobbiShopModalProps) {
const [activeCategory, setActiveCategory] = useState<ShopItemCategory>('food');
const [selectedItem, setSelectedItem] = useState<ShopItem | null>(null);
const [showPurchaseDialog, setShowPurchaseDialog] = useState(false);
export function BlobbiShopModal({
open,
onOpenChange,
profile,
initialTab = 'items',
companion,
onUseItem,
isUsingItem,
}: BlobbiShopModalProps) {
const [topTab, setTopTab] = useState<TopTab>(initialTab);
const { mutate: purchaseItem, isPending: isPurchasing } = useBlobbiPurchaseItem(profile);
const [purchasingItemId, setPurchasingItemId] = useState<string | null>(null);
const availableCoins = profile?.coins ?? 0;
const items = getShopItemsByType(activeCategory);
const allItems = getLiveShopItems();
const handlePurchaseClick = (item: ShopItem) => {
setSelectedItem(item);
setShowPurchaseDialog(true);
// Reset to initialTab when modal re-opens
const handleOpenChange = (isOpen: boolean) => {
if (isOpen) {
setTopTab(initialTab);
}
onOpenChange(isOpen);
};
const handlePurchase = (quantity: number) => {
if (!selectedItem) return;
// Instant purchase — one tap = one item
const handleBuyItem = (item: ShopItem) => {
if (isPurchasing || availableCoins < item.price) return;
setPurchasingItemId(item.id);
purchaseItem(
{
itemId: selectedItem.id,
price: selectedItem.price,
quantity,
},
{
onSuccess: () => {
setShowPurchaseDialog(false);
setSelectedItem(null);
},
}
{ itemId: item.id, price: item.price, quantity: 1 },
{ onSettled: () => setPurchasingItemId(null) },
);
};
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
// ── Items resolution — sourced from the full catalog (not inventory) ──
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
const stage = companion?.stage ?? 'egg';
const allCatalogItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const item of allCatalogItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [companion?.stage]);
// ── Inventory use item handler ──
const [usingItemId, setUsingItemId] = useState<string | null>(null);
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
setUsingItemId(item.itemId);
onUseItem(item.itemId);
};
// Clear usingItemId when isUsingItem goes false
const effectiveUsingItemId = isUsingItem ? usingItemId : null;
const inventoryEmpty = inventoryItems.length === 0;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl 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-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-pink-500/20 flex items-center justify-center shrink-0">
<ShoppingBag className="size-4 sm:size-5 text-primary" />
</div>
<DialogTitle className="text-xl sm:text-2xl truncate">Blobbi Shop</DialogTitle>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-sm sm:text-base px-3 sm:px-4 py-1">
{formatCompactNumber(availableCoins)} coins
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden rounded-2xl [&>button:last-child]:hidden">
{/* Tab Bar (replaces header) */}
<div className="flex items-center border-b bg-muted/30">
{/* Tabs */}
<button
onClick={() => setTopTab('items')}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
topTab === 'items'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground/70'
)}
>
<Package className="size-4" />
Items
{!inventoryEmpty && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
{inventoryItems.length}
</Badge>
<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>
</div>
</DialogHeader>
)}
{topTab === 'items' && (
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
)}
</button>
<button
onClick={() => setTopTab('shop')}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
topTab === 'shop'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground/70'
)}
>
<ShoppingBag className="size-4" />
Shop
{topTab === 'shop' && (
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
)}
</button>
{/* Category Tabs - Part of sticky header area */}
<div className="sticky top-[60px] sm:top-[72px] z-10 bg-background px-4 sm:px-6 pt-3 sm:pt-4 pb-2 border-b">
<div className="flex gap-1.5 sm:gap-2 overflow-x-auto pb-1 -mx-1 px-1">
{CATEGORIES.map(category => {
const isActive = activeCategory === category.type;
const itemCount = getShopItemsByType(category.type).length;
return (
<button
key={category.type}
onClick={() => setActiveCategory(category.type)}
className={cn(
'flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg transition-all whitespace-nowrap',
'border text-sm sm:text-base',
isActive
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/50 text-muted-foreground border-transparent hover:bg-muted'
)}
>
{category.icon}
<span className="font-medium hidden xs:inline">{category.label}</span>
<Badge variant="secondary" className="ml-0.5 sm:ml-1 text-xs">
{itemCount}
</Badge>
</button>
);
})}
{/* Coin badge + Close */}
<div className="flex items-center gap-1.5 pr-3 pl-2">
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-xs px-2 py-0.5">
<span className="mr-1">🪙</span>{formatCompactNumber(availableCoins)}
</Badge>
<DialogClose className="rounded-full p-1 opacity-60 hover:opacity-100 transition-opacity">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
{/* Scrollable Content Area */}
{/* Scrollable Content */}
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Accessories Coming Soon Banner */}
{activeCategory === 'accessory' && (
<div className="mx-4 sm:mx-6 mt-3 sm:mt-4 p-4 sm:p-6 rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20">
<div className="flex items-start gap-3 sm:gap-4">
<div className="size-12 sm:size-16 rounded-xl sm:rounded-2xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-2xl sm:text-3xl relative shrink-0">
🎨
<div className="absolute -top-1 -right-1 text-base sm:text-xl"></div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold mb-1">Accessories Coming Soon!</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Get ready to customize your Blobbi's appearance with amazing accessories and cosmetic items.
</p>
</div>
</div>
</div>
{topTab === 'shop' ? (
<ShopGrid
items={allItems}
availableCoins={availableCoins}
onBuy={handleBuyItem}
purchasingItemId={effectivePurchasingId}
/>
) : (
<ItemsGrid
items={inventoryItems}
onUseItem={handleUseItem}
isUsingItem={isUsingItem}
usingItemId={effectiveUsingItemId}
onGoToShop={() => setTopTab('shop')}
/>
)}
{/* Items List */}
<div className="px-4 sm:px-6 py-3 sm:py-4">
<div className="space-y-2">
{items.map(item => (
<BlobbiShopItemRow
key={item.id}
item={item}
availableCoins={availableCoins}
onPurchaseClick={handlePurchaseClick}
/>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* Purchase Dialog */}
{selectedItem && (
<BlobbiPurchaseDialog
open={showPurchaseDialog}
onOpenChange={setShowPurchaseDialog}
item={selectedItem}
availableCoins={availableCoins}
onPurchase={handlePurchase}
isPurchasing={isPurchasing}
/>
)}
</>
);
}
// ─── Shop Grid (tile layout, all items, cost in button) ───────────────────────
interface ShopGridProps {
items: ShopItem[];
availableCoins: number;
onBuy: (item: ShopItem) => void;
purchasingItemId: string | null;
}
function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridProps) {
return (
<div className="p-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map(item => {
const isDisabled = item.status === 'disabled';
const isAffordable = !isDisabled && availableCoins >= item.price;
const isBuying = purchasingItemId === item.id;
return (
<div
key={item.id}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center',
'bg-card/60 backdrop-blur-sm',
isDisabled && 'opacity-50',
!isDisabled && !isAffordable && 'opacity-70',
)}
>
{/* Icon */}
<div className="text-3xl leading-none mt-1">{item.icon}</div>
{/* Name */}
<span className="text-xs font-medium truncate w-full">{item.name}</span>
{/* Buy button with integrated cost */}
<button
onClick={() => onBuy(item)}
disabled={isDisabled || !isAffordable || !!purchasingItemId}
className={cn(
'w-full rounded-lg px-2 py-1.5 text-xs font-medium transition-colors',
isDisabled
? 'bg-muted text-muted-foreground cursor-not-allowed'
: isAffordable
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 active:scale-95 transition-transform'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{isDisabled ? (
'Soon'
) : isBuying ? (
<span className="flex items-center justify-center">
<Loader2 className="size-3 animate-spin" />
</span>
) : (
<span className="flex items-center justify-center gap-1">
<span>🪙</span> {formatCompactNumber(item.price)}
</span>
)}
</button>
</div>
);
})}
</div>
</div>
);
}
// ─── Items Grid (catalog, tile layout) ────────────────────────────────────────
interface ItemsGridProps {
items: ResolvedInventoryItem[];
onUseItem: (item: ResolvedInventoryItem) => void;
isUsingItem?: boolean;
usingItemId: string | null;
onGoToShop: () => void;
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-8 text-muted-foreground/60" />
</div>
<p className="text-sm text-muted-foreground">
No items are available for your Blobbi's current stage.
</p>
</div>
);
}
return (
<div className="p-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map(item => {
const isThisUsing = isUsingItem && usingItemId === item.itemId;
return (
<div
key={item.itemId}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center relative',
'bg-card/60 backdrop-blur-sm',
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
)}
>
{/* Icon */}
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
{/* Name */}
<span className="text-xs font-medium truncate w-full">{item.name}</span>
{/* Use button */}
{item.canUse ? (
<Button
size="sm"
variant="outline"
className="w-full h-7 text-xs"
onClick={() => onUseItem(item)}
disabled={isUsingItem}
>
{isThisUsing ? (
<Loader2 className="size-3 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="w-full">
<Button
size="sm"
variant="outline"
className="w-full h-7 text-xs"
disabled
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -7,7 +7,7 @@
* Used by:
* - BlobbiShopItemRow (shop listing)
* - BlobbiPurchaseDialog (purchase confirmation)
* - BlobbiInventoryModal (inventory listing)
* - BlobbiInventoryModal (items listing)
* - BlobbiActionInventoryModal (action item selection)
*
* All consumers should use this component to ensure consistent display of item effects.
@@ -192,30 +192,6 @@ export function ItemEffectDisplay({
return null;
}
// ─── Utility Exports ──────────────────────────────────────────────────────────
/**
* Format effects as a summary string (for compatibility with existing code).
* This is a drop-in replacement for formatEffectSummary in blobbi-shop-utils.ts.
*
* @deprecated Use <ItemEffectDisplay variant="inline" /> instead
*/
export function formatEffectSummary(effect: ItemEffect | undefined, maxEffects = 4): string {
const entries = getSortedEffectEntries(effect);
if (entries.length === 0) {
return 'No effects';
}
const displayEntries = maxEffects !== undefined ? entries.slice(0, maxEffects) : entries;
return displayEntries
.map(([stat, value]) => `${formatStatValue(value)} ${STAT_LABELS[stat]}`)
.join(', ');
}
/**
* Get sorted effect entries for custom rendering.
* Useful when you need to iterate over effects yourself.
*/
export { getSortedEffectEntries };

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