Compare commits

...

194 Commits

Author SHA1 Message Date
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
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
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
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
209 changed files with 22193 additions and 6695 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.
+29 -10
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
+98 -2
View File
@@ -716,6 +716,43 @@ await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newT
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### 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 +1014,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 +1385,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 +1441,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
+100 -6
View File
@@ -1,11 +1,105 @@
# Changelog
## [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 NIP-46 login flows (Amber, Primal)
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
@@ -60,11 +154,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
@@ -83,7 +177,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
@@ -110,10 +204,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.6"
versionName "2.4.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.6;
MARKETING_VERSION = 2.4.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.6;
MARKETING_VERSION = 2.4.1;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+1866 -23
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.2.6",
"version": "2.4.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,6 +53,17 @@
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@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.0",
"@nostrify/react": "^0.4.0",
"@nostrify/types": "^0.36.9",
@@ -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",
+100 -6
View File
@@ -1,11 +1,105 @@
# Changelog
## [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 NIP-46 login flows (Amber, Primal)
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
@@ -60,11 +154,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
@@ -83,7 +177,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
@@ -110,10 +204,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
+23 -5
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";
@@ -114,6 +116,7 @@ const hardcodedConfig: AppConfig = {
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
@@ -147,15 +150,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 +201,11 @@ export function App() {
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
+32 -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 })));
@@ -101,6 +107,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();
@@ -113,6 +139,8 @@ export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<Toaster />
<VersionCheck />
<MinimizedAudioBar />
<AudioNavigationGuard />
<DeepLinkHandler />
@@ -184,6 +212,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={
@@ -191,6 +221,7 @@ export function AppRouter() {
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
fabHref="/articles/new"
/>
}
/>
@@ -204,16 +235,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={
@@ -15,7 +15,7 @@ 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 {
@@ -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>
);
}
@@ -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';
@@ -23,8 +23,8 @@ 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 } from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
@@ -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;
}
/**
@@ -129,6 +132,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 +167,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,
});
@@ -185,13 +198,16 @@ export function useBlobbiDirectAction({
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,7 +62,7 @@ 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;
@@ -325,7 +325,7 @@ 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;
@@ -476,7 +476,7 @@ 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;
@@ -627,7 +627,7 @@ 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;
@@ -780,7 +780,7 @@ 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;
@@ -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[];
}
/**
@@ -157,14 +157,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 ───
@@ -6,15 +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, BlobbiStats } 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,
@@ -32,6 +32,7 @@ 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';
@@ -52,7 +53,10 @@ export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
}
/**
@@ -70,7 +74,7 @@ export interface UseBlobbiUseInventoryItemParams {
/** Latest profile tags after migration (use instead of profile.allTags) */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -186,14 +190,49 @@ export function useBlobbiUseInventoryItem({
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── 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 ───
// 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.
//
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
// - clean: count when hygiene or happiness INCREASES
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
//
// Use canonical companion stage for egg checks
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
let effectiveItemCount = 0; // Number of items that produced intended effects
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
@@ -203,9 +242,15 @@ export function useBlobbiUseInventoryItem({
const healthDelta = shopItem.effect.health ?? 0;
// Apply health effect N times in sequence with clamping at each step
// Only count items that actually INCREASED health (positive effect only)
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHealth = currentHealth;
currentHealth = applyStat(currentHealth, healthDelta);
// Only count as effective if health increased (not just changed)
if (healthDelta > 0 && currentHealth > prevHealth) {
effectiveItemCount++;
}
}
statsUpdate.health = currentHealth.toString();
@@ -228,11 +273,20 @@ export function useBlobbiUseInventoryItem({
const happinessDelta = shopItem.effect.happiness ?? 0;
// Apply effects N times in sequence
// Only count items that INCREASED hygiene or happiness (positive effects only)
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHygiene = currentHygiene;
const prevHappiness = currentHappiness;
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
// Count as effective if hygiene OR happiness increased (positive effects only)
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
if (hygieneIncreased || happinessIncreased) {
effectiveItemCount++;
}
}
statsUpdate.hygiene = currentHygiene.toString();
@@ -252,9 +306,49 @@ export function useBlobbiUseInventoryItem({
} else {
// Normal stats application for baby/adult
// Apply item effects N times in sequence ON TOP of decayed stats
// Use action-aware effectiveness checking for XP calculation
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
const effect = shopItem.effect;
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
const prevStats = { ...currentStats };
currentStats = applyItemEffects(currentStats, effect);
// Action-aware effectiveness check:
// Only count INTENDED positive effects, not negative side effects
let isEffective = false;
if (action === 'feed') {
// Feed: count when hunger/energy/health/happiness INCREASE
// Do NOT count hygiene decrease (that's a side effect)
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
} else if (action === 'clean') {
// Clean: count when hygiene or happiness INCREASES
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hygieneIncreased || happinessIncreased;
} else if (action === 'medicine') {
// Medicine: count when health/energy/happiness INCREASE
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = healthIncreased || energyIncreased || happinessIncreased;
} else if (action === 'play') {
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
// Playing naturally consumes energy, so energy decrease counts as valid
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
isEffective = happinessIncreased || energyDecreased;
}
if (isEffective) {
effectiveItemCount++;
}
}
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
@@ -288,9 +382,18 @@ export function useBlobbiUseInventoryItem({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (Based on effective item count) ───
// Only grant XP for items that actually changed stats.
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
// This prevents XP farming by mass-using items after stats are already maxed.
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 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,
});
@@ -330,15 +433,19 @@ export function useBlobbiUseInventoryItem({
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity }) => {
onSuccess: ({ itemName, action, quantity, 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}${quantityText} 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');
+3 -3
View File
@@ -57,10 +57,10 @@ export {
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
buildHatchPhrase,
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 +71,7 @@ export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_WALL_EDIT,
KIND_PROFILE_TABS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
@@ -111,7 +111,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 {
@@ -1,6 +1,6 @@
// 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';
@@ -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 };
}
+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);
}
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* 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
* - Direct actions (sing, play_music) give moderate XP as they're free
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
* - 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 inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
*/
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 costly and critical - highest inventory XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't consume items - they're free activities.
*/
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 total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
*
* @param action - The action performed
* @param quantity - Number of items used (defaults to 1)
* @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 (for inventory actions)
* @returns Object with xpGained and total 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);
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,210 @@
/**
* 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 { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
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 { 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 +213,53 @@ 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 === 'baby' || companion.stage === 'adult') && (
<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>
)}
</>
);
}
@@ -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 */
@@ -128,10 +134,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 +210,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 +224,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 +247,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
targetX,
energy: companion?.energy ?? 50,
onReachedTarget,
sharedMotionRef: motionRef,
});
// Entry animation management (handles route changes and companion changes)
@@ -292,7 +303,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 +321,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,83 @@ 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(() => {
@@ -136,7 +140,7 @@ export function useBlobbiCompanionState({
// 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]);
// 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]);
// 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(() => {
@@ -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';
@@ -100,7 +100,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>
);
@@ -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 ─────────────────────────────────────────────────
@@ -27,7 +27,7 @@ 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, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
@@ -36,8 +36,8 @@ import {
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,
@@ -188,14 +188,45 @@ 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]
});
@@ -0,0 +1,187 @@
/**
* 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) {
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
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.
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),
});
}
// Also invalidate for background refetch to ensure eventual consistency
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
}, [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
};
}
@@ -19,7 +19,7 @@ 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 type { StorageItem } from '@/blobbi/core/lib/blobbi';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -82,7 +82,6 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
toy: 'play',
medicine: 'medicine',
hygiene: 'clean',
accessory: null, // Accessories are cosmetic, not usable
};
/**
@@ -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,9 +1,9 @@
import { useCallback } from '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,
@@ -18,7 +18,7 @@ import {
type BlobbiCompanion,
type BlobbonautProfile,
type StorageItem,
} from '@/lib/blobbi';
} from '../lib/blobbi';
/**
* Result of a successful migration.
@@ -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;
@@ -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);
}
@@ -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 */
@@ -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;
}
+95 -19
View File
@@ -9,7 +9,7 @@
*/
import { useState, useCallback, useMemo } from 'react';
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
@@ -22,11 +22,23 @@ 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 ────────────────────────────────────────────────────────────────────
/** Tour dev actions for the first-hatch tour */
interface FirstHatchTourDevActions {
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
skipPostRequirement: () => void;
/** Reset the entire first-hatch tour so it can be tested again from scratch */
resetTour: () => void;
/** Current tour step id, or null if not active */
currentStepId: string | null;
/** Whether the tour has been completed */
isCompleted: boolean;
}
interface BlobbiDevEditorProps {
/** Whether the editor modal is open */
isOpen: boolean;
@@ -38,6 +50,8 @@ interface BlobbiDevEditorProps {
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
/** Whether an update is in progress */
isUpdating?: boolean;
/** Optional: first-hatch tour dev actions (only passed when tour system is available) */
tourDevActions?: FirstHatchTourDevActions;
}
/** Updates that can be applied to a Blobbi */
@@ -58,8 +72,6 @@ export interface BlobbiDevUpdates {
breedingReady?: boolean;
/** Generation number */
generation?: number;
/** Visibility to others */
visibleToOthers?: boolean;
}
// ─── Stat Presets ─────────────────────────────────────────────────────────────
@@ -172,6 +184,7 @@ export function BlobbiDevEditor({
companion,
onApply,
isUpdating = false,
tourDevActions,
}: BlobbiDevEditorProps) {
// ─── Local State ───
// Initialize from companion values
@@ -189,7 +202,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 +219,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 +235,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 +280,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 +542,82 @@ 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>
{/* ─── First-Hatch Tour Controls ─── */}
{tourDevActions && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">First-Hatch Tour</Label>
<Badge variant="outline" className="text-xs">
{tourDevActions.isCompleted
? 'Completed'
: tourDevActions.currentStepId
? tourDevActions.currentStepId
: 'Not started'}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Test the first-hatch tour flow without needing to create a real post.
</p>
<div className="flex flex-wrap gap-2">
{/* A. Skip Post Requirement */}
<Button
variant="outline"
size="sm"
onClick={() => {
tourDevActions.skipPostRequirement();
}}
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
className="gap-2 text-xs"
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
>
<SkipForward className="size-3.5" />
Skip Post
</Button>
{/* B. Restart First-Hatch Tour */}
<Button
variant="outline"
size="sm"
onClick={() => {
tourDevActions.resetTour();
}}
className="gap-2 text-xs"
title="Reset the entire first-hatch tour state so it can be tested again"
>
<RefreshCw className="size-3.5" />
Restart Tour
</Button>
{/* C. Reset Blobbi to Egg */}
<Button
variant="outline"
size="sm"
onClick={() => {
setStage('egg');
setState('active');
tourDevActions.resetTour();
}}
disabled={companion.stage === 'egg'}
className="gap-2 text-xs"
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
>
<Egg className="size-3.5" />
Reset to Egg + Tour
</Button>
</div>
{companion.stage !== 'egg' && stage === 'egg' && (
<p className="text-xs text-amber-500">
Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start.
</p>
)}
</div>
</>
)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
+2
View File
@@ -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 -6
View File
@@ -14,8 +14,8 @@ 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 ────────────────────────────────────────────────────────────────────
@@ -140,10 +140,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();
+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
+163 -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;
}
}
@@ -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';
@@ -26,7 +26,7 @@ import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
import { Loader2 } from 'lucide-react';
import type { BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
interface BlobbiOnboardingFlowProps {
/** Current profile (null if doesn't exist) */
@@ -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);
+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,
@@ -18,7 +18,7 @@ import {
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 { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
@@ -45,20 +45,25 @@ interface ResolvedInventoryItem extends ShopItem {
reason?: string;
}
export function BlobbiInventoryModal({
open,
onOpenChange,
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
isUsingItem?: boolean;
}
export function BlobbiInventoryContent({
profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryModalProps) {
// State for use confirmation dialog
}: BlobbiInventoryContentProps) {
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';
@@ -68,7 +73,6 @@ export function BlobbiInventoryModal({
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({
@@ -84,7 +88,6 @@ export function BlobbiInventoryModal({
const isEmpty = inventoryItems.length === 0;
// Handlers for use dialog
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem) return;
setSelectedItem(item);
@@ -95,7 +98,6 @@ export function BlobbiInventoryModal({
const handleConfirmUse = () => {
if (!selectedItem || !onUseItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset state
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
@@ -109,7 +111,6 @@ export function BlobbiInventoryModal({
}
};
// 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));
@@ -123,142 +124,117 @@ export function BlobbiInventoryModal({
};
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-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-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>
<>
<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>
<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>
<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 tab to purchase items for your Blobbi. Items you buy will appear here.
</p>
</div>
</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 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>
{/* 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 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 on mobile */}
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
<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>
))}
</div>
)}
</div>
</DialogContent>
{/* 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>
{/* Use Item Confirmation Dialog */}
{selectedItem && companion && (
@@ -276,6 +252,49 @@ export function BlobbiInventoryModal({
isUsing={isUsingItem}
/>
)}
</>
);
}
// ── Standalone Inventory Modal (kept for backwards compatibility) ──
export function BlobbiInventoryModal({
open,
onOpenChange,
profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryModalProps) {
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-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</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" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto">
<BlobbiInventoryContent
profile={profile}
companion={companion}
onUseItem={onUseItem}
isUsingItem={isUsingItem}
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -309,15 +328,12 @@ function InventoryUseConfirmDialog({
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];
@@ -327,7 +343,6 @@ function InventoryUseConfirmDialog({
}
}
// 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);
@@ -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>
+321 -135
View File
@@ -1,178 +1,364 @@
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, getShopItemById } 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 inventory item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
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, quantity: number) => 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;
// ── Inventory items resolution ──
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;
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]);
// ── 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, 1);
};
// 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.reduce((sum, i) => sum + i.quantity, 0)}
</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 (inventory, tile layout) ──────────────────────────────────────
interface ItemsGridProps {
items: ResolvedInventoryItem[];
onUseItem: (item: ResolvedInventoryItem) => void;
isUsingItem?: boolean;
usingItemId: string | null;
onGoToShop: () => void;
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, 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 mb-4">
No items yet. Visit the shop to stock up!
</p>
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
<ShoppingBag className="size-3.5" />
Browse Shop
</Button>
</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',
)}
>
{/* Quantity badge */}
<Badge
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
>
{item.quantity}
</Badge>
{/* 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>
);
}
@@ -5,12 +5,12 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { PurchaseRequest } from '../types/shop.types';
import type { BlobbonautProfile, StorageItem } from '@/lib/blobbi';
import type { BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
createStorageTags,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import { getShopItemById } from '../lib/blobbi-shop-items';
/**
+1 -34
View File
@@ -177,39 +177,6 @@ export const BLOBBI_SHOP_ITEMS: ShopItem[] = [
status: 'live',
},
// ─── Accessory Items (Disabled) ─────────────────────────────────────────────
{
id: 'acc_hat',
name: 'Party Hat',
type: 'accessory',
price: 75,
icon: '🎩',
status: 'disabled',
},
{
id: 'acc_glasses',
name: 'Cool Glasses',
type: 'accessory',
price: 60,
icon: '🕶️',
status: 'disabled',
},
{
id: 'acc_bow',
name: 'Bow Tie',
type: 'accessory',
price: 50,
icon: '🎀',
status: 'disabled',
},
{
id: 'acc_crown',
name: 'Crown',
type: 'accessory',
price: 100,
icon: '👑',
status: 'disabled',
},
];
/**
@@ -237,7 +204,7 @@ export function getLiveShopItems(): ShopItem[] {
* Get all shop item categories with their counts
*/
export function getShopCategories(): Array<{ type: ShopItemCategory; count: number; label: string }> {
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene', 'accessory'];
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene'];
return categories.map(type => ({
type,
+1 -2
View File
@@ -7,8 +7,7 @@ export type ShopItemCategory =
| 'food'
| 'toy'
| 'medicine'
| 'hygiene'
| 'accessory';
| 'hygiene';
/**
* Stat effects that items can apply to Blobbi
@@ -0,0 +1,133 @@
/**
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
*
* Rendered directly in the BlobbiPage layout so the experience feels
* focused and guided. Adapts its messaging based on the current tour step.
*
* When the post mission is completed, the card stays visible with a
* celebratory completed state for ~2s (the parent auto-advances after
* that delay). This ensures the user sees the checkmark before the
* flow progresses to the egg-tap phase.
*/
import { Send, Check, MousePointerClick } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { FirstHatchTourStepId } from '../lib/tour-types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourCardProps {
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Current tour step id for adaptive messaging */
currentStep: FirstHatchTourStepId | null;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourCard({
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
currentStep,
}: FirstHatchTourCardProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
// Determine which phase of the card to show
const isPostStep = currentStep === 'show_hatch_card';
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|| currentStep === 'egg_crack_stage_1'
|| currentStep === 'egg_crack_stage_2'
|| currentStep === 'egg_crack_stage_3';
return (
<div className="w-full max-w-sm mx-auto space-y-4">
{/* Title + description */}
<div className="text-center space-y-1.5">
<h3 className="text-lg font-semibold">
{isClickStep
? `Tap ${capitalizedName} to hatch!`
: postCompleted && isPostStep
? `${capitalizedName} heard you!`
: `${capitalizedName} is ready to hatch!`}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{isClickStep
? `Tap the egg to help ${capitalizedName} break free.`
: postCompleted && isPostStep
? 'Your post was shared. Get ready to hatch...'
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
</p>
</div>
{/* Mission card - only during post step */}
{isPostStep && (
<div className="rounded-xl border bg-card p-4 space-y-3">
{postCompleted ? (
/* ── Completed state — celebratory, stays visible ── */
<div className="flex flex-col items-center gap-2 py-2">
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
<Check className="size-5 text-emerald-500" />
</div>
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
Post shared!
</p>
<p className="text-xs text-muted-foreground">
Continuing in a moment...
</p>
</div>
) : (
/* ── Pending state — post mission ── */
<>
<div className="flex items-start gap-3">
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium">Share a hatch post</p>
<p className="text-xs text-muted-foreground">
Your post must include:
</p>
<p className="text-xs font-medium text-primary break-words">
{requiredPhrase}
</p>
</div>
</div>
<Button
size="sm"
className="w-full"
onClick={onCreatePost}
>
<Send className="size-3.5 mr-2" />
Create Post
</Button>
</>
)}
</div>
)}
{/* Tap hint during click steps */}
{isClickStep && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<MousePointerClick className="size-4" />
<span>Tap the egg</span>
</div>
)}
{/* Extra hint for post step */}
{isPostStep && !postCompleted && (
<p className="text-xs text-center text-muted-foreground">
You can add extra text before or after the required phrase.
</p>
)}
</div>
);
}
@@ -0,0 +1,119 @@
/**
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
*
* Tells the user their egg is about to hatch and guides them to create a post.
* Contains a single mission: create the hatch post.
*/
import { Egg, Send, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Advance the tour (called after post is confirmed complete) */
onContinue: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourModal({
open,
onOpenChange,
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
onContinue,
}: FirstHatchTourModalProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm p-0 gap-0 [&>button:last-child]:hidden">
{/* Header with egg accent */}
<div className="px-6 pt-8 pb-4 text-center space-y-3">
<div className="mx-auto size-14 rounded-full bg-amber-500/10 flex items-center justify-center">
<Egg className="size-7 text-amber-500" />
</div>
<DialogTitle className="text-xl font-bold">
{capitalizedName} is ready to hatch!
</DialogTitle>
<p className="text-sm text-muted-foreground leading-relaxed">
Share a post to the Nostr network and help {capitalizedName} break free.
</p>
</div>
{/* Mission card */}
<div className="px-6 pb-4">
<div className="rounded-xl border bg-card p-4 space-y-3">
<div className="flex items-start gap-3">
{/* Status indicator */}
<div className={
postCompleted
? 'mt-0.5 size-5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0'
: 'mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0'
}>
{postCompleted && <Check className="size-3 text-emerald-500" />}
</div>
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium">
{postCompleted ? 'Post shared!' : 'Share a hatch post'}
</p>
<p className="text-xs text-muted-foreground">
Post must include the phrase:
</p>
<p className="text-xs font-medium text-primary break-words">
{requiredPhrase}
</p>
</div>
</div>
{!postCompleted && (
<Button
size="sm"
className="w-full"
onClick={onCreatePost}
>
<Send className="size-3.5 mr-2" />
Create Post
</Button>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 pb-6">
{postCompleted ? (
<Button className="w-full" onClick={onContinue}>
Continue
</Button>
) : (
<p className="text-xs text-center text-muted-foreground">
You can add extra text before or after the required phrase.
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
+226
View File
@@ -0,0 +1,226 @@
/**
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
*
* Orchestration only -- no rendering, no animations.
* The hook manages:
* - Ordered step progression
* - Persisted state via localStorage (survives refresh / close)
* - Derived booleans for UI consumption
* - Safe advance / goTo / complete / reset actions
*
* Activation is handled separately by useFirstHatchTourActivation,
* which calls `start()` when all preconditions are met.
*
*
* Future integration points
*
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
* to decide whether to start the tour.
* 2. UI components read `state.currentStepId` and render overlays,
* spotlights, modals, or animation cues accordingly.
* 3. Animation components call `actions.advance()` when their
* sequence finishes (for autoAdvance steps).
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
* on the user interaction.
* 5. EggGraphic receives a visual-state prop derived from
* `state.currentStepId` -- it does NOT own the tour logic.
*/
import { useMemo, useCallback, useRef } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
type FirstHatchTourStepId,
type FirstHatchTourPersistedState,
type TourState,
type TourActions,
} from '../lib/tour-types';
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* localStorage key for the first hatch tour state.
* Not user-scoped because onboarding state is device-local and the tour
* is inherently tied to "first ever egg on this device". If multi-user
* support on the same device becomes a concern, scope by pubkey.
*/
const STORAGE_KEY = 'blobbi:tour:first-hatch';
/** Pre-computed lookup: stepId -> index */
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
);
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
// ─── Result Type ──────────────────────────────────────────────────────────────
export interface UseFirstHatchTourResult {
/** Reactive tour state for UI consumption */
state: TourState<FirstHatchTourStepId>;
/** Actions to drive the tour forward */
actions: TourActions<FirstHatchTourStepId>;
/**
* Convenience: check if the current step matches a given id.
* Useful for conditional rendering: `isStep('egg_crack_stage_1')`.
*/
isStep: (stepId: FirstHatchTourStepId) => boolean;
/**
* Convenience: check if the current step is one of the given ids.
* Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`.
*/
isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean;
/**
* The current step definition (with autoAdvance metadata), or null.
*/
currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useFirstHatchTour(): UseFirstHatchTourResult {
// ── Persisted state ──
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
STORAGE_KEY,
FIRST_HATCH_TOUR_DEFAULT_STATE,
);
// Stable ref to current persisted state so callbacks never go stale.
const persistedRef = useRef(persisted);
persistedRef.current = persisted;
// ── Helpers ──
const updatePersisted = useCallback(
(patch: Partial<FirstHatchTourPersistedState>) => {
setPersisted((prev) => ({
...prev,
...patch,
updatedAt: Date.now(),
}));
},
[setPersisted],
);
// ── Actions ──
const start = useCallback(() => {
const p = persistedRef.current;
// No-op if already active or completed
if (p.completed || p.currentStepId !== null) return;
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
if (!firstStep) return;
updatePersisted({ currentStepId: firstStep.id });
}, [updatePersisted]);
const advance = useCallback(() => {
const p = persistedRef.current;
if (p.completed || p.currentStepId === null) return;
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
if (currentIndex === undefined) return;
const nextIndex = currentIndex + 1;
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
// Past the end -- complete
updatePersisted({ currentStepId: null, completed: true });
return;
}
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
if (nextStep.id === 'complete') {
// Reaching the 'complete' terminal step means the tour is done
updatePersisted({ currentStepId: null, completed: true });
} else {
updatePersisted({ currentStepId: nextStep.id });
}
}, [updatePersisted]);
const goTo = useCallback(
(stepId: FirstHatchTourStepId) => {
if (!STEP_INDEX_MAP.has(stepId)) {
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
}
if (stepId === 'complete') {
updatePersisted({ currentStepId: null, completed: true });
} else {
updatePersisted({ currentStepId: stepId, completed: false });
}
},
[updatePersisted],
);
const complete = useCallback(() => {
updatePersisted({ currentStepId: null, completed: true });
}, [updatePersisted]);
const reset = useCallback(() => {
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
}, [setPersisted]);
// ── Derived state ──
const currentStepIndex = persisted.currentStepId !== null
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
: -1;
const state = useMemo((): TourState<FirstHatchTourStepId> => {
const isActive = persisted.currentStepId !== null && !persisted.completed;
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
return {
isActive,
currentStepId: persisted.currentStepId,
currentStepIndex,
totalSteps,
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
isCompleted: persisted.completed,
progress: persisted.completed
? 1
: currentStepIndex >= 0
? currentStepIndex / LAST_REAL_STEP_INDEX
: 0,
};
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
start,
advance,
goTo,
complete,
reset,
}), [start, advance, goTo, complete, reset]);
// ── Convenience helpers ──
const isStep = useCallback(
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
[persisted.currentStepId],
);
const isAnyStep = useCallback(
(...stepIds: FirstHatchTourStepId[]) => {
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
},
[persisted.currentStepId],
);
const currentStepDef = currentStepIndex >= 0
? FIRST_HATCH_TOUR_STEPS[currentStepIndex]
: null;
return {
state,
actions,
isStep,
isAnyStep,
currentStepDef,
};
}
@@ -0,0 +1,164 @@
/**
* useFirstHatchTourActivation - Activation guard for the first-egg hatch tour.
*
* This hook checks all preconditions and calls `tour.actions.start()` when
* the tour should activate. It is intentionally separated from the tour
* state machine so that:
* - The state machine stays generic and reusable.
* - Activation rules are centralized in one place.
* - The rules are easy to read and modify.
*
*
* Activation rules (ALL must be true):
*
* 1. The companions list is loaded (not loading / error).
* 2. The user has exactly 1 Blobbi.
* 3. That Blobbi is in the egg stage.
* 4. No Blobbi is in baby or adult stage.
* 5. The tour has not been completed yet (checked via profile tag
* AND localStorage fallback).
*
* Completion is authoritative from the Blobbonaut profile event
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
* is a secondary signal for in-progress UI state and as a fallback
* when the profile hasn't been updated yet.
*
*/
import { useEffect, useMemo } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { UseFirstHatchTourResult } from './useFirstHatchTour';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface FirstHatchTourActivationInput {
/** The full list of the user's Blobbi companions */
companions: BlobbiCompanion[];
/** Whether the companions list is still loading */
isLoading: boolean;
/** The tour hook result (localStorage-based state machine) */
tour: UseFirstHatchTourResult;
/**
* Whether onboarding is already marked complete in the Blobbonaut profile
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
* When true, the tour will not activate regardless of localStorage state.
*/
profileOnboardingDone?: boolean;
}
export interface FirstHatchTourActivationResult {
/**
* Whether all preconditions for activating the tour are met right now.
* This is a derived boolean -- it does NOT mean the tour IS active,
* just that it SHOULD be activated. The tour may already be active
* from a previous render or a persisted state.
*/
shouldActivate: boolean;
/**
* Whether the tour is eligible (preconditions met and not yet completed).
* Useful for hiding UI that should only appear during the tour window.
*/
isEligible: boolean;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Evaluates activation preconditions and auto-starts the tour when met.
*
* Usage:
* ```ts
* const tour = useFirstHatchTour();
* const activation = useFirstHatchTourActivation({
* companions,
* isLoading: companionsLoading,
* tour,
* profileOnboardingDone: profile?.onboardingDone,
* });
* ```
*/
export function useFirstHatchTourActivation({
companions,
isLoading,
tour,
profileOnboardingDone: _profileOnboardingDone = false,
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
// ── Precondition evaluation ──
const { shouldActivate, isEligible } = useMemo(() => {
// Can't evaluate until data is loaded
if (isLoading) {
return { shouldActivate: false, isEligible: false };
}
// localStorage tour already completed — this is always authoritative
if (tour.state.isCompleted) {
return { shouldActivate: false, isEligible: false };
}
// Must have exactly 1 companion
if (companions.length !== 1) {
return { shouldActivate: false, isEligible: false };
}
const onlyBlobbi = companions[0];
// That companion must be an egg
if (onlyBlobbi.stage !== 'egg') {
return { shouldActivate: false, isEligible: false };
}
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
// but kept explicit for clarity and future-proofing if rules change)
const hasBabyOrAdult = companions.some(
(c) => c.stage === 'baby' || c.stage === 'adult',
);
if (hasBabyOrAdult) {
return { shouldActivate: false, isEligible: false };
}
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
// Some older accounts had `onboarding_done` migrated to
// `blobbi_onboarding_done=true` before the first-hatch tour
// existed, so they never experienced it. When the user is in the
// exact single-egg/no-evolved-companions state (all checks above
// passed), we intentionally ignore `profileOnboardingDone` so
// those accounts can still enter the tour.
//
// This is safe because:
// - The localStorage `tour.state.isCompleted` check above
// already prevents re-triggering for users who HAVE finished
// the tour.
// - The egg-stage + single-companion guard means this only
// fires for users who genuinely haven't hatched yet.
//
// TODO: Replace `blobbi_onboarding_done` with a dedicated
// `blobbi_first_hatch_tour_done` tag so onboarding completion
// and tour completion are tracked independently. Once that tag
// is in place, remove this safeguard and gate activation on the
// new tag instead.
// ───────────────────────────────────────────────────────────────
// (profileOnboardingDone is intentionally NOT checked here)
// All preconditions met
const eligible = true;
// Only activate if the tour is not already running
const activate = !tour.state.isActive;
return { shouldActivate: activate, isEligible: eligible };
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]);
// ── Auto-start effect ──
// When all preconditions are met and the tour hasn't started yet,
// start it. This fires once and then `shouldActivate` flips to false
// because `tour.state.isActive` becomes true.
useEffect(() => {
if (shouldActivate) {
tour.actions.start();
}
}, [shouldActivate, tour.actions]);
return { shouldActivate, isEligible };
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Blobbi Tour Module
*
* Provides the orchestration layer for guided tours / tutorials.
* Currently implements the first-egg hatch tour.
*
* Architecture:
* - tour-types.ts: Step definitions, persisted state shape, generic types
* - useFirstHatchTour: State machine (step progression, persistence, actions)
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
*
* UI components import from this barrel and read tour state to decide
* what to render. They call tour actions (advance, goTo, complete) in
* response to user interactions or animation completions.
*/
// ── Types (generic tour infrastructure) ──
export type {
TourStepDef,
TourPersistedState,
TourState,
TourActions,
} from './lib/tour-types';
// ── First Hatch Tour - Types & Constants ──
export {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
} from './lib/tour-types';
export type {
FirstHatchTourStepId,
FirstHatchTourPersistedState,
} from './lib/tour-types';
// ── First Hatch Tour - Hooks ──
export { useFirstHatchTour } from './hooks/useFirstHatchTour';
export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour';
export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation';
export type {
FirstHatchTourActivationInput,
FirstHatchTourActivationResult,
} from './hooks/useFirstHatchTourActivation';
// ── First Hatch Tour - Components ──
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
+140
View File
@@ -0,0 +1,140 @@
/**
* Tour System - Core Types
*
* Generic, reusable types for step-based guided tours.
* The tour system is designed to be:
* - Easy to extend with new tours (define steps + config)
* - Easy to reorder steps (change the STEPS array)
* - Persistent across page refreshes (localStorage)
* - Decoupled from rendering (UI reads state, doesn't own it)
*/
// ─── Generic Tour Infrastructure ──────────────────────────────────────────────
/**
* A tour step definition.
*
* Each step has a unique id and optional metadata that future UI layers
* can use to decide what to render (spotlights, modals, animations, etc.).
*/
export interface TourStepDef<StepId extends string = string> {
/** Unique identifier for this step */
id: StepId;
/**
* Whether this step auto-advances (e.g. animations) or waits for
* an explicit `advance()` / `goTo()` call from the UI.
* Default: false (manual).
*/
autoAdvance?: boolean;
}
/**
* Persisted state for a tour.
* Stored in localStorage so tours survive refresh / close / return.
*/
export interface TourPersistedState<StepId extends string = string> {
/** Current step id, or null when the tour is not yet started */
currentStepId: StepId | null;
/** Whether the tour has been completed */
completed: boolean;
/** Unix ms timestamp of last state change (for debugging / analytics) */
updatedAt: number;
}
/**
* Full runtime state exposed by a tour hook.
*/
export interface TourState<StepId extends string = string> {
/** Whether the tour is currently active (started and not yet completed) */
isActive: boolean;
/** Current step id, or null when idle / completed */
currentStepId: StepId | null;
/** 0-based index of the current step in the steps array, or -1 */
currentStepIndex: number;
/** Total number of steps */
totalSteps: number;
/** Whether the current step is the last one before completion */
isLastStep: boolean;
/** Whether the tour has been completed (persisted) */
isCompleted: boolean;
/** Progress as a fraction 0..1 */
progress: number;
}
/**
* Actions exposed by a tour hook.
*/
export interface TourActions<StepId extends string = string> {
/** Start the tour from the first step (no-op if already active or completed) */
start: () => void;
/** Advance to the next step. Completes the tour if on the last step. */
advance: () => void;
/** Jump to a specific step by id. Throws if the step doesn't exist. */
goTo: (stepId: StepId) => void;
/** Mark the tour as completed and reset to idle. */
complete: () => void;
/** Reset the tour entirely (clears persisted state). For dev/testing. */
reset: () => void;
}
// ─── First Hatch Tour ─────────────────────────────────────────────────────────
/**
* Step ids for the first-egg hatch tour.
*
* Flow:
* 1. idle initial state (auto-advances immediately)
* 2. show_hatch_card egg with initial crack + wiggle + inline card
* 3. egg_glowing_waiting_click post done, egg glows, waiting for user click
* 4. egg_crack_stage_1 click 1: crack expands
* 5. egg_crack_stage_2 click 2: crack expands further
* 6. egg_crack_stage_3 click 3: crack reaches edges
* 7. egg_opening shell opens (auto-advance after animation)
* 8. egg_hatching bright light + baby reveal (auto-advance)
* 9. complete terminal, marks tour done
*
* The order here matches the intended flow. To reorder steps,
* change FIRST_HATCH_TOUR_STEPS (the array), not this type.
*/
export type FirstHatchTourStepId =
| 'idle'
| 'show_hatch_card'
| 'egg_glowing_waiting_click'
| 'egg_crack_stage_1'
| 'egg_crack_stage_2'
| 'egg_crack_stage_3'
| 'egg_opening'
| 'egg_hatching'
| 'complete';
/**
* Ordered step definitions for the first hatch tour.
*
* To add / remove / reorder steps, edit this array.
* The tour state machine walks through these in order.
*/
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
{ id: 'idle' },
{ id: 'show_hatch_card' },
{ id: 'egg_glowing_waiting_click' },
{ id: 'egg_crack_stage_1' },
{ id: 'egg_crack_stage_2' },
{ id: 'egg_crack_stage_3' },
{ id: 'egg_opening', autoAdvance: true },
{ id: 'egg_hatching', autoAdvance: true },
{ id: 'complete' },
];
/**
* Persisted state shape for the first hatch tour.
*/
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
/**
* Default persisted state for a brand-new first hatch tour.
*/
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
currentStepId: null,
completed: false,
updatedAt: 0,
};
+104
View File
@@ -0,0 +1,104 @@
/**
* BlobbiAdultSvgRenderer Pure SVG rendering component for adult Blobbi.
*
* This component is the leaf node of the visual pipeline. It:
* 1. Resolves the base SVG for the adult form
* 2. Customizes colors and unique IDs
* 3. Adds eye animation infrastructure (blink clip-paths, gaze groups)
* 4. Applies visual recipe or emotion preset
* 5. Applies manual body effects (when no recipe is provided)
* 6. Sanitizes the SVG
* 7. Renders via dangerouslySetInnerHTML
*
* It does NOT know about:
* - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset)
* - Render mode (page vs companion)
* - Reaction CSS classes (sway / bounce)
* - Companion runtime (drag, float, position)
*
* This separation ensures that the SVG DOM node stays mounted and stable
* as long as the visual inputs don't change. SMIL and CSS animations
* inside the SVG continue running across parent rerenders.
*/
import { useMemo } from 'react';
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
import { addEyeAnimation } from './lib/eye-animation';
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
import type { BlobbiEmotion } from './lib/emotion-types';
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
import { debugBlobbi } from './lib/debug';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
export interface BlobbiAdultSvgRendererProps {
/** The Blobbi data */
blobbi: Blobbi;
/** Whether the Blobbi is sleeping */
isSleeping: boolean;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (used in CSS class names). */
recipeLabel?: string;
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
emotion?: BlobbiEmotion;
/** Body-level visual effects (manual/external use only — not from status reaction). */
bodyEffects?: BodyEffectsSpec;
/** Additional CSS classes for the container */
className?: string;
}
/**
* Pure SVG renderer for adult Blobbi.
*
* IMPORTANT: This component must remain a pure rendering leaf. It must NOT:
* - Run eye-tracking hooks (those belong in the Visual wrapper)
* - Know about render modes or companion runtime
* - Apply reaction CSS classes (those belong on an outer wrapper)
*
* The parent Visual wrapper owns the DOM query boundary (containerRef)
* that eye hooks use to find SVG elements via querySelector.
*/
export function BlobbiAdultSvgRenderer({
blobbi,
isSleeping: _isSleeping,
recipe: recipeProp,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiAdultSvgRendererProps) {
const customizedSvg = useMemo(() => {
debugBlobbi('svg-rebuild', 'adult customizedSvg rebuild');
// Always use the base (awake) SVG — sleeping is a recipe overlay, not an asset swap
const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping: false });
const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, false);
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
if (recipeProp) {
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'adult', form, blobbi.id);
} else if (emotion !== 'neutral') {
const resolved = resolveVisualRecipe(emotion);
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'adult', form, blobbi.id);
}
if (bodyEffects && !recipeProp) {
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
}
return animatedSvg;
}, [blobbi, recipeProp, recipeLabel, emotion, bodyEffects]);
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
);
}
+87 -120
View File
@@ -1,167 +1,134 @@
/**
* BlobbiAdultVisual - Reusable component for rendering Blobbi adults
* BlobbiAdultVisual Visual wrapper for rendering Blobbi adults.
*
* Uses the adult-blobbi module for SVG resolution and customization.
* Handles awake vs sleeping states automatically.
* Supports multiple adult evolution forms.
* Eyes always track the mouse cursor in real-time.
* Responsibilities:
* - Owns the container ref for eye hooks to query SVG DOM
* - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking)
* - Runs useExternalEyeOffset (companion gaze RAF loop)
* - Applies reaction CSS classes (sway/bounce) in page mode
* - Delegates SVG rendering to BlobbiAdultSvgRenderer
*
* The SVG renderer is a separate component so the dangerouslySetInnerHTML
* node stays mounted even when wrapper-level props change (reaction,
* className toggles, etc.).
*
* Render modes:
* - 'page' (default): Mouse tracking enabled, reaction classes applied here.
* - 'companion': Mouse tracking disabled (gaze via ref), reaction classes
* suppressed (applied by outer companion wrapper instead).
*/
import { useMemo, useRef, useEffect } from 'react';
import { useRef, type RefObject } from 'react';
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
import { cn } from '@/lib/utils';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
import { addEyeAnimation } from './lib/eye-animation';
import { applyEmotion, type BlobbiEmotion } from './lib/emotions';
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
import type { Blobbi } from '@/types/blobbi';
import { isBlobbiSleeping } from '@/types/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Reaction states for adult Blobbi animations
*/
export type AdultReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
/**
* External eye offset for companion control
* Values range from -1 to 1, converted to pixel movement internally
*/
export interface ExternalEyeOffset {
x: number;
y: number;
}
import { useExternalEyeOffset } from './lib/useExternalEyeOffset';
import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types';
import type { BlobbiVisualRecipe } from './lib/recipe';
import type { BlobbiEmotion } from './lib/emotion-types';
import type { BodyEffectsSpec } from './lib/bodyEffects';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
import { BlobbiAdultSvgRenderer } from './BlobbiAdultSvgRenderer';
export interface BlobbiAdultVisualProps {
/** The Blobbi data */
blobbi: Blobbi;
/** Reaction state for music/sing animations */
reaction?: AdultReactionState;
reaction?: BlobbiReactionState;
/** Controls eye tracking behavior (default: 'follow-pointer') */
lookMode?: BlobbiLookMode;
/** Disable blinking animation (for photo/export mode) */
disableBlink?: boolean;
/**
* External eye offset from companion system.
* When provided, bypasses internal mouse tracking and uses this offset directly.
* Values should be -1 to 1, will be converted to pixel movement.
*/
/** External eye offset (value-based — causes rerenders). */
externalEyeOffset?: ExternalEyeOffset;
/**
* Emotional state to display.
* Adds visual overlays like eyebrows, modified mouth, and tears.
* Default: 'neutral' (no modifications)
*/
/** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */
externalEyeOffsetRef?: RefObject<ExternalEyeOffset>;
/** Render mode. Default: 'page'. */
renderMode?: BlobbiRenderMode;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (used in CSS class names). */
recipeLabel?: string;
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
emotion?: BlobbiEmotion;
/** Body-level visual effects (manual/external use only). */
bodyEffects?: BodyEffectsSpec;
/** Additional CSS classes for the container */
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
/**
* Renders an adult Blobbi using inline SVG.
*
* - Resolves the correct form from blobbi data (evolutionForm or seed-derived)
* - Selects the correct SVG variant (awake or sleeping) based on state
* - Applies color customization from Blobbi traits
* - Eyes always track the mouse cursor (instant, real-time)
* - Renders safely using dangerouslySetInnerHTML
*/
export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', className }: BlobbiAdultVisualProps) {
export function BlobbiAdultVisual({
blobbi,
reaction = 'idle',
lookMode = 'follow-pointer',
disableBlink = false,
externalEyeOffset,
externalEyeOffsetRef,
renderMode = 'page',
recipe,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiAdultVisualProps) {
const isSleeping = isBlobbiSleeping(blobbi);
// This ref is the DOM query boundary for eye hooks. useBlobbiEyes and
// useExternalEyeOffset use querySelector on this element to find SVG
// eye elements rendered by the child SvgRenderer.
const containerRef = useRef<HTMLDivElement>(null);
// Disable reactions when sleeping
const isCompanion = renderMode === 'companion';
const effectiveReaction = isSleeping ? 'idle' : reaction;
// Eye animation hook - handles DOM manipulation internally
// When externalEyeOffset is provided, we disable tracking but keep blinking
// ── Eye hooks ──────────────────────────────────────────────────────────────
useBlobbiEyes(containerRef, {
isSleeping,
maxMovement: 2.5, // Slightly more movement for larger adult form
maxMovement: 2.5,
lookMode,
disableBlink,
disableTracking: !!externalEyeOffset, // External system controls eye position
disableTracking: isCompanion,
});
// External eye offset control - applies offset directly when provided
// This bypasses useBlobbiEyes and gives companion full control
useEffect(() => {
if (!externalEyeOffset || !containerRef.current || isSleeping) return;
useExternalEyeOffset({
containerRef,
externalEyeOffset,
externalEyeOffsetRef,
isSleeping,
variant: 'adult',
});
const eyeElements = containerRef.current.querySelectorAll<SVGGElement>('.blobbi-eye-left, .blobbi-eye-right');
if (eyeElements.length === 0) return;
// Convert -1 to 1 offset to pixel movement
// Increased max movement for more visible eye tracking (4.5px horizontal for adults)
const maxMovementX = 4.5;
const x = externalEyeOffset.x * maxMovementX;
// Asymmetric vertical movement:
// - Upward (negative y): stronger movement (1.0x) for clear "looking up" effect
// - Downward (positive y): reduced movement (0.6x) to avoid looking too droopy
// Y offset: -1 = looking up, +1 = looking down
const maxMovementYUp = 4.5; // Full range for looking up
const maxMovementYDown = 2.7; // Reduced range for looking down (0.6x)
const y = externalEyeOffset.y < 0
? externalEyeOffset.y * maxMovementYUp // Looking up: full range
: externalEyeOffset.y * maxMovementYDown; // Looking down: reduced range
eyeElements.forEach(el => {
el.setAttribute('transform', `translate(${x} ${y})`);
});
}, [externalEyeOffset, isSleeping]);
// Memoize the customized SVG to avoid unnecessary processing
const customizedSvg = useMemo(() => {
// Get form and base SVG
const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping });
// Apply color customization
const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, isSleeping);
// Add eye animation wrappers when awake (eyes are closed when sleeping)
if (!isSleeping) {
// Pass base color for eyelid generation
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
// Apply emotion overlays (eyebrows, sad mouth, tears, etc.)
// Pass form for form-specific adjustments (e.g., owli/froggi eyebrow positioning)
if (emotion !== 'neutral') {
animatedSvg = applyEmotion(animatedSvg, emotion, 'adult', form);
}
return animatedSvg;
}
return colorizedSvg;
}, [blobbi, isSleeping, emotion]);
// Defense-in-depth: sanitize the final SVG before DOM injection.
// The upstream pipeline validates inputs (normalizeHexColor, instanceId sanitization),
// but this catches anything unexpected from the 3000+ lines of SVG string manipulation.
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
// ── Render ─────────────────────────────────────────────────────────────────
// In companion mode, reaction classes are applied by an outer wrapper to
// keep the dangerouslySetInnerHTML div className-stable.
return (
<div
ref={containerRef}
className={cn(
'relative flex items-center justify-center',
// Reduced opacity when sleeping for visual feedback
isSleeping && 'opacity-70',
// Reaction animations for adult
(effectiveReaction === 'listening' ||
// No opacity change for sleeping — sleeping is a recipe overlay, not a visual dim
!isCompanion && (effectiveReaction === 'listening' ||
effectiveReaction === 'swaying' ||
effectiveReaction === 'happy') &&
'animate-blobbi-sway',
effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className,
)}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
>
<BlobbiAdultSvgRenderer
blobbi={blobbi}
isSleeping={isSleeping}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* BlobbiBabySvgRenderer Pure SVG rendering component for baby Blobbi.
*
* This component is the leaf node of the visual pipeline. It:
* 1. Resolves the base SVG for the baby
* 2. Customizes colors and unique IDs
* 3. Adds eye animation infrastructure (blink clip-paths, gaze groups)
* 4. Applies visual recipe or emotion preset
* 5. Applies manual body effects (when no recipe is provided)
* 6. Sanitizes the SVG
* 7. Renders via dangerouslySetInnerHTML
*
* It does NOT know about:
* - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset)
* - Render mode (page vs companion)
* - Reaction CSS classes (sway / bounce)
* - Companion runtime (drag, float, position)
*/
import { useMemo } from 'react';
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
import { addEyeAnimation } from './lib/eye-animation';
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
import type { BlobbiEmotion } from './lib/emotion-types';
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
import { debugBlobbi } from './lib/debug';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
export interface BlobbiBabySvgRendererProps {
/** The Blobbi data */
blobbi: Blobbi;
/** Whether the Blobbi is sleeping */
isSleeping: boolean;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (used in CSS class names). */
recipeLabel?: string;
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
emotion?: BlobbiEmotion;
/** Body-level visual effects (manual/external use only — not from status reaction). */
bodyEffects?: BodyEffectsSpec;
/** Additional CSS classes for the container */
className?: string;
}
/**
* Pure SVG renderer for baby Blobbi.
*
* IMPORTANT: This component must remain a pure rendering leaf. It must NOT:
* - Run eye-tracking hooks (those belong in the Visual wrapper)
* - Know about render modes or companion runtime
* - Apply reaction CSS classes (those belong on an outer wrapper)
*
* The parent Visual wrapper owns the DOM query boundary (containerRef)
* that eye hooks use to find SVG elements via querySelector.
*/
export function BlobbiBabySvgRenderer({
blobbi,
isSleeping: _isSleeping,
recipe: recipeProp,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiBabySvgRendererProps) {
const customizedSvg = useMemo(() => {
debugBlobbi('svg-rebuild', 'baby customizedSvg rebuild');
// Always use the base (awake) SVG — sleeping is a recipe overlay, not an asset swap
const baseSvg = resolveBabySvg(blobbi, { isSleeping: false });
const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, false);
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
if (recipeProp) {
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'baby', undefined, blobbi.id);
} else if (emotion !== 'neutral') {
const resolved = resolveVisualRecipe(emotion);
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'baby', undefined, blobbi.id);
}
if (bodyEffects && !recipeProp) {
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
}
return animatedSvg;
}, [blobbi, recipeProp, recipeLabel, emotion, bodyEffects]);
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
);
}
+78 -119
View File
@@ -1,161 +1,120 @@
/**
* BlobbiBabyVisual - Reusable component for rendering Blobbi babies
* BlobbiBabyVisual Visual wrapper for rendering Blobbi babies.
*
* Uses the baby-blobbi module for SVG resolution and customization.
* Handles awake vs sleeping states automatically.
* Eyes always track the mouse cursor in real-time.
* Responsibilities:
* - Owns the container ref for eye hooks to query SVG DOM
* - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking)
* - Runs useExternalEyeOffset (companion gaze RAF loop)
* - Applies reaction CSS classes (sway/bounce) in page mode
* - Delegates SVG rendering to BlobbiBabySvgRenderer
*
* Render modes:
* - 'page' (default): Mouse tracking enabled, reaction classes applied here.
* - 'companion': Mouse tracking disabled (gaze via ref), reaction classes
* suppressed (applied by outer companion wrapper instead).
*/
import { useMemo, useRef, useEffect } from 'react';
import { useRef, type RefObject } from 'react';
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
import { addEyeAnimation } from './lib/eye-animation';
import { applyEmotion, type BlobbiEmotion } from './lib/emotions';
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
import { cn } from '@/lib/utils';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
import type { Blobbi } from '@/types/blobbi';
import { isBlobbiSleeping } from '@/types/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Reaction states for baby Blobbi animations
*/
export type BabyReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
/**
* External eye offset for companion control
* Values range from -1 to 1, converted to pixel movement internally
*/
export interface ExternalEyeOffset {
x: number;
y: number;
}
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
import { useExternalEyeOffset } from './lib/useExternalEyeOffset';
import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types';
import type { BlobbiVisualRecipe } from './lib/recipe';
import type { BlobbiEmotion } from './lib/emotion-types';
import type { BodyEffectsSpec } from './lib/bodyEffects';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
import { BlobbiBabySvgRenderer } from './BlobbiBabySvgRenderer';
export interface BlobbiBabyVisualProps {
/** The Blobbi data */
blobbi: Blobbi;
/** Reaction state for music/sing animations */
reaction?: BabyReactionState;
/** Controls eye tracking behavior (default: 'follow-pointer') */
reaction?: BlobbiReactionState;
lookMode?: BlobbiLookMode;
/** Disable blinking animation (for photo/export mode) */
disableBlink?: boolean;
/**
* External eye offset from companion system.
* When provided, bypasses internal mouse tracking and uses this offset directly.
* Values should be -1 to 1, will be converted to pixel movement.
*/
externalEyeOffset?: ExternalEyeOffset;
/**
* Emotional state to display.
* Adds visual overlays like eyebrows, modified mouth, and tears.
* Default: 'neutral' (no modifications)
*/
/** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */
externalEyeOffsetRef?: RefObject<ExternalEyeOffset>;
/** Render mode. Default: 'page'. */
renderMode?: BlobbiRenderMode;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (CSS class names). */
recipeLabel?: string;
/** Named emotion preset. Ignored when `recipe` is provided. */
emotion?: BlobbiEmotion;
/** Additional CSS classes for the container */
/** Body-level visual effects — for manual/external use only. */
bodyEffects?: BodyEffectsSpec;
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
/**
* Renders a baby Blobbi using inline SVG.
*
* - Resolves the correct SVG (awake or sleeping) based on state
* - Applies color customization from Blobbi traits
* - Eyes always track the mouse cursor (instant, real-time)
* - Renders safely using dangerouslySetInnerHTML
*/
export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', className }: BlobbiBabyVisualProps) {
export function BlobbiBabyVisual({
blobbi,
reaction = 'idle',
lookMode = 'follow-pointer',
disableBlink = false,
externalEyeOffset,
externalEyeOffsetRef,
renderMode = 'page',
recipe,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiBabyVisualProps) {
const isSleeping = isBlobbiSleeping(blobbi);
// DOM query boundary for eye hooks. See BlobbiAdultVisual for details.
const containerRef = useRef<HTMLDivElement>(null);
// Disable reactions when sleeping
const isCompanion = renderMode === 'companion';
const effectiveReaction = isSleeping ? 'idle' : reaction;
// Eye animation hook - handles DOM manipulation internally
// When externalEyeOffset is provided, we disable tracking but keep blinking
// ── Eye hooks ──────────────────────────────────────────────────────────────
useBlobbiEyes(containerRef, {
isSleeping,
maxMovement: 2,
lookMode,
disableBlink,
disableTracking: !!externalEyeOffset, // External system controls eye position
disableTracking: isCompanion,
});
// External eye offset control - applies offset directly when provided
// This bypasses useBlobbiEyes and gives companion full control
useEffect(() => {
if (!externalEyeOffset || !containerRef.current || isSleeping) return;
useExternalEyeOffset({
containerRef,
externalEyeOffset,
externalEyeOffsetRef,
isSleeping,
variant: 'baby',
});
const eyeElements = containerRef.current.querySelectorAll<SVGGElement>('.blobbi-eye-left, .blobbi-eye-right');
if (eyeElements.length === 0) return;
// Convert -1 to 1 offset to pixel movement
// Increased max movement for more visible eye tracking (4px horizontal)
const maxMovementX = 4;
const x = externalEyeOffset.x * maxMovementX;
// Asymmetric vertical movement:
// - Upward (negative y): stronger movement (1.0x) for clear "looking up" effect
// - Downward (positive y): reduced movement (0.6x) to avoid looking too droopy
// Y offset: -1 = looking up, +1 = looking down
const maxMovementYUp = 4; // Full range for looking up
const maxMovementYDown = 2.4; // Reduced range for looking down (0.6x)
const y = externalEyeOffset.y < 0
? externalEyeOffset.y * maxMovementYUp // Looking up: full range
: externalEyeOffset.y * maxMovementYDown; // Looking down: reduced range
eyeElements.forEach(el => {
el.setAttribute('transform', `translate(${x} ${y})`);
});
}, [externalEyeOffset, isSleeping]);
// Memoize the customized SVG to avoid unnecessary processing
const customizedSvg = useMemo(() => {
const baseSvg = resolveBabySvg(blobbi, { isSleeping });
const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, isSleeping);
// Add eye animation wrappers (only when not sleeping)
if (!isSleeping) {
// Pass base color for eyelid generation
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
// Apply emotion overlays (eyebrows, sad mouth, tears, etc.)
// Pass 'baby' variant for baby-specific adjustments (e.g., eyebrow positioning)
if (emotion !== 'neutral') {
animatedSvg = applyEmotion(animatedSvg, emotion, 'baby');
}
return animatedSvg;
}
return colorizedSvg;
}, [blobbi, isSleeping, emotion]);
// Defense-in-depth: sanitize the final SVG before DOM injection.
// The upstream pipeline validates inputs (normalizeHexColor, instanceId sanitization),
// but this catches anything unexpected from the 3000+ lines of SVG string manipulation.
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div
ref={containerRef}
className={cn(
'relative flex items-center justify-center',
// Reduced opacity when sleeping for visual feedback
isSleeping && 'opacity-70',
// Reaction animations for baby
(effectiveReaction === 'listening' ||
// No opacity change for sleeping — sleeping is a recipe overlay, not a visual dim
!isCompanion && (effectiveReaction === 'listening' ||
effectiveReaction === 'swaying' ||
effectiveReaction === 'happy') &&
'animate-blobbi-sway',
effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className,
)}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
>
<BlobbiBabySvgRenderer
blobbi={blobbi}
isSleeping={isSleeping}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
</div>
);
}
+16 -4
View File
@@ -13,17 +13,17 @@
import { useMemo } from 'react';
import { EggGraphic, type EggReactionState } from '@/blobbi/egg';
import { toEggGraphicVisualBlobbi } from '@/lib/blobbi-egg-adapter';
import { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from '@/blobbi/egg';
import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
export type BlobbiEggSize = 'sm' | 'md' | 'lg';
// Re-export for convenience
export type { EggReactionState } from '@/blobbi/egg';
export type { EggReactionState, EggStatusEffects, EggTourVisualState } from '@/blobbi/egg';
export interface BlobbiEggVisualProps {
/** The Blobbi companion data from parseBlobbiEvent */
@@ -34,6 +34,12 @@ export interface BlobbiEggVisualProps {
animated?: boolean;
/** Reaction state for music/sing animations */
reaction?: EggReactionState;
/** Status effects for egg visual feedback (dirty, sick, happy) */
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;
/** Additional CSS classes for the container */
className?: string;
}
@@ -67,6 +73,9 @@ export function BlobbiEggVisual({
size = 'md',
animated = false,
reaction = 'idle',
statusEffects,
tourVisualState,
onTourEggClick,
className,
}: BlobbiEggVisualProps) {
// Memoize adapter output to avoid unnecessary re-renders
@@ -99,6 +108,9 @@ export function BlobbiEggVisual({
sizeVariant={config.sizeVariant}
animated={animated && !isSleeping}
reaction={effectiveReaction}
statusEffects={isSleeping ? undefined : statusEffects}
tourVisualState={tourVisualState}
onTourEggClick={onTourEggClick}
/>
</div>
);
+1 -1
View File
@@ -27,7 +27,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import { trackDailyMissionProgress } from '@/blobbi/actions';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────

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