Compare commits

...

91 Commits

Author SHA1 Message Date
lemon 72d7962632 Improve community bookmark reliability 2026-05-04 21:44:34 -07:00
lemon 5e91f1d328 Add bookmark toggle to community detail page top bar
Places a NIP-51 kind 10004 bookmark button between the edit and share
buttons so users can save a community while viewing it, not just from
the feed card's more-menu.
2026-05-03 22:10:34 -07:00
lemon efe5d3db1c Show bookmarked communities in My Communities via NIP-51 kind 10004
Bookmarking a kind 34550 community now writes to the NIP-51 Communities
list (kind 10004) keyed by the addressable coordinate, so the reference
stays valid across community updates. My Communities merges bookmarked
communities as a third discovery source alongside founded and member-of,
with Founder/Member/Bookmarked badges on each card.

Bookmark toasts live on the mutation itself so they survive the more-menu
dialog unmounting between .mutate() and publish resolution.
2026-05-03 21:59:36 -07:00
lemon 9ac379b259 fix: remove duplicate community share action 2026-05-03 21:28:00 -07:00
lemon c8b3961da6 feat: add community editing 2026-05-03 21:28:00 -07:00
lemon 259c657c33 fix: improve community member management 2026-05-03 21:28:00 -07:00
lemon 2f6aeb05e4 refactor: split community creation into two steps
- CreateCommunityDialog now only publishes kind 34550 (name, image, description)
- New AddMemberDialog on the community detail page handles membership:
  - Founder can add moderators and members
  - Moderators can add members only
  - Badge definition (kind 30009) created lazily on first member add
  - Community definition republished once with all changes batched
  - Kind 8 badge awards published for each member
- Add Members button on Members tab, visible to rank 0 users
- Search dropdown moved outside ScrollArea to prevent clipping
2026-05-03 21:27:41 -07:00
lemon 5cea93de34 feat: add community creation flow and improve discovery UX
- Add CreateCommunityDialog with name, image upload, description, and moderator type-ahead search
- Publish kind 30009 badge definition + kind 34550 community definition with d-tag collision check
- Context-aware FAB on My Communities tab opens the create dialog
- Default Search page tab to Communities instead of Posts
- Add Search to default sidebar order for new accounts
- Improve empty states on both Activities and My Communities tabs to guide users toward discovery
2026-05-03 21:26:48 -07:00
lemon bd6852041e Cache feed events, keep pages on refetch, flag partial goal tallies
- Seed ['event', id] query cache from the community activity feed so
  embedded previews resolve without a second fetch.
- Add placeholderData and a 30-minute gcTime to the community activity
  feed so navigation and background refetches don't flash empty.
- Surface useGoalProgress's isPartial flag in GoalCard with a '~'
  prefix and tooltip so users know when a tally hit the safety cap.
2026-05-03 00:17:45 -07:00
lemon e15c2b312c Use effective relay hints for fundraising goals 2026-05-02 23:59:21 -07:00
lemon 17e7bbd07e Paginate fundraising goal and community activity queries 2026-05-02 23:53:00 -07:00
lemon 910d759155 Allow goals without valid relay URLs, fall back to user's relays 2026-05-02 23:40:06 -07:00
lemon a8d5a1538c Deduplicate useNow, memoize moderation context, sanitize goal image URL 2026-05-02 23:40:06 -07:00
lemon 3f982e2241 Simplify goal progress to tally zaps at face value, matching rest of app
Drop LNURL signer resolution and NIP-57 receipt validation from goal
progress tallying. This removes a network request per beneficiary for a
trust level that is still spoofable and that no other zap display in the
app enforces. Revert this commit to restore strict validation.
2026-05-02 23:40:06 -07:00
lemon 418909f531 Fix BOLT11 parser to correctly extract HRP and handle zero-amount invoices 2026-05-02 23:40:05 -07:00
lemon 44098af247 Extract shared BOLT11 parser and deduplicate LNURL signer resolution 2026-05-02 23:40:05 -07:00
lemon 268b171ba4 Propagate abort signal to LNURL fetch in goal progress 2026-05-02 23:40:05 -07:00
lemon 2e44d2a677 Stabilize goal progress queryKey by keying on lightning address 2026-05-02 23:40:05 -07:00
lemon 4277a8fe7d Sort past fundraising goals by deadline descending 2026-05-02 23:40:05 -07:00
lemon 9045ff3c41 Harden fundraising goal zaps 2026-05-02 23:40:05 -07:00
lemon f56ff2f305 Fix activity feed empty state flash on refresh
Include communitiesLoading in the hook's isLoading so the skeleton
shows while the dependent communities query is still resolving,
instead of briefly rendering the empty state.
2026-05-02 23:40:05 -07:00
lemon 699bc6ca33 Fix moderation and context for goals in activity feed
Goals use lowercase 'a' tags (not uppercase 'A' like NIP-22 comments)
to link to communities. The activity feed's moderation filter, members-
only filter, and CommunityModerationContext provider lookup all only
checked uppercase 'A', so goals bypassed moderation and had no '...'
menu. Now all three check both tag casings.
2026-05-02 23:40:05 -07:00
lemon 16ec99b327 Apply community moderation filtering to fundraising goals
Content-banned goals and goals from member-banned authors are now
filtered out of the fundraising tab via applyCommunityModerationToEvents,
matching the behavior of the comments tab.
2026-05-02 23:40:05 -07:00
lemon 09e211f48a Render fundraising goals via NoteCard for moderation support
Replace standalone GoalCard with NoteCard in the community fundraising
tab so goals get the same '...' menu with remove/ban actions that
comments have. Strip GoalCard down to just the compact inline renderer
(no variant prop, no skeleton, no card-only imports). Simplify
useCommunityGoals to return plain events instead of parsed wrappers.
2026-05-02 23:40:05 -07:00
lemon 27b60b2a6f Refactor goal components: deduplicate GoalCard/GoalContent, fix staleness
- Unify GoalCard and GoalContent into a single component with variant prop
- Extract useGoalDisplay hook for shared display logic (author, progress,
  community link, deadline, image)
- Add useNow(60s) interval so deadline labels refresh automatically
- Add generic parseATagCoordinate utility to nostrEvents.ts
- Replace DOM-mutating image onError with React state
- Remove dead isGoalFunded export and redundant created_at in publish
- Delete GoalContent.tsx (-144 net lines)
2026-05-02 23:40:05 -07:00
lemon 07ea1f94d1 Render full zap goal card on detail page and add FAB to community tabs
- PostDetailPage: render GoalContent for kind 9041 instead of plain text
- CommunityDetailPage: add floating action button on comments (compose) and fundraising (new goal) tabs, remove inline New Goal button
- CreateGoalDialog: support controlled open/onOpenChange props for external triggers
2026-05-02 23:40:05 -07:00
lemon d1017697a4 Add NIP-75 community fundraising goals
Implement zap goals (kind 9041) linked to communities via a-tag.
Includes goal creation dialog, progress tracking from zap receipts,
recipient profile/lightning address display, community link, and
members-only filtering. Goals appear in community detail Fundraising
tab, activity feed, and main feed via NoteCard.
2026-05-02 23:39:07 -07:00
Sam Thomson 2788127894 Merge branch 'ui/gut-blobbi' into 'main'
ui/gut-blobbi

See merge request soapbox-pub/agora-3!14
2026-04-30 07:11:33 +00:00
sam 9f425366c0 Merge branch 'main' into ui/gut-blobbi 2026-04-30 13:53:14 +07:00
Sam Thomson 0436949797 Merge branch 'refactor/changelog' into 'main'
refactor/changelog

See merge request soapbox-pub/agora-3!13
2026-04-30 06:49:31 +00:00
sam 8fdb5cf1ad reset changelog to agora 2026-04-30 13:38:02 +07:00
sam b46703eaed remove blobbis 2026-04-30 13:19:22 +07:00
Sam Thomson ecbee21d34 Merge branch 'ui/gut-custom-themes' into 'main'
ui/gut-custom-themes

See merge request soapbox-pub/agora-3!11
2026-04-29 15:08:41 +00:00
sam 3f28bf571a remove all theme stuff 2026-04-29 22:04:39 +07:00
sam 2e7eee66ee gut theme customisation 2026-04-29 21:47:36 +07:00
Sam Thomson 7c4d3012ec Merge branch 'fix/discovery-relay-selection' into 'main'
fix/discovery-relay-selection

See merge request soapbox-pub/agora-3!10
2026-04-29 13:47:31 +00:00
sam 01af784953 fix discovery relay selection 2026-04-29 20:41:41 +07:00
Sam Thomson da8a5e1dde Merge branch 'refactor/tidy-some-pages' into 'main'
refactor/tidy-some-pages

See merge request soapbox-pub/agora-3!9
2026-04-28 12:48:42 +00:00
sam e3b16a3c5b Merge branch 'main' into refactor/tidy-some-pages 2026-04-28 19:48:08 +07:00
sam a5849fc747 copy updates 2026-04-28 19:41:58 +07:00
sam 42430e510d update messaging dep so that syncing thing stays visible 2026-04-28 19:24:31 +07:00
sam 09c364b060 hide the messaging header 2026-04-28 19:24:19 +07:00
sam d96361c578 updated messaging dep 2026-04-28 18:39:32 +07:00
sam 1346112f36 feed title for consistency 2026-04-28 16:03:31 +07:00
sam 44b1019d98 header to notifs page 2026-04-28 15:51:08 +07:00
sam b4c5db0c0e dont wash out inactive cards so much, let errors be conveyed. and fixed layout issues with action sponsor overflows 2026-04-28 15:47:21 +07:00
sam a56b4839c8 let images fail fast and fix double header 2026-04-28 14:47:06 +07:00
sam 5768dc9183 llm -> fail fast, don't swallow errors 2026-04-28 14:46:20 +07:00
sam e871229248 update verified follow pack to new layout 2026-04-28 14:21:17 +07:00
sam 141166cdc8 copy 2026-04-28 14:20:03 +07:00
Sam Thomson b99590bc5e Merge branch 'feat/community-reporting' into 'main'
Community Moderation

See merge request soapbox-pub/agora-3!7
2026-04-28 04:10:13 +00:00
lemon b2f4cc3583 Make members-only toggle reactive and move it off the tab row
Two fixes on the members-only filter UI:

1. Toggling the shield now updates feeds live, without a reload.

The previous implementation used `useLocalStorage` in two separate
components. Each call instantiates its own `useState`, so writes from
one didn't flow to the other's reader. `localStorage`'s `storage`
event only fires cross-tab, not in the tab that wrote — so same-tab
consumers stayed stale until a remount.

Replaced with a module-level singleton store subscribed via
`useSyncExternalStore`. All consumers share one source of truth;
toggling rerenders every subscriber in the same tab instantly. The
store still persists to localStorage and listens for cross-tab
`storage` events, so behaviour across tabs is unchanged.

2. Move the shield off the CommunityDetailPage tab row.

Placing the toggle inline with the TabsList made it sit on the
bottom-border stroke that belongs to the tabs, reading as if the
shield itself were an underlined tab. Moved it up one row, right-
justified on the "Founded by" label row. Visually cleaner and still
scopes the filter to the entire community (all content feeds under
the tabs, current and future), not any single tab.
2026-04-27 09:22:08 -07:00
lemon e21ee2e4fc Enforce report pubkey match and add members-only filter toggle
Two NIP-alignment fixes:

Gap 1 — Report warnings now require `p` match (correctness).

Previously `CommunityContentWarning` looked up reports by event id only,
so any community member could publish a kind 1984 pairing a victim
event's id with their own pubkey on the `p` tag to force a warning
overlay onto an arbitrary event. Added `getApplicableReports` in
communityUtils mirroring `hasApplicableContentBan`, and use it to
require `report.targetPubkey === event.pubkey` before the warning
renders. Matches NIP.md §Reports — Content Warnings: "report warnings
MUST only attach to content when the target event's id matches the
report's `e` tag and the target event's pubkey matches the report's
`p` tag."

Gap 2 — Members-only filter toggle.

The NIP recommends canonical community feeds discard non-member
content by default. Added a shield-icon toggle that controls this as
a presentation-layer filter, defaulting on. When active, community
feeds (Activities feed, per-community Comments tab, and any future
community-scoped content surfaces) only show events authored by
chain-validated members. When off, everything scoped to the
community is shown regardless of authorship.

- `useMembersOnlyFilter` — localStorage-backed hook with cross-tab
  sync; one preference shared across all community surfaces.
- `MembersOnlyToggle` — shield / shield-off icon button with tooltip
  explaining current state.
- Filtering is applied post-query in the consumer pages, so toggling
  is instant and doesn't invalidate the query cache.
- Community definition events (kind 34550) are never filtered — they
  represent the community itself, not user-generated content.
- Toggle placement: in `CommunitiesPage` header (scopes the global
  Activities feed); in `CommunityDetailPage` alongside the tabs
  (scopes every content feed in that community, now and future).
- Empty-state copy hints at the filter when a list is empty only
  because of it.
2026-04-27 09:22:08 -07:00
lemon 8923aa87e2 Remove unfinished community events tab
Drops the read-only calendar-events (kind 31922/31923) listing from
CommunityDetailPage. The feature was partial — events could be listed
but not created from the community context — and the moderation /
authorship model for community-scoped events needs its own design
pass. Keeping it half-shipped complicates the moderation foundation
this branch is establishing.

A proper community events implementation will land in its own MR with
clearer scope: creation, RSVP handling, moderation rules for
community-scoped NIP-52 events, and whether the activity feed should
surface them.

General (non-community) calendar event support is unaffected —
EventsFeedPage, CalendarEventContent, CalendarEventDetailPage, RSVP
hooks, and the feed dispatch all remain. The community activity feed
already did not include kind 31922/31923, so no change there.
2026-04-27 09:22:08 -07:00
lemon 527b31247b Restore moderation trust boundary and remove unsafe cache seeding
Two fixes prompted by external review:

1. resolveCommunityModeration now takes the community A tag and filters
   events by matching `A` tag as its first pass. The previous change
   removed the A-tag existence check from parseCommunityReport on the
   assumption that callers scope by relay `#A` filter; that was an
   invariant of the current callers, not a property of the API. Moving
   the check to the resolver restores the trust boundary at the public
   API surface while keeping parseCommunityReport a pure single-event
   parser. The activity feed's pre-grouping pass is dropped since the
   resolver now handles per-community filtering itself.

2. Drop the `['community-members', aTag]` cache seeding from the
   activity feed. The activity feed uses shared relay limits across
   every subscribed community (500 awards and 500 reports total), so
   per-community results can be truncated. Seeding the per-community
   members cache with incomplete data would silently corrupt membership,
   authority, and moderation state on community detail pages.
   useCommunityMembers remains the authoritative per-community fetch.
2026-04-27 09:22:08 -07:00
lemon 864057f382 Tighten community moderation performance and unify context paths
- Extract community content warning's context subscription into the
  wrapper itself so NoteCard's memo() boundary no longer depends on
  moderation data. Refetches now re-render only the warning and the
  three-dot menu, not the whole card.
- Rename useCommunityModeration -> useCommunityModerationForEvent and
  return the full context value; PostDetailPage installs it as a
  Provider, removing 7 manual communityContext prop passes. Unifies the
  three previous paths for computing CommunityMenuContext down to one.
- Seed the per-community members cache from the activity feed so
  opening a community detail page after the feed loads hits warm cache
  instead of re-querying kind 8 awards and kind 1984 reports.
- Single-pass parse in resolveCommunityModeration (was parsing each
  kind 1984 event twice across the ban and report passes).
- Drop the redundant A-tag existence check in parseCommunityReport;
  callers scope events via the relay's #A filter.
- Scope ban/report cache invalidation with a predicate that only
  matches activity feeds containing the affected community's A tag.
- Drop CommunityMembership.totalCount (was just members.length) and
  consolidate scattered EMPTY_* sentinels into EMPTY_MEMBERSHIP and
  EMPTY_RANK_MAP in communityUtils.
2026-04-27 09:22:08 -07:00
lemon 7440b2d620 Improve community moderation structure and naming for reviewability
Rename memberMap -> rankMap to clarify it is a pre-moderation rank lookup
(includes banned members) and should not be used to list active members.

Extract canBanTarget(), getViewerAuthority(), isEventAllowedByModeration(),
CommunityMenuContext, and EMPTY_MODERATION into communityUtils as shared
primitives, eliminating duplicated logic across hooks and components.

Remove unused ApplyCommunityModerationOptions dead code.
2026-04-27 09:22:08 -07:00
lemon f48ba562ea Clarify community moderation UI labels 2026-04-27 09:22:08 -07:00
lemon c91bdc1d89 Harden community moderation foundation 2026-04-27 09:22:08 -07:00
lemon c7b3305ef4 Void bans and reports from banned members
Rework resolveCommunityModeration into a two-pass approach so that
members who are themselves banned cannot retain moderation authority:

Pass 1: collect valid ban candidates, sort by reporter rank ascending,
then apply them — skipping any candidate whose reporter was already
banned by a higher-ranked member earlier in the pass.

Pass 2: collect non-ban reports, skipping reporters who ended up in
the banned set from pass 1.
2026-04-27 09:22:08 -07:00
lemon 09c904917d Show moderation actions in three-dot menu across all community contexts
The NoteMoreMenu 'Remove post' and 'Ban' options were only visible on
the community detail page where CommunityModerationContext was provided.
Now they also appear in the activities feed and post detail page.

- Add useCommunityModeration hook for PostDetailPage (resolves community
  context from event's A tag with lazy queries)
- Extend useCommunityActivityFeed to expose per-community memberMap and
  moderation data (zero extra queries — reuses already-fetched data)
- Wrap each NoteCard in ActivitiesTab with CommunityModerationContext
- NoteCard itself is untouched — no performance impact on other feeds
2026-04-27 09:22:08 -07:00
lemon 3e099bb08d apply moderation filter to activites 2026-04-27 09:22:08 -07:00
lemon 0e99250a3b Unify more-menu sections and rename 'Remove content' to 'Remove post'
Merge the three middle sections into one, remove stale JSDoc referencing
deleted reinstatement logic, and align dialog/toast copy with menu label.
2026-04-27 09:22:08 -07:00
lemon 9be5650dcd Improve community moderation robustness and efficiency
- Eliminate double resolveMembership call by filtering banned members post-hoc
- Memoize community context derivation in NoteMoreMenu
- Hoist viewerMember lookup out of render loop in CommunityDetailPage
- Only mount BanConfirmDialog when viewer has ban authority
- Deduplicate NIP-56 report type definitions into canonical source
2026-04-27 09:22:08 -07:00
lemon 3efdcd5a63 Remove deletion/reinstatement logic from community moderation
Reinstatement via kind 5 deletions will be implemented in a future branch.
Removing it now eliminates an overly-broad unscoped query and a security
issue where any pubkey could reinstate banned content.
2026-04-27 09:22:07 -07:00
lemon fec7021a7f Implement community moderation: reporting, content bans, and member bans
Add two-tier moderation system for hierarchical communities using kind 1984
events scoped via A tags. Authoritative bans use NIP-32 labels
([l, ban, moderation]) and require rank authority. Soft reports use standard
NIP-56 types and trigger content warnings for any valid member.

- Update NIP.md with ban/report classification, NIP-32 label schema, and
  reinstatement via kind 5
- Add parseCommunityReport(), resolveCommunityModeration() to communityUtils
- Update resolveMembership() to apply moderation overlay (remove banned members)
- Update useCommunityMembers to fetch kind 1984/5 and resolve moderation
- Add CommunityModerationContext for propagating moderation state
- Add CommunityReportDialog for soft reports (NIP-56 types)
- Add BanConfirmDialog for content removal and member bans with optional reason
- Add CommunityContentWarning component for click-to-reveal reported content
- Wire moderation into NoteMoreMenu (auto-detects community context)
- Wire moderation into CommunityDetailPage (member ban buttons, feed filtering)
- Add Remove content / Ban @user menu items to NoteMoreMenu
- Remove Copy Link to Post and Mention @user from NoteMoreMenu
- Move Mute Conversation into the mute/report section
2026-04-27 09:22:07 -07:00
Sam Thomson 0940358fba Merge branch 'feat/dms' into 'main'
feat/dms

See merge request soapbox-pub/agora-3!8
2026-04-27 12:07:17 +00:00
sam 50637a4dc1 updated to latest messaging package to resolve peer dep issue 2026-04-27 18:44:30 +07:00
sam 89a3562a1e update nips used doc 2026-04-27 18:33:33 +07:00
sam 2852590e09 updated messaging dep 2026-04-27 13:09:54 +07:00
sam b5c941f9fb use latest messaging dep 2026-04-25 20:34:46 +07:00
sam 9cdbb7c9e8 expose legacy nip4 option in settings 2026-04-25 19:05:51 +07:00
sam 0c9da915ef bring messaging settings over 2026-04-25 18:20:03 +07:00
sam 94ca6d162f Merge branch 'main' into feat/dms 2026-04-25 00:41:19 +07:00
Sam Thomson f351443049 Merge branch 'feat/world-feed' into 'main'
Replace Ditto feed tab with World feed

See merge request soapbox-pub/agora-3!4
2026-04-24 17:39:02 +00:00
lemon 348bbf6522 Invalidate query cache on world feed pull-to-refresh 2026-04-23 17:02:31 -07:00
lemon 9aa7366c74 Remove diversity cap from world feed, sort purely by recency 2026-04-23 17:02:31 -07:00
lemon f68f257234 Replace Ditto feed tab with World feed
- Add useWorldFeed hook combining infinite-scroll pagination with live
  streaming and 'X new posts' buffer/flush pattern
- World feed queries all country-tagged events globally with a diversity
  cap (max 4 posts per country per page)
- Live streaming via persistent relay subscription with scroll-aware
  buffering and highlight animation on flush
- Rename Ditto tab to World across Feed, ContentSettings, and useFeedTab
- Migrate localStorage key from ditto:showDittoFeed to agora:showWorldFeed
2026-04-23 17:02:31 -07:00
sam d1ca846d30 updated messaging dep 2026-04-23 20:02:41 +05:45
sam 0240e77bf9 Merge branch 'main' into feat/dms 2026-04-23 12:18:16 +05:45
Sam Thomson cfcc4b8858 Merge branch 'fix/themes' into 'main'
Remove Ditto Themes and Set Defaults of System/Light/Dark

See merge request soapbox-pub/agora-3!6
2026-04-23 06:32:38 +00:00
lemon b3b7bdd20c replace theme showcase with simple System/Light/Dark appearance setting
Remove the 'Make it yours' theme strip from the landing hero and the
ThemeStep from the signup/onboarding flow. Add an Appearance settings
page at /settings/appearance with three options (System, Light, Dark)
defaulting to System.
2026-04-22 18:38:13 -07:00
sam 12c7676882 keep agent concise 2026-04-22 19:47:17 +05:45
sam 8411fb997d Merge branch 'main' into feat/dms 2026-04-22 12:19:16 +05:45
sam 3cc1e1dcec dont use the generic lazy loading for messages page, its looks daft and messaging has its own loading state 2026-04-22 12:17:53 +05:45
sam ef100bfac1 guard messages if not authed 2026-04-20 18:39:19 +05:45
sam c82b256128 port conflict 2026-04-20 18:27:38 +05:45
sam a5c52c72be dms first pass 2026-04-20 18:14:35 +05:45
sam 865a472ef1 delete legacy mkstack dms 2026-04-20 18:10:23 +05:45
sam 85b8e68f52 ++ 2026-04-20 18:09:43 +05:45
sam c26aa709d0 nginx proxy for decrypting kind15s 2026-04-20 18:09:28 +05:45
352 changed files with 5891 additions and 64919 deletions
+4 -4
View File
@@ -1,4 +1,4 @@
Thanks for contributing to Ditto! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
Thanks for contributing to Agora! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
## Related Issue
@@ -29,9 +29,9 @@ Closes #
## Philosophy Alignment
<!-- Answer this question for your change: -->
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
<!-- "Does this make Agora more magnetic, more threatening to the status quo, -->
<!-- and more peaceful to inhabit?" -->
<!-- See: https://about.ditto.pub/philosophy -->
<!-- See: CONTRIBUTING.md -> "Understanding Agora" -->
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
## How to Test
@@ -50,7 +50,7 @@ Closes #
### Process
- [ ] I read `AGENTS.md` before starting
- [ ] I read the [Ditto Philosophy](https://about.ditto.pub/philosophy)
- [ ] I read "Understanding Agora" in `CONTRIBUTING.md`
- [ ] I used plan/research mode before writing code
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+17 -1
View File
@@ -10,6 +10,21 @@ Violating this is a critical failure.
---
# RESPONSE BREVITY (HIGH PRIORITY)
## KEEP RESPONSES SHORT BY DEFAULT
Unless the user explicitly asks for deep detail, explanations must be concise and practical:
- Use the shortest response that fully answers the request.
- Prefer 1-3 short paragraphs or 3-6 bullets.
- Do not include long background context unless requested.
- Do not restate obvious information from the prompt or code.
- For code changes, summarize only what changed and why in a few lines.
- Offer extra detail only as an optional follow-up.
If unsure between a short and long response, choose the shorter one.
# Project Overview
This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify.
@@ -462,7 +477,7 @@ const links = getAllTags(event.tags, 'r')
**Mitigation strategy — sanitize at the parse layer:**
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely.
2. **Strings in CSS declarations** (e.g. font family names): Use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens, underscores, apostrophes, and periods are permitted. Everything else is stripped.
@@ -1175,6 +1190,7 @@ The router includes automatic scroll-to-top functionality and a 404 NotFound pag
- Default connection to one Nostr relay for best performance
- Comprehensive provider setup with NostrLoginProvider, QueryClientProvider, and custom AppProvider
- **Never use the `any` type**: Always use proper TypeScript types for type safety
- **Fail-fast error visibility**: Never silently hide errors in the UI. If data fails validation, a resource fails to load, or a user action errors, surface an explicit visible error state/message so users can see what failed and why.
## Loading States
+2 -445
View File
@@ -1,450 +1,7 @@
# Changelog
## [2.8.0] - 2026-04-16
## [1.0.0] - 2026-04-30
### Added
- Back up your secret key right from Profile settings -- reveal, copy, and save it to iCloud Keychain, Android Credential Manager, or a local file
- Blobbi mission progress now persists across page refreshes, so your hatching and evolution journey picks up right where you left off
### Changed
- AI chat has been overhauled with a cleaner layout, the Dork mascot across empty states, and a clear path to grab Shakespeare credits when you run out
- Friendly error banners now explain when you've hit the rate limit or run out of AI credits, instead of cryptic failures
### Fixed
- Avatar shape selection during signup now actually saves to your profile
- Blobbi interaction missions now tally correctly the moment you start incubating or evolving
- Blobbi task progress displays the right numbers immediately on page load instead of showing 0 until everything catches up
## [2.7.1] - 2026-04-16
### Added
- Tap the Home tab while already on Home to scroll to the top and refresh your feed
- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch
- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone
### Changed
- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen
- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder
- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection
- Android's automatic cloud backup now excludes your wallet credentials
### Fixed
- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed
- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds
- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data
## [2.7.0] - 2026-04-14
### Added
- Customizable widget sidebar -- drag, drop, and rearrange widgets on your feed including Trending, Hot Posts, Bluesky, AI Chat, Blobbi, Music, Photos, Wikipedia, and more
- Blobbi rooms -- swipe between living spaces, clean up after your pet, and earn XP from daily care routines
- Native push notifications on iOS with author names, content previews, and smart grouping by category
- Haptic feedback throughout the app -- taps, buzzes, and pulses when you react, zap, repost, pull to refresh, play games, and interact with your Blobbi
- Hot Posts widget showing the most popular posts from your feed at a glance
### Changed
- Sidebar widgets are now clickable links that take you to their full pages
- Blobbi widget shows live stats with circular ring indicators and quick action buttons
### Fixed
- Zaps embedded in posts now render as proper inline cards instead of blank space
- Quote posts display media and Blobbi companions correctly
- Deep linking on Google Play works again
- Game controller buttons no longer trigger text selection on long-press on iOS
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
## [2.6.4] - 2026-04-11
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
- Signup flow simplified -- cleaner profile step with a single Continue button
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- 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 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
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- 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
- 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
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- 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 users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
- Letters page added to the sidebar with a custom mailbox icon
## [2.0.1] - 2026-03-26
### Added
- Tap the version number in settings to see what's new
## [2.0.0] - 2026-03-26
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
- Initial Agora 3 release.
+7 -7
View File
@@ -4,8 +4,8 @@ We welcome contributions, but we have high standards. Agora is a carefully desig
**Required reading before you start:**
- [Agora Philosophy](https://agora.spot/philosophy) -- the product vision. Your change must align with it.
- [Contributing Guide](https://agora.spot/contributing) -- the upstream contribution process.
- [Understanding Agora](#understanding-agora) -- the product vision. Your change must align with it.
- This `CONTRIBUTING.md` guide -- the contribution process for this repository.
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
## Understanding Agora
@@ -36,7 +36,7 @@ If a change does all three, it belongs. If it only does one, think harder. If it
- A place where profiles feel like worlds, not business cards
- The most fun you've had on the internet in years
Read the [full philosophy](https://agora.spot/philosophy) for the complete vision.
Read the full "Understanding Agora" section above for the complete vision.
## What we accept
@@ -46,11 +46,11 @@ One bug, one merge request. Fix exactly one thing. Don't bundle unrelated change
### New features and significant changes
Every feature MR must link to an existing open issue and clearly align with the [Agora Philosophy](https://agora.spot/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Agora. If you can't articulate that clearly, the change probably doesn't belong.
Every feature MR must link to an existing open issue and clearly align with the "Understanding Agora" section in this file. The philosophy alignment section in the MR template is where you make the case for why your change belongs in Agora. If you can't articulate that clearly, the change probably doesn't belong.
If you have an idea for a feature that doesn't have an issue yet:
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://agora.spot/contributing)).
1. Build it as a standalone Nostr app first (then document traction/feedback in the linked issue).
2. Prove it works and get user feedback.
3. Open an issue to discuss integration.
@@ -80,7 +80,7 @@ Read `AGENTS.md` in the repo root. This is the single source of truth for how co
### 4. Read the philosophy
Read the [Agora Philosophy](https://agora.spot/philosophy). Agora is a carnival, not a platform. Your change should feel like it belongs in Agora -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
Read "Understanding Agora" in this file. Agora is a carnival, not a platform. Your change should feel like it belongs in Agora -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
### 5. Plan before you code
@@ -163,7 +163,7 @@ Fill out every field in the MR template. Incomplete MRs will not be reviewed.
## What gets your MR closed without review
- No linked issue
- Feature MRs with no clear alignment with the [Agora Philosophy](https://agora.spot/philosophy)
- Feature MRs with no clear alignment with "Understanding Agora" in this file
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
- Incomplete MR template (missing checklist, screenshots, or preview URL)
- Changes that go beyond what was asked for (scope creep)
+15
View File
@@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY package*.json ./
COPY .npmrc ./
COPY scripts/ ./scripts/
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+210 -209
View File
@@ -6,8 +6,6 @@
| 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) |
### Agora Kinds
@@ -35,175 +33,47 @@ These event kinds were created by community contributors and are supported by Di
| 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) |
---
## Kind 36767: Theme Definition
## Standard NIPs: Direct Messaging
### Summary
This application implements encrypted direct messaging using two standard Nostr protocols:
Addressable event kind for publishing shareable custom UI themes. A single user may publish multiple themes, each identified by a unique `d` tag.
### NIP-04 (Legacy Encrypted DMs)
A theme consists of colors, optional fonts, and an optional background. Colors are stored in `c` tags, fonts in `f` tags, and background in a `bg` tag.
| Field | Value |
|-------|-------|
| Kind | 4 |
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
### Event Structure
Legacy encrypted direct messages. Content is encrypted with AES-256-CBC using a shared secret derived from the sender's private key and recipient's public key. The recipient is identified by a `p` tag.
```json
{
"kind": 36767,
"content": "",
"tags": [
["d", "mk-dark-theme"],
["c", "#1a1a2e", "background"],
["c", "#e0e0e0", "text"],
["c", "#6c3ce0", "primary"],
["f", "Inter", "https://example.com/inter.woff2", "body"],
["f", "Playfair Display", "https://example.com/playfair.woff2", "title"],
["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg", "dim 1920x1080"],
["title", "MK Dark Theme"],
["alt", "Custom theme: MK Dark Theme"]
]
}
```
Used for backward compatibility with older Nostr clients that do not support NIP-17.
### Content
### NIP-17 (Private Direct Messages)
The `content` field is unused and MUST be an empty string (`""`).
| Field | Value |
|-------|-------|
| Kinds | 1059 (Gift Wrap), 1060 (Seal) |
| Spec | [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) |
### Tags
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
| Tag | Required | Description |
|---------|----------|---------------------------------------------------------------------------------------|
| `d` | Yes | Unique identifier (slug) for this theme, e.g. `"mk-dark-theme"` |
| `c` | Yes (×3) | Hex color with marker. See [Color Tags](#color-tags). |
| `f` | No | Font declaration. See [Font Tag](#font-tag). |
| `bg` | No | Background media. See [Background Tag](#background-tag). |
| `title` | Yes | Human-readable theme name |
| `alt` | Yes | NIP-31 human-readable fallback |
1. **Rumor** (kind 14) — unsigned plaintext message
2. **Seal** (kind 13) — rumor encrypted to the recipient, signed by the sender
3. **Gift Wrap** (kind 1059) — seal encrypted to the recipient, signed by a random ephemeral key
### Multiple Themes Per User
This provides metadata protection: relays and observers cannot determine the sender, recipient, or content. The application uses NIP-17 as the default send protocol, with optional NIP-04 compatibility for older clients.
Since kind 36767 is addressable, a user can publish multiple themes by using different `d` tag values. Publishing a new event with the same `d` tag replaces the previous version (this is how editing works).
### Protocol Configuration
---
Users can configure their preferred send protocol via Settings > Messages:
## Kind 16767: Active Profile Theme
### Summary
Replaceable event that represents the user's currently active profile theme. Only one per user. When other users visit a profile, they query this kind to determine what theme to display.
### Event Structure
```json
{
"kind": 16767,
"content": "",
"tags": [
["c", "#1a1a2e", "background"],
["c", "#e0e0e0", "text"],
["c", "#6c3ce0", "primary"],
["f", "Inter", "https://example.com/inter.woff2", "body"],
["f", "Playfair Display", "https://example.com/playfair.woff2", "title"],
["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg"],
["title", "MK Dark Theme"],
["alt", "Active profile theme"]
]
}
```
### Content
The `content` field is unused and MUST be an empty string (`""`).
### Tags
| Tag | Required | Description |
|---------|----------|---------------------------------------------------------------------------------------|
| `c` | Yes (×3) | Hex color with marker. See [Color Tags](#color-tags). |
| `f` | No | Font declaration. See [Font Tag](#font-tag). |
| `bg` | No | Background media. See [Background Tag](#background-tag). |
| `title` | No | Human-readable name for the theme |
| `alt` | Yes | NIP-31 human-readable fallback |
### Client Behavior
- When visiting a profile, clients query `{ kinds: [16767], authors: [pubkey], limit: 1 }` to get the active theme.
- Clients read the `c` tags to extract colors, `f` tags for fonts, and `bg` tag for the background.
- Setting a new active theme publishes a new kind 16767 event (replacing the old one).
- To remove the active theme, publish a kind 5 deletion event targeting kind 16767.
---
## Shared Tag Definitions
The following tag definitions apply to both kind 36767 and kind 16767.
### Color Tags
Format: `["c", "#rrggbb", "<marker>"]`
| Index | Required | Description |
|-------|----------|-----------------------------------------------------------------------------------------------|
| 0 | Yes | Tag name: `"c"` |
| 1 | Yes | Lowercase 6-digit hex color code including the `#` sign (e.g. `"#ff0000"`) |
| 2 | Yes | Color role marker: one of `"primary"`, `"text"`, or `"background"` |
- All three markers (`"primary"`, `"text"`, `"background"`) MUST be present.
- Only one `c` tag per marker is allowed.
### Font Tag
Format: `["f", "<family>", "<url>", "<role>"]`
| Index | Required | Description |
|-------|----------|-----------------------------------------------------------------------------------------------|
| 0 | Yes | Tag name: `"f"` |
| 1 | Yes | CSS `font-family` name (e.g. `"Inter"`) |
| 2 | Yes | Direct URL to a font file (`.woff2`, `.ttf`, `.otf`) |
| 3 | Yes | Font role: `"body"` or `"title"` |
**Roles:**
| Role | Applies to |
|-----------|--------------------------------------------------|
| `"body"` | All text globally (body, headings, UI elements) |
| `"title"` | The user's profile display name |
**Rules:**
- The `f` tag is optional on the event.
- At most one `f` tag per role is allowed (i.e. one body font and one title font).
- The `"body"` font tag MUST be ordered before the `"title"` font tag. This ensures backward-compatible clients that only read the first `f` tag will pick up the body font.
- If the URL fails to load, the client SHOULD fall back to a default font gracefully.
- Clients that do not recognize a role SHOULD ignore that `f` tag.
- Legacy events with an `f` tag that has no role marker (only 3 elements) SHOULD be treated as `"body"`.
- Variable font files (covering multiple weights in a single file) are preferred.
### Background Tag
The `bg` tag uses an `imeta`-style variadic format where each entry (after the tag name) is a space-delimited key/value pair.
Format: `["bg", "url <url>", "mode <mode>", "m <mime-type>", ...]`
| Key | Required | Description |
|-------------|----------|------------------------------------------------------------------------------------------|
| `url` | Yes | URL to an image or video file |
| `mode` | Yes | Display mode: `"cover"` or `"tile"` |
| `m` | Yes | MIME type (e.g. `"image/jpeg"`, `"image/png"`, `"video/mp4"`) |
| `dim` | No | Dimensions in pixels: `"<width>x<height>"` (e.g. `"1920x1080"`) |
| `blurhash` | No | Blurhash placeholder string for progressive loading |
- At most one `bg` tag is allowed per event.
- Clients MAY choose not to render video backgrounds for performance or bandwidth reasons.
- Unknown keys SHOULD be ignored for forward compatibility.
- **NIP-17 only** (default) — maximum privacy, only modern clients can read
- **NIP-04 + NIP-17** — sends via both protocols for compatibility with legacy clients
---
@@ -569,6 +439,14 @@ Clients SHOULD only surface events from the last hour (`since = now - 3600`). Ol
Hierarchical communities on Nostr, composed from existing event kinds. Communities have ranked membership where authority flows downward through a chain of badge awards.
This specification is intended to be a foundation for community-scoped features. A community is a kind `34550` root that other events can tag with uppercase `A`. Posts, events, polls, listings, and future content kinds can all participate in the same community model when they tag the community root and pass the membership and moderation rules below.
The initial implementation focuses on three foundation capabilities:
1. Viewing communities a user owns or belongs to.
2. Posting community-scoped discussion content.
3. Moderating community-scoped content and members within communities the viewer has authority over.
**No new event kinds are introduced.** The system composes:
- **Kind 34550** ([NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)) -- Community Definition
@@ -576,7 +454,7 @@ Hierarchical communities on Nostr, composed from existing event kinds. Communiti
- **Kind 8** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Award
- **Kind 1111** ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) -- Community Posts
- **Kind 1984** ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) -- Moderation
- **Kind 5** ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)) -- Deletion / Revocation
- **Kind 5** ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)) -- Badge Award Revocation / Moderation Rescinding
### Overview
@@ -585,9 +463,9 @@ A hierarchical community consists of:
1. **Badge definitions** (kind `30009`), one per rank tier, published by the founder.
2. A **community definition** (kind `34550`) referencing those badges with rank indices.
3. **Badge awards** (kind `8`) forming a chain of trust -- each award grants a rank, validated by the awarder's rank.
4. **Posts** (kind `1111`) scoped to the community via NIP-22.
5. **Reports** (kind `1984`) scoped to the community for content removal or member bans.
6. **Deletion requests** (kind `5`) for revoking awards or rescinding moderation.
4. **Community-scoped content** (initially kind `1111`) tagged to the community root.
5. **Reports and bans** (kind `1984`) scoped to the community for content warnings, content removal, and member bans.
6. **Deletion requests** (kind `5`) for revoking badge awards or rescinding moderation events.
### Membership Derivation
@@ -703,13 +581,20 @@ Membership is **derived state**. Clients compute effective membership by resolvi
1. **Seed rank 0**: The event publisher (founder) and all `p` tags (moderators) in the community definition are rank 0 members.
2. **Query awards**: `{ kinds: [8], #a: [<all badge coordinates>] }`
3. **Iteratively validate**: For each award, check if the awarder is a validated member with rank strictly less than the awarded rank. If valid, add the recipient. Repeat until no new members are discovered.
4. **Apply moderation**: Query `{ kinds: [1984], #A: [<community-a-tag>] }`. Remove banned members (but not their downstream subtrees -- the chain remains intact). Hide reported posts.
4. **Resolve moderation**: Query `{ kinds: [1984], #A: [<community-a-tag>] }`. Classify kind `1984` events into **bans** and **reports** (see [Moderation](#moderation)). Kind `1984` events from non-members and banned members are ignored. Ban attempts from insufficiently ranked members are ignored, such as a rank 2 member trying to ban a rank 0 founder or moderator.
5. **Apply moderation**: Remove banned members from effective membership. Omit content from banned authors, omit verified content bans, and attach report data to reported content for content-warning display.
Clients MUST NOT trust kind `8` events at face value. An attacker can publish awards for themselves, but these fail chain validation without a path to a founder or moderator.
### Community Posts
### Community-Scoped Content
Community discussion uses kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) scoped to the community definition as the root event.
Community-scoped content is any event that tags the community definition with uppercase `A`. The foundation implementation starts with kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) posts, but the same moderation overlay applies to future community content kinds such as calendar events, polls, listings, or other domain-specific events.
Clients SHOULD treat valid community members as the canonical authors for community views. Content from non-members MAY be shown in future review surfaces, but canonical community feeds SHOULD discard non-member content by default.
#### Community Post
Community discussion uses kind `1111` scoped to the community definition as the root event.
#### Top-Level Post
@@ -749,7 +634,7 @@ Replies keep the community as root scope and point to the parent comment:
#### Querying
Fetch all community-scoped posts and moderation data in a single request:
Fetch community-scoped content and moderation data together when relay limits permit. The `kinds` list can expand as the application adds supported community content kinds.
```jsonc
{
@@ -758,59 +643,111 @@ Fetch all community-scoped posts and moderation data in a single request:
}
```
Clients then filter client-side: discard kind `1111` posts from non-members, and apply authoritative kind `1984` reports per the moderation rules below.
Clients then filter client-side: discard unsupported kinds, discard non-member content from canonical community views, and process kind `1984` events per the moderation rules below. The moderation overlay is content-kind agnostic: a valid content ban or warning applies to the targeted event regardless of whether that event is a post, calendar event, poll, listing, or future supported kind.
### Moderation
Moderation uses kind `1984` ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) scoped to the community via the uppercase `A` tag. Reports from higher-ranked members are treated as **authoritative moderation actions**.
Moderation uses kind `1984` ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) scoped to the community via the uppercase `A` tag. Moderation is derived state: clients first resolve trusted moderation actions from kind `1984`, then apply those actions to concrete community-scoped events.
#### Authority Rules
There are two tiers of moderation events:
A report is **authoritative** if:
1. **Bans** -- authoritative actions from higher-ranked members that remove content or ban users. Identified by the presence of [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) label tags `["L", "moderation"]` and `["l", "ban", "moderation"]`.
2. **Reports** -- soft flags from any valid community member using standard [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) report types (`nudity`, `spam`, `profanity`, `illegal`, `malware`, `impersonation`, `other`). No `L`/`l` tags. Clients display a content warning that users must click through to reveal.
1. The reporter is a validated community member.
2. The reporter's rank is strictly less than the target's rank (or the target is a non-member).
Kind `1984` events from **non-members** are ignored entirely within community context. Kind `1984` events from members who are themselves banned are also ignored after ban resolution; banned members cannot retain moderation or reporting authority.
Reports from non-members or insufficiently-ranked members are ignored.
#### Bans (Authoritative Moderation)
#### Content Removal
A ban is **authoritative** if and only if:
Hide a post by publishing kind `1984` with both `e` and `p` tags:
1. The event contains `["l", "ban", "moderation"]` and `["L", "moderation"]` tags.
2. The publisher is a validated community member.
3. The publisher is not themselves banned after ban resolution.
4. The publisher's rank is **strictly less than** the target's rank (or the target is a non-member).
Bans that fail any of these conditions MUST be ignored.
##### Content Ban
Ban a specific post by publishing kind `1984` with `e`, `p`, and `A` tags plus the `ban` label. The `e` and `p` tags use `"other"` as the NIP-56 report type since the action is administrative rather than categorical.
```jsonc
{
"kind": 1984,
"pubkey": "<moderator-pubkey>",
"content": "Spam",
"content": "Reason for removal",
"tags": [
["e", "<offending-event-id>"],
["p", "<offending-author-pubkey>"],
["A", "34550:<founder-pubkey>:<community-d-tag>"]
["e", "<offending-event-id>", "other"],
["p", "<offending-author-pubkey>", "other"],
["A", "34550:<founder-pubkey>:<community-d-tag>"],
["L", "moderation"],
["l", "ban", "moderation"]
]
}
```
#### Member Ban
Clients MUST omit the banned event from canonical community feeds entirely. The event is not displayed, blurred, or indicated in any way -- it is treated as if it does not exist.
Ban a member by publishing kind `1984` with `p` tag only (no `e` tag). This is **non-cascading** -- only the targeted member is banned. Their kind `8` awards remain on relays, so downstream members whose chain passes through the banned member are still valid. For cascading removal, use badge revocation (kind `5`) instead.
The `e` and `p` tags are untrusted until matched against the actual target event. A content ban MUST only apply when the targeted event's `id` matches the ban's `e` tag and the targeted event's `pubkey` matches the ban's `p` tag. This prevents a malicious or mistaken report from hiding an event by pairing its event ID with a lower-ranked or non-member pubkey.
##### Member Ban
Ban a member by publishing kind `1984` with `p` and `A` tags only (no `e` tag) plus the `ban` label. This is **non-cascading** -- only the targeted member is banned. Their kind `8` awards remain on relays, so downstream members whose chain passes through the banned member are still valid. For cascading removal, use badge revocation (kind `5`) instead.
```jsonc
{
"kind": 1984,
"pubkey": "<moderator-pubkey>",
"content": "Violated guidelines",
"content": "Reason for ban",
"tags": [
["p", "<banned-member-pubkey>"],
["p", "<banned-member-pubkey>", "other"],
["A", "34550:<founder-pubkey>:<community-d-tag>"],
["L", "moderation"],
["l", "ban", "moderation"]
]
}
```
Clients distinguish content bans (`e` + `p` + `A` + `ban` label) from member bans (`p` + `A` + `ban` label, no `e` tag).
#### Reports (Content Warnings)
Any **valid, non-banned community member** (regardless of rank) may report content by publishing kind `1984` with a standard NIP-56 report type on the `e` and `p` tags. Reports do NOT use `L`/`l` label tags.
```jsonc
{
"kind": 1984,
"pubkey": "<member-pubkey>",
"content": "Additional context",
"tags": [
["e", "<event-id>", "nudity"],
["p", "<author-pubkey>", "nudity"],
["A", "34550:<founder-pubkey>:<community-d-tag>"]
]
}
```
Clients distinguish content removal (`e` + `p` + `A`) from bans (`p` + `A`, no `e`).
Clients SHOULD display reported content behind a content warning overlay that requires user interaction to reveal. The report type (e.g. `nudity`, `spam`) MAY be shown in the warning. Multiple reports on the same event reinforce the warning but do not automatically escalate to a ban.
#### Reinstatement
Reports from non-members and banned members are ignored.
Delete the kind `1984` event via kind `5` ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)). Per NIP-09, only the original author can delete it.
As with content bans, report warnings MUST only attach to content when the target event's `id` matches the report's `e` tag and the target event's `pubkey` matches the report's `p` tag.
#### Classification Summary
| `l` tag present? | `e` tag present? | Authority check | Result |
|---|---|---|---|
| `["l", "ban", "moderation"]` | Yes | Non-banned member; rank < target; `e`/`p` match target event | Content ban (omit event) |
| `["l", "ban", "moderation"]` | No | Non-banned member; rank < target | Member ban |
| No | Yes | Non-banned member; `e`/`p` match target event | Content warning |
| No | No | -- | Invalid (ignored) |
| Any | Any | Non-member | Ignored |
| Any | Any | Banned member | Ignored |
| `["l", "ban", "moderation"]` | Any | Rank >= target | Ignored |
#### Rescinding Moderation
A kind `1984` ban or report can be rescinded by deleting the kind `1984` event via kind `5` ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)). Per NIP-09, only the original author of the kind `1984` event can delete it.
```jsonc
{
@@ -819,6 +756,8 @@ Delete the kind `1984` event via kind `5` ([NIP-09](https://github.com/nostr-pro
}
```
Clients that implement moderation rescinding SHOULD discard any kind `1984` event whose matching kind `5` deletion exists before resolving bans and reports. This branch does not implement moderation rescinding yet; it is retained here as part of the protocol foundation for future moderation extensions.
### Revocation
A badge awarder can revoke their own award via kind `5`:
@@ -852,6 +791,22 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank
2. Extract badge `a` tags from results.
3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }`
**Communities a user has bookmarked:**
Agora uses [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) kind `10004` ("Communities") to let users save communities they want quick access to without requiring membership. Bookmarked communities are surfaced in the "My Communities" view alongside founded and member-of communities.
1. `{ "kinds": [10004], "authors": ["<user-pubkey>"], "limit": 1 }`
2. Extract `a` tags whose value begins with `34550:` from the result.
3. For each coordinate `34550:<author-pubkey>:<d-tag>`, query the community definition with both `authors` and `#d` filters to prevent spoofing:
```jsonc
{ "kinds": [34550], "authors": ["<author-pubkey>"], "#d": ["<d-tag>"], "limit": 1 }
```
Clients toggling a bookmark MUST perform a read-modify-write cycle on the replaceable kind `10004` event: fetch the freshest version from relays, add or remove the matching `["a", "34550:<pubkey>:<d-tag>"]` tag, and republish the full tag list. Appending new entries to the end preserves chronological bookmark order per NIP-51.
When the same community appears in multiple discovery sources, clients SHOULD display a single card but MAY indicate all applicable relationships (e.g. a member who has also bookmarked a community).
### Security Considerations
- **Author filtering**: Clients MUST filter community definitions by `authors` to prevent impersonation.
@@ -864,12 +819,86 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank
- [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) -- Event Deletion Request
- [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md) -- Comment
- [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) -- Unknown Event Kinds (`alt` tag)
- [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) -- Labeling (moderation `ban` label)
- [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) -- Lists (kind `10004` Communities list for bookmarks)
- [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) -- Reporting
- [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) -- Badges
- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities
---
## Community Fundraising Goals (NIP-75)
### Summary
Communities can host fundraising campaigns using [NIP-75 Zap Goals](https://github.com/nostr-protocol/nips/blob/master/75.md) (kind `9041`). A zap goal linked to a community allows members and supporters to contribute sats toward a shared target.
### Linking Goals to Communities
A zap goal is linked to a community by including an `a` tag pointing to the community's kind `34550` definition:
```json
{
"kind": 9041,
"content": "Community meetup travel fund",
"tags": [
["amount", "500000000"],
["relays", "wss://relay.ditto.pub", "wss://relay.primal.net"],
["a", "34550:<community-author-pubkey>:<community-d-identifier>"],
["alt", "Zap goal: Community meetup travel fund"],
["summary", "Help fund travel for our annual meetup"],
["image", "https://example.com/meetup.jpg"],
["closed_at", "1735689600"]
]
}
```
### Required Tags (per NIP-75)
- `amount` -- Target amount in millisatoshis
- `relays` -- Relay URLs where zaps should be sent and tallied from
### Optional Tags (per NIP-75)
- `closed_at` -- Unix timestamp deadline; zap receipts after this time are excluded from the tally
- `image` -- Image URL for the goal
- `summary` -- Brief description
### Additional Tags (Agora-specific)
- `a` -- Community link (`34550:<pubkey>:<d-tag>`) scoping the goal to a community
- `alt` -- NIP-31 human-readable description
### Querying
Community goals are queried by filtering on the `a` tag:
```
{ "kinds": [9041], "#a": ["34550:<pubkey>:<d-tag>"], "limit": 50 }
```
### Progress Tallying
Goal progress is calculated from kind `9735` zap receipts targeting the goal event:
```
{ "kinds": [9735], "#e": ["<goal-event-id>"], "limit": 500 }
```
Receipts with `created_at` after the `closed_at` deadline (if set) are excluded from the tally.
### Access Control
Anyone may create a zap goal linked to a community. The existing community members-only feed filter controls whether non-member goals are displayed. Anyone may zap a goal.
### Dependencies
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) -- Lightning Zaps
- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities
- [NIP-75](https://github.com/nostr-protocol/nips/blob/master/75.md) -- Zap Goals
---
## Kind 0 Extension: Avatar Shape
### Summary
@@ -933,31 +962,3 @@ NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, d
**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.
#### Kind 11125 `content` JSON — `missions` field
The `content` of kind 11125 is a JSON object. Ditto extends it with a `missions` field that tracks daily and evolution mission progress:
```jsonc
{
"missions": {
"date": "2026-04-16", // ISO date string for the current daily mission set
"daily": [ /* Mission[] */ ],
"evolution": [ /* Mission[] active hatch/evolve tasks, cleared on stage transition */ ],
"rerolls": 2 // remaining daily mission rerolls
}
// ...other profile fields (coins, achievements, inventory, etc.)
}
```
Each `Mission` is either a **TallyMission** (`{ id, target, count }`) or an **EventMission** (`{ id, target, events: string[] }`) where `events` contains Nostr event IDs that satisfy the mission. Evolution missions are populated when incubation or evolution begins and cleared when the stage transition completes or is cancelled.
+30
View File
@@ -39,6 +39,36 @@ npm run dev
Development server: `http://localhost:8080`
### Docker Getting Started
Use Docker Compose when you want the nginx reverse-proxy stack (necessary if you want decryptable media in messages - kind 15s of NIP 17):
```sh
git clone https://gitlab.com/soapbox-pub/agora-3.git
cd agora-3
cp .env.example .env
docker compose up --build
```
Proxy URL: `http://localhost:8083`
This starts:
- `vite` service on the internal Docker network (`vite:8080`)
- `web` service (`nginx`) on host port `8082`, proxying to Vite with websocket support
Stop stack:
```sh
docker compose down
```
Production-style container build:
```sh
docker compose -f docker-compose.prod.yml up --build
```
### Build
```sh
+6
View File
@@ -0,0 +1,6 @@
services:
web:
build: .
restart: unless-stopped
expose:
- "80"
+30
View File
@@ -0,0 +1,30 @@
services:
web:
image: nginx:alpine
ports:
- "8083:80"
volumes:
- ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
- ./dist:/usr/share/nginx/html:ro
restart: unless-stopped
depends_on:
- vite
networks:
- agora-network
vite:
image: node:22-alpine
working_dir: /app
# Use host node_modules so new dependencies are picked up after install.
command: sh -c "npm install && npm run dev"
volumes:
- .:/app
environment:
- NODE_ENV=development
networks:
- agora-network
restart: unless-stopped
networks:
agora-network:
driver: bridge
-383
View File
@@ -1,383 +0,0 @@
# Theme System
This document describes the two separate but overlapping theme features in Ditto: the **App Theme** (which controls the local UI) and the **Profile Theme** (which is published to Nostr for others to see). Understanding the distinction is key to working with this codebase.
## Overview
| Concept | Purpose | Scope | Persistence |
|---|---|---|---|
| **App Theme** | Controls colors, fonts, and background of the local UI | Local to the user's browser | localStorage + encrypted NIP-78 sync |
| **Profile Theme** | A set of theme values published as a Nostr event | Public, visible to other users | Kind 16767 replaceable event |
The App Theme and Profile Theme share the same underlying data structure (`ThemeConfig`), and there is an optional bridge between them (`autoShareTheme`), but they are fundamentally independent systems.
---
## Part 1: App Theme
The App Theme controls what the user sees in their own browser. It has no inherent connection to Nostr.
### Core Concept: 3 Colors Define Everything
The entire theme is derived from just 3 core colors, defined by the `CoreThemeColors` interface in `src/themes.ts:8`:
```typescript
interface CoreThemeColors {
background: string; // HSL string, e.g. "228 20% 10%"
text: string; // Text/foreground color
primary: string; // Primary accent (buttons, links, focus rings)
}
```
From these 3 values, the system auto-derives 19 CSS tokens (the full `ThemeTokens` set) via `deriveTokensFromCore()` in `src/lib/colorUtils.ts:141`. The derivation algorithm:
- Detects dark/light mode from background luminance (threshold: 0.2)
- Derives `card` and `popover` surfaces by slightly lightening the background (dark mode) or using it directly (light mode)
- Derives `secondary` and `muted` surfaces by adjusting background lightness
- Derives `border` using the primary hue with reduced saturation
- Computes `mutedForeground` as a dimmer version of the text color
- Sets `accent = primary` and `ring = primary`
- Auto-computes `primaryForeground` using WCAG contrast detection (white or dark)
- Uses fixed red values for `destructive` / `destructiveForeground`
### Theme Modes
The `Theme` type (`src/contexts/AppContext.ts:9`) has four values:
| Mode | Behavior |
|---|---|
| `"light"` | Uses the builtin (or configured) light color set |
| `"dark"` | Uses the builtin (or configured) dark color set |
| `"system"` | Resolves to `"light"` or `"dark"` based on `prefers-color-scheme`, with a live media query listener |
| `"custom"` | Uses user-defined colors stored in `config.customTheme` |
**Builtin themes** are defined in `src/themes.ts:102`:
```typescript
const builtinThemes = {
light: { background: '270 50% 97%', text: '270 25% 12%', primary: '270 65% 55%' },
dark: { background: '228 20% 10%', text: '210 40% 98%', primary: '258 70% 60%' },
};
```
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
The `ThemeConfig` type (`src/themes.ts:50`) wraps the 3 core colors with optional extras:
```typescript
interface ThemeConfig {
title?: string;
colors: CoreThemeColors;
font?: ThemeFont; // { family: string; url?: string }
background?: ThemeBackground; // { url: string; mode?: 'cover' | 'tile'; ... }
}
```
This is the canonical type used everywhere: in `AppConfig.customTheme`, in encrypted settings, and in Nostr theme events.
### Theme Presets
Named presets are defined in `src/themes.ts:136` (e.g. `pink`, `toxic`, `sunset`). Each preset includes core colors and optionally a font and background image. Applying a preset sets the app theme to `"custom"` and stores the preset's config as `customTheme`.
### How Themes Apply to the DOM
The theme pipeline has three stages designed to prevent any flash of wrong colors:
#### Stage 1: Pre-React Blocking Script (`public/theme.js`)
A synchronous `<script>` tag in `index.html:43` runs before React mounts. It:
1. Reads `nostr:app-config` from localStorage
2. Resolves `"system"` via `matchMedia`
3. Handles legacy presets (`"black"`, `"pink"`)
4. Sets `document.documentElement.className` to the theme name
5. Sets `document.body.style.background` to the correct background color
6. Updates preloader colors (logo and spinner) to match
This prevents any visible flash between the hardcoded dark defaults in `index.html:32` and the user's actual theme.
#### Stage 2: React Provider (`src/components/AppProvider.tsx`)
Three private hooks run during the provider's lifecycle:
**`useApplyTheme`** (line 91) - Uses `useLayoutEffect` (synchronous before paint) to:
- Resolve the theme mode
- Build a full CSS string from `CoreThemeColors` via `buildThemeCssFromCore()`
- Inject/update a `<style id="theme-vars">` element with all 19 CSS custom properties
- Set `document.documentElement.className` to the resolved theme
- Remove the inline body style left by `theme.js`
- When mode is `"system"`, attach a `matchMedia` change listener
**`useApplyFonts`** (line 133) - Loads and applies custom fonts via `loadAndApplyFont()` from `src/lib/fontLoader.ts`.
**`useApplyBackground`** (line 156) - Injects/removes a `<style id="theme-background">` for background images (cover or tile mode).
#### Stage 3: Theme Switch (`src/hooks/useTheme.ts`)
The `setTheme()` function (line 52) performs a flicker-free theme switch:
1. Injects a temporary `<style>` that disables all CSS transitions (`transition: none !important`)
2. Synchronously builds and applies CSS vars before React re-renders
3. Updates `document.documentElement.className`
4. Re-enables transitions after browser paint via `requestAnimationFrame`
5. Updates localStorage config
6. Debounce-syncs to encrypted NIP-78 storage (1 second delay)
### How Components Consume Theme Values
#### CSS Custom Properties to Tailwind
`tailwind.config.ts` maps all 19 CSS custom properties to Tailwind color utilities:
```typescript
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
// ... (secondary, destructive, muted, accent, popover, card, border, input, ring)
}
```
Components use standard Tailwind classes like `bg-primary`, `text-foreground`, `border-border`, etc. These resolve to `hsl(var(--primary))`, which picks up whichever values are currently set on `:root`.
The `cn()` utility in `src/lib/utils.ts` combines `clsx` (conditional class joining) with `tailwind-merge` (intelligent Tailwind class deduplication).
#### Static CSS
`src/index.css` applies base styles using theme tokens:
```css
* { @apply border-border; }
body { @apply bg-background text-foreground; }
```
The only static CSS custom property is `--radius: 0.75rem`. All color variables are injected dynamically.
### ScopedTheme
The `ScopedTheme` component (`src/components/ScopedTheme.tsx`) applies a different set of theme colors to a DOM subtree by setting CSS variables as inline `style`:
```tsx
<ScopedTheme colors={someColors} className="rounded-lg p-4">
{/* Children here see different --background, --primary, etc. */}
</ScopedTheme>
```
It also sets `data-theme-mode="dark"` or `"light"` based on background luminance, for CSS targeting.
### App Theme Persistence
#### Layer 1: localStorage (immediate)
The `useLocalStorage` hook (`src/hooks/useLocalStorage.ts`) stores the full `AppConfig` under key `"nostr:app-config"`. This includes `theme`, `customTheme`, `autoShareTheme`, and `themes`. Changes are reflected immediately and support cross-tab sync via `StorageEvent`.
#### Layer 2: Encrypted NIP-78 Settings (cross-device sync)
The `useEncryptedSettings` hook (`src/hooks/useEncryptedSettings.ts`) stores theme preferences in a kind 30078 addressable event, encrypted to self via NIP-44. The `EncryptedSettings` interface includes `theme`, `customTheme`, and `autoShareTheme` among other app settings.
Key behaviors:
- Query is delayed 5 seconds after login to avoid competing with feed load
- Uses optimistic updates with a `pendingSettings` ref for rapid successive mutations
- A `recentlyWritten()` guard returns true for 10 seconds after a local write to prevent `NostrSync` from overwriting the value that was just saved
#### Sync via NostrSync
The `NostrSync` component (`src/components/NostrSync.tsx`) runs globally and syncs encrypted settings from Nostr on login. For theme-related fields, it:
1. Seeds a `lastSyncedTimestamp` ref on first load to prevent stale events from overwriting local config
2. Skips application if `recentlyWritten()` is true
3. Only applies changes if the remote timestamp is newer
4. Handles legacy theme value migration (`"black"`, `"pink"` to `"custom"`)
5. Diffs each field individually to avoid unnecessary re-renders
---
## Part 2: Profile Theme
The Profile Theme is a public Nostr event that represents a user's chosen theme. Other clients can read it to style that user's profile page, or users can browse and copy each other's themes.
### Nostr Event Kinds
#### Kind 36767: Theme Definition (addressable, multiple per user)
A shareable, named theme that a user has created. Think of these as "published theme presets." Tags:
| Tag | Purpose | Example |
|---|---|---|
| `d` | Identifier (slug) | `["d", "ocean-night"]` |
| `c` | Color (hex + role) | `["c", "#1a1a2e", "background"]` |
| `f` | Font (family + optional URL) | `["f", "Comfortaa", "https://cdn.jsdelivr.net/..."]` |
| `bg` | Background (imeta-style variadic) | `["bg", "url https://...", "mode cover", "m image/jpeg"]` |
| `title` | Display name | `["title", "Ocean Night"]` |
| `alt` | NIP-31 description | `["alt", "Custom theme: Ocean Night"]` |
| `t` | Topic tag | `["t", "theme"]` |
| `description` | Optional description | `["description", "A deep blue theme"]` |
Colors are stored as **hex** in `c` tags (converted to/from HSL internally). The `content` field is empty (legacy events may have JSON in content for backward compatibility).
#### Kind 16767: Active Profile Theme (replaceable, one per user)
The user's currently active profile theme. Same tag structure as kind 36767 but without `d` or `description` tags, and with an optional `a` tag referencing the source theme definition:
| Tag | Purpose |
|---|---|
| `c` | Color tags (same as 36767) |
| `f` | Font tag (same as 36767) |
| `bg` | Background tag (same as 36767) |
| `alt` | Always `"Active profile theme"` |
| `title` | Optional theme name |
| `a` | Optional reference to source kind 36767 event |
### Hooks
| Hook | File | Purpose |
|---|---|---|
| `usePublishTheme` | `src/hooks/usePublishTheme.ts` | Publish/update/delete theme definitions (36767), set/clear active profile theme (16767) |
| `useUserThemes` | `src/hooks/useUserThemes.ts` | Query all kind 36767 themes by a user, deduplicated by d-tag, sorted newest first |
| `useActiveProfileTheme` | `src/hooks/useActiveProfileTheme.ts` | Query a user's kind 16767 active profile theme |
### Publishing and Parsing
All event building and parsing is in `src/lib/themeEvent.ts`:
- `buildThemeDefinitionTags()` / `parseThemeDefinition()` - Kind 36767
- `buildActiveThemeTags()` / `parseActiveProfileTheme()` - Kind 16767
- `buildColorTags()` / `parseColorTags()` - HSL-to-hex conversion for `c` tags
- `buildFontTag()` / `parseFontTag()` - Font `f` tags
- `buildBackgroundTag()` / `parseBackgroundTag()` - Background `bg` tags (imeta-style)
- `titleToSlug()` - Generate d-tag identifiers from titles
Backward compatibility: if `c` tags are missing, the parser falls back to reading legacy JSON from `content` (handling both the old 19-token format and the 4-color format).
---
## Part 3: The Bridge Between App Theme and Profile Theme
The two systems are connected by the **autoShareTheme** setting and the NostrSync component.
### App Theme -> Profile Theme
When `autoShareTheme` is enabled (default: `true`) and the user applies a custom theme via `applyCustomTheme()`, the `useTheme` hook automatically publishes the custom theme as a kind 16767 active profile theme, debounced by 2 seconds.
```
User picks a custom theme
-> applyCustomTheme() in useTheme.ts:88
-> Updates local config (localStorage)
-> Syncs to encrypted NIP-78 storage (1s debounce)
-> If autoShareTheme: publishes kind 16767 (2s debounce)
```
### Profile Theme -> App Theme
On page load, if `autoShareTheme` is enabled, `NostrSync` (line 174) fetches the user's kind 16767 event and applies it as `customTheme` **without changing the theme mode**. This means:
- If the user is on `theme: "dark"`, their profile theme is stored as `customTheme` but the UI stays in dark mode
- If the user is on `theme: "custom"`, the profile theme's colors are applied to the UI
- This allows the profile theme to stay in sync across devices without forcing the user into custom mode
### Theme Definitions (Kind 36767)
Theme definitions are independent of the app theme. Users can create, publish, edit, and delete named themes. Other users can view them in feeds (via `ThemeUpdateCard`) and copy them. These are purely social objects on the Nostr network.
---
## Font System
Fonts are managed by `src/lib/fontLoader.ts` and `src/lib/fonts.ts`.
### Bundled Fonts
10 fonts are bundled via `@fontsource` packages with lazy loading (dynamic imports):
| Category | Fonts |
|---|---|
| Sans | Inter, DM Sans, Outfit, Montserrat |
| Serif | Lora, Merriweather, Playfair Display |
| Mono | JetBrains Mono |
| Display | Comfortaa |
| Handwriting | Comic Relief |
Each has a `load()` function and a `cdnUrl` for Nostr event publishing.
### Font Application
Three `<style>` elements manage fonts:
| ID | Purpose |
|---|---|
| `theme-font-faces` | `@font-face` rules for remote fonts |
| `theme-font-overrides` | `html { font-family: "CustomFont", "Inter Variable", ... !important; }` |
| `theme-vars` | Theme CSS custom properties (not font-specific, but part of the pipeline) |
The `loadAndApplyFont()` function:
1. Tries to load via bundled `@fontsource` package first
2. Falls back to injecting a `@font-face` rule from a remote URL
3. Applies a global font-family override via `<style id="theme-font-overrides">`
4. Passing `undefined` clears the override (reverts to default Inter)
---
## Color Utilities
`src/lib/colorUtils.ts` provides the color math underpinning the theme system:
| Function | Purpose |
|---|---|
| `parseHsl` / `formatHsl` | Parse/format HSL strings (`"228 20% 10%"`) |
| `hslToRgb` / `rgbToHsl` | HSL-RGB conversion |
| `hexToRgb` / `rgbToHex` | Hex-RGB conversion |
| `hexToHslString` / `hslStringToHex` | Direct hex-to-HSL-string conversion (used for Nostr `c` tags) |
| `getLuminance` | WCAG 2.1 relative luminance |
| `getContrastRatio` / `getContrastRatioHsl` | WCAG contrast ratio between two colors |
| `isDarkTheme` | Determines if a background is "dark" (luminance < 0.2) |
| `deriveTokensFromCore` | The core algorithm: 3 colors -> 19 tokens |
| `tokensToCoreColors` | Extract 3 core colors from a legacy 19-token object |
All colors are stored internally as HSL strings without the `hsl()` wrapper (e.g. `"228 20% 10%"`). The `hsl()` wrapper is added by Tailwind's config (`hsl(var(--background))`).
---
## Validation
Theme data is validated with Zod schemas in `src/lib/schemas.ts`:
- `ThemeSchema` - Validates `'dark' | 'light' | 'system' | 'custom'`
- `CoreThemeColorsSchema` - Validates the 3 HSL string fields
- `ThemeConfigSchema` - Full config with optional font/background
- `ThemeConfigCompatSchema` - Accepts both `ThemeConfig` and bare `CoreThemeColors`
- `ThemeColorsCompatSchema` - Union of current 3-color, old 4-color, and legacy 19-token formats
- `AppConfigSchema` - Full app config including all theme fields
- `EncryptedSettingsSchema` - Encrypted settings including theme fields
The `AppProvider` deserializer (`src/components/AppProvider.tsx:32`) validates each top-level field individually with `safeParse`, so a single invalid field doesn't nuke the entire config.
---
## File Index
| File | Role |
|---|---|
| `src/themes.ts` | Core types (`CoreThemeColors`, `ThemeConfig`, `ThemeTokens`), builtin themes, presets, CSS builders |
| `src/lib/colorUtils.ts` | Color conversion, contrast detection, token derivation |
| `src/lib/themeEvent.ts` | Nostr event kinds (36767, 16767), tag building/parsing |
| `src/lib/fontLoader.ts` | Font loading and CSS injection |
| `src/lib/fonts.ts` | Bundled font definitions |
| `src/lib/schemas.ts` | Zod validation schemas |
| `src/contexts/AppContext.ts` | `Theme` type, `AppConfig` interface, React context |
| `src/hooks/useTheme.ts` | Primary theme API: `setTheme()`, `applyCustomTheme()`, `setAutoShareTheme()` |
| `src/hooks/useAppContext.ts` | Context consumer hook |
| `src/hooks/useEncryptedSettings.ts` | NIP-78 encrypted settings (cross-device sync) |
| `src/hooks/usePublishTheme.ts` | Publish theme definitions and active profile theme |
| `src/hooks/useUserThemes.ts` | Query user's theme definitions |
| `src/hooks/useActiveProfileTheme.ts` | Query user's active profile theme |
| `src/components/AppProvider.tsx` | Theme application to DOM (`useApplyTheme`, `useApplyFonts`, `useApplyBackground`) |
| `src/components/NostrSync.tsx` | Cross-device sync for encrypted settings and profile theme |
| `src/components/ScopedTheme.tsx` | Scoped CSS variable overrides for subtrees |
| `src/components/ThemeSelector.tsx` | Full settings UI for theme management |
| `src/components/SidebarThemeDropdown.tsx` | Compact theme picker dropdown |
| `public/theme.js` | Pre-React blocking script for flash prevention |
| `index.html` | Hardcoded dark defaults, preloader, blocking script tag |
| `tailwind.config.ts` | CSS custom property to Tailwind color mapping |
| `src/index.css` | Base styles using theme tokens |
-254
View File
@@ -1,254 +0,0 @@
# Blobbi Tag Schema
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
## Overview
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
- All valid tags and their purposes
- Which tags are required vs optional
- Which tags persist across stage transitions
- Which tags should be removed during transitions
- Deprecated tags that should be filtered out
---
## Tag Categories
### 1. System / Metadata Tags
Core protocol-level tags required for event identification and ecosystem membership.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
### 2. Core Identity Tags
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
### 3. Visual Trait Tags
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
### 4. Personality / Trait Tags
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
### 5. Stat Tags
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
**Stage Transition Behavior**:
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
### 6. State / Lifecycle Tags
Tags that track the Blobbi's current lifecycle state.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
**State Constraints**:
- `incubating` is only valid for `stage: egg`
- `evolving` is only valid for `stage: baby`
- After hatch/evolve completes, `state` MUST be set to `active`
### 7. Task System Tags
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
### 8. Progression Tags
Long-term progress tracking that persists across all stages.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
### 9. Social / Flag Tags
User preferences and computed flags.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
### 10. Evolution Tags
Tags specific to adult Blobbis.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
### 11. Extension Tags
Optional tags for themes and crossover features.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
---
## Deprecated Tags
These tags are from legacy versions and MUST be removed when republishing events.
| Tag | Reason | Replaced By |
|-----|--------|-------------|
| `shell_integrity` | Eggs use standard `health` stat | `health` |
| `egg_temperature` | Warmth handled via UI props | N/A |
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
| `egg_status` | Replaced by standard state | `state` |
| `fees` | Removed | N/A |
| `incubation_time` | Uses state_started_at | `state_started_at` |
| `start_incubation` | Uses state_started_at | `state_started_at` |
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
---
## Stage Transition Rules
### Hatch (egg → baby)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``baby`
- `state``active`
- `hunger``100`
- `happiness``100`
- `hygiene``100`
- `energy``100`
- `health` → (inherited from egg after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- All system tags (`d`, `b`, `t`, `client`)
- All identity tags (`name`, `seed`, `generation`)
- All visual tags (colors, pattern, size)
- All personality tags (if present)
- All progression tags (`experience`, `care_streak`)
- All social tags (`breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``adult`
- `state``active`
- All stats → (inherited from baby after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- Same as hatch, plus all stats are inherited (not reset)
**Tags to ADD (optional)**:
- `adult_type` → computed based on care history
---
## Migration Rules
When migrating legacy Blobbis to canonical format:
1. **Always preserve existing values** - Do not regenerate tags that already exist
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
---
## Validation Rules
A valid Blobbi event MUST have:
- `d` tag in canonical format
- `b` tag = `blobbi:ecosystem:v1`
- `t` tag = `blobbi`
- `name` tag (non-empty)
- `seed` tag (64 hex chars)
- `stage` tag (valid value)
- `state` tag (valid value)
- `last_interaction` tag (valid timestamp)
---
## Implementation Checklist
When implementing any flow that modifies Blobbi tags:
- [ ] Start from `canonical.allTags` as the base
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
- [ ] Filter out deprecated tags
- [ ] Update only the tags that need to change
- [ ] Validate required tags are present
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
resolver 127.0.0.11 valid=10s;
set $vite_backend http://vite:8080;
proxy_pass $vite_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}
+142 -3
View File
@@ -95,6 +95,7 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.17.1",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
@@ -196,6 +197,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -2564,6 +2566,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -2577,6 +2580,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -2586,6 +2590,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -5822,6 +5827,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5835,6 +5841,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5848,6 +5855,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5861,6 +5869,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5874,6 +5883,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5887,6 +5897,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5900,6 +5911,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5913,6 +5925,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5926,6 +5939,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5939,6 +5953,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5952,6 +5967,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5965,6 +5981,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5978,6 +5995,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5991,6 +6009,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6004,6 +6023,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6017,6 +6037,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6030,6 +6051,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6043,6 +6065,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6056,6 +6079,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6069,6 +6093,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6082,6 +6107,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6095,6 +6121,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6108,6 +6135,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6121,6 +6149,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6134,12 +6163,46 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@samthomson/nostr-messaging": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@samthomson/nostr-messaging/-/nostr-messaging-0.17.1.tgz",
"integrity": "sha512-TfgC3L/7sKnkLSqod1UyF9Bt/F36kH02nRffWjm5YEMfLvHLEYlT5ECgzyrnt9QVpYXG25rVAhEpXF9wxmPX0w==",
"license": "MIT",
"dependencies": {
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"fuse.js": "^7.1.0",
"idb": "^8.0.3",
"nostr-tools": "^2.13.0",
"react-blurhash": "^0.3.0"
},
"peerDependencies": {
"@nostrify/nostrify": ">=0.47.0",
"@nostrify/react": ">=0.2.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.56.2",
"clsx": "^2.0.0",
"lucide-react": "^0.462.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.0.0",
"tailwind-merge": "^2.0.0"
}
},
"node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
@@ -6750,6 +6813,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -6759,7 +6823,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -7450,12 +7514,14 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -7469,6 +7535,7 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -7697,6 +7764,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7786,6 +7854,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -7910,6 +7979,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -8043,6 +8113,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -8067,6 +8138,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -8194,6 +8266,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -8244,6 +8317,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -8600,6 +8674,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
@@ -8612,6 +8687,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
@@ -9044,6 +9120,7 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -9060,6 +9137,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -9086,6 +9164,7 @@
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -9131,6 +9210,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -9217,6 +9297,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -9231,11 +9312,25 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz",
"integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/krisk"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -9278,6 +9373,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -9320,6 +9416,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -9663,6 +9760,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -9675,6 +9773,7 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -9716,6 +9815,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -9734,6 +9834,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -9804,6 +9905,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -9862,6 +9964,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -10302,6 +10405,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -10314,6 +10418,7 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -10773,6 +10878,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -11364,6 +11470,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -11471,6 +11578,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -11569,6 +11677,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -11688,6 +11797,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -11867,6 +11977,7 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
@@ -11998,6 +12109,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -12010,6 +12122,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12019,6 +12132,7 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -12091,6 +12205,7 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -12108,6 +12223,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
@@ -12127,6 +12243,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -12162,6 +12279,7 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -12187,6 +12305,7 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -12214,6 +12333,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/postgres-array": {
@@ -12630,6 +12750,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -13007,6 +13128,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -13031,6 +13153,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -13231,6 +13354,7 @@
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
@@ -13261,6 +13385,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -13416,7 +13541,7 @@
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -13703,6 +13828,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -14082,6 +14208,7 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -14117,6 +14244,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -14146,6 +14274,7 @@
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -14192,6 +14321,7 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -14259,6 +14389,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -14268,6 +14399,7 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -14310,6 +14442,7 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -14326,6 +14459,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -14343,6 +14477,7 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -14405,6 +14540,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -14486,6 +14622,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
@@ -14524,7 +14661,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -14899,6 +15036,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/vaul": {
@@ -16673,6 +16811,7 @@
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
+1
View File
@@ -102,6 +102,7 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.17.1",
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
+2 -445
View File
@@ -1,450 +1,7 @@
# Changelog
## [2.8.0] - 2026-04-16
## [1.0.0] - 2026-04-30
### Added
- Back up your secret key right from Profile settings -- reveal, copy, and save it to iCloud Keychain, Android Credential Manager, or a local file
- Blobbi mission progress now persists across page refreshes, so your hatching and evolution journey picks up right where you left off
### Changed
- AI chat has been overhauled with a cleaner layout, the Dork mascot across empty states, and a clear path to grab Shakespeare credits when you run out
- Friendly error banners now explain when you've hit the rate limit or run out of AI credits, instead of cryptic failures
### Fixed
- Avatar shape selection during signup now actually saves to your profile
- Blobbi interaction missions now tally correctly the moment you start incubating or evolving
- Blobbi task progress displays the right numbers immediately on page load instead of showing 0 until everything catches up
## [2.7.1] - 2026-04-16
### Added
- Tap the Home tab while already on Home to scroll to the top and refresh your feed
- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch
- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone
### Changed
- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen
- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder
- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection
- Android's automatic cloud backup now excludes your wallet credentials
### Fixed
- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed
- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds
- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data
## [2.7.0] - 2026-04-14
### Added
- Customizable widget sidebar -- drag, drop, and rearrange widgets on your feed including Trending, Hot Posts, Bluesky, AI Chat, Blobbi, Music, Photos, Wikipedia, and more
- Blobbi rooms -- swipe between living spaces, clean up after your pet, and earn XP from daily care routines
- Native push notifications on iOS with author names, content previews, and smart grouping by category
- Haptic feedback throughout the app -- taps, buzzes, and pulses when you react, zap, repost, pull to refresh, play games, and interact with your Blobbi
- Hot Posts widget showing the most popular posts from your feed at a glance
### Changed
- Sidebar widgets are now clickable links that take you to their full pages
- Blobbi widget shows live stats with circular ring indicators and quick action buttons
### Fixed
- Zaps embedded in posts now render as proper inline cards instead of blank space
- Quote posts display media and Blobbi companions correctly
- Deep linking on Google Play works again
- Game controller buttons no longer trigger text selection on long-press on iOS
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
## [2.6.4] - 2026-04-11
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
- Signup flow simplified -- cleaner profile step with a single Continue button
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- 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 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
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- 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
- 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
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- 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 users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
- Letters page added to the sidebar with a custom mailbox icon
## [2.0.1] - 2026-03-26
### Added
- Tap the version number in settings to see what's new
## [2.0.0] - 2026-03-26
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
- Initial Agora 3 release.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+18 -26
View File
@@ -8,7 +8,7 @@ import { InferSeoMetaPlugin } from "@unhead/addons";
import { createHead, UnheadProvider } from "@unhead/react/client";
import { useEffect } from "react";
import { AppProvider } from "@/components/AppProvider";
import { DMProvider, type DMConfig } from "@/components/DMProvider";
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
import { InitialSyncGate } from "@/components/InitialSyncGate";
import { NativeNotifications } from "@/components/NativeNotifications";
import NostrProvider from "@/components/NostrProvider";
@@ -22,17 +22,11 @@ import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { SparkWalletProvider } from "@/contexts/SparkWalletContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import { PROTOCOL_MODE } from "@samthomson/nostr-messaging/core";
import AppRouter from "./AppRouter";
const dmConfig: DMConfig = {
enabled: false,
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
const head = createHead({
plugins: [InferSeoMetaPlugin()],
});
@@ -55,7 +49,6 @@ const hardcodedConfig: AppConfig = {
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
autoShareTheme: true,
useAppRelays: true,
relayMetadata: {
relays: [],
@@ -92,13 +85,6 @@ const hardcodedConfig: AppConfig = {
showVideos: true,
feedIncludeNormalVideos: true,
feedIncludeShortVideos: true,
showProfileThemes: false,
feedIncludeProfileThemes: true,
showThemeDefinitions: true,
feedIncludeThemeDefinitions: true,
showProfileThemeUpdates: true,
feedIncludeProfileThemeUpdates: true,
showCustomProfileThemes: true,
feedIncludeVoiceMessages: true,
showEmojiPacks: true,
feedIncludeEmojiPacks: true,
@@ -120,7 +106,6 @@ const hardcodedConfig: AppConfig = {
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
@@ -132,6 +117,7 @@ const hardcodedConfig: AppConfig = {
"badges",
"feed",
"notifications",
"messages",
"communities",
"profile",
"settings",
@@ -159,6 +145,14 @@ const hardcodedConfig: AppConfig = {
{ id: 'trends' },
{ id: 'hot-posts' },
],
messaging: {
enabled: true,
relayMode: 'hybrid',
protocolMode: PROTOCOL_MODE.NIP17_ONLY,
renderInlineMedia: true,
soundEnabled: false,
devMode: false,
},
};
/**
@@ -216,15 +210,13 @@ export function App() {
<NWCProvider>
<SparkWalletProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
<DMProviderWrapper>
<TooltipProvider>
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</DMProviderWrapper>
</SparkWalletProvider>
</NWCProvider>
</NostrProvider>
+8 -11
View File
@@ -4,7 +4,6 @@ import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
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";
@@ -17,9 +16,7 @@ import { getExtraKindDef } from "./lib/extraKinds";
// Critical-path pages: eagerly loaded (landing + fallback)
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
// Lazy-loaded companion layer (~450K code-split)
const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => ({ default: m.BlobbiCompanionLayer })));
import MessagesPage from "./pages/Messages";
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
@@ -30,6 +27,7 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
// All other pages: code-split via React.lazy
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
@@ -49,6 +47,7 @@ const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
const MessagingSettingsPage = lazy(() => import("./pages/MessagingSettingsPage").then(m => ({ default: m.MessagingSettingsPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
@@ -60,11 +59,11 @@ const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => (
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ default: m.VerifiedPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
@@ -109,11 +108,6 @@ export function AppRouter() {
<AudioNavigationGuard />
<DeepLinkHandler />
<ScrollToTop />
<BlobbiActionsProvider>
<Suspense fallback={null}>
<BlobbiCompanionLayer />
</Suspense>
</BlobbiActionsProvider>
<Routes>
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
@@ -123,12 +117,14 @@ export function AppRouter() {
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
<Route path="/t/:tag" element={<HashtagPage />} />
<Route path="/g/:geohash" element={<GeotagPage />} />
<Route path="/feed/:domain" element={<DomainFeedPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/feed" element={<ContentSettingsPage />} />
<Route path="/settings/content" element={<ContentPage />} />
@@ -137,6 +133,7 @@ export function AppRouter() {
path="/settings/notifications"
element={<NotificationSettings />}
/>
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
<Route
path="/settings/advanced"
element={<AdvancedSettingsPage />}
@@ -160,9 +157,9 @@ export function AppRouter() {
/>
}
/>
<Route path="/themes" element={<ThemesPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/verified" element={<VerifiedPage />} />
<Route path="/world" element={<WorldPage />} />
<Route path="/badges" element={<BadgesPage />} />
<Route path="/communities" element={<CommunitiesPage />} />
@@ -1,317 +0,0 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import {
filterInventoryByAction,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
canUseAction,
getStageRestrictionMessage,
ACTION_METADATA,
type InventoryAction,
type ResolvedInventoryItem,
type EggStatPreview,
} from '../lib/blobbi-action-utils';
interface BlobbiActionInventoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
}
export function BlobbiActionInventoryModal({
open,
onOpenChange,
action,
companion,
profile: _profile,
onUseItem,
onOpenShop: _onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
const availableItems = useMemo(() => {
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
const stageMessage = getStageRestrictionMessage(companion, action);
const isEmpty = availableItems.length === 0;
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
onUseItem(item.itemId);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
{actionMeta.icon}
</div>
<div className="min-w-0">
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
{actionMeta.description}
</p>
</div>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
{/* Stage Restriction Message */}
{!canUse && stageMessage && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
<span className="text-3xl">🥚</span>
</div>
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{stageMessage}
</p>
</div>
)}
{/* Empty State */}
{canUse && isEmpty && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for this action at your Blobbi's current stage.
</p>
</div>
)}
{/* Item List */}
{canUse && !isEmpty && (
<div className="grid gap-3">
{availableItems.map((item) => (
<BlobbiInventoryUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── Inventory Use Row ────────────────────────────────────────────────────────
interface BlobbiInventoryUseRowProps {
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
onUse: () => void;
isUsing: boolean;
disabled: boolean;
}
function BlobbiInventoryUseRow({
item,
companion,
action,
onUse,
isUsing,
disabled,
}: BlobbiInventoryUseRowProps) {
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
item.effect
),
};
}
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
};
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
{/* Top row on mobile: Icon + Info + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className="relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl">
{item.icon}
</div>
</div>
{/* Item Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
</div>
{/* Use Button */}
<Button
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
</div>
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
);
}
@@ -1,201 +0,0 @@
// src/blobbi/actions/components/BlobbiActionsModal.tsx
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
interface BlobbiActionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
onRest: () => void;
onInventoryAction: (action: InventoryAction) => void;
onDirectAction: (action: DirectAction) => void;
actionInProgress: string | null;
isPublishing: boolean;
}
export function BlobbiActionsModal({
open,
onOpenChange,
companion,
onRest,
onInventoryAction,
onDirectAction,
actionInProgress,
isPublishing,
}: BlobbiActionsModalProps) {
const isSleeping = companion.state === 'sleeping';
const isDisabled = isPublishing || actionInProgress !== null;
const isEgg = companion.stage === 'egg';
const handleAction = (action: () => void) => {
action();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle>Blobbi Actions</DialogTitle>
<p className="text-sm text-muted-foreground">{companion.name}</p>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="grid gap-3">
{/* Feed Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('feed'))}
disabled={isDisabled}
>
<Utensils className="size-5 text-orange-500" />
<div className="text-left">
<p className="font-medium">Feed</p>
<p className="text-xs text-muted-foreground">
Give your Blobbi something to eat
</p>
</div>
</Button>
)}
{/* Play Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('play'))}
disabled={isDisabled}
>
<Gamepad2 className="size-5 text-yellow-500" />
<div className="text-left">
<p className="font-medium">Play</p>
<p className="text-xs text-muted-foreground">
Play with toys to make your Blobbi happy
</p>
</div>
</Button>
)}
{/* Clean Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('clean'))}
disabled={isDisabled}
>
<SparklesIcon className="size-5 text-blue-500" />
<div className="text-left">
<p className="font-medium">Clean</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg clean and fresh'
: 'Keep your Blobbi clean and fresh'}
</p>
</div>
</Button>
{/* Medicine Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('medicine'))}
disabled={isDisabled}
>
<Pill className="size-5 text-green-500" />
<div className="text-left">
<p className="font-medium">Medicine</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg healthy'
: 'Heal your Blobbi'}
</p>
</div>
</Button>
{/* Play Music Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('play_music'))}
disabled={isDisabled}
>
<Music className="size-5 text-pink-500" />
<div className="text-left">
<p className="font-medium">Play Music</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Play soothing music for your egg'
: 'Play music for your Blobbi'}
</p>
</div>
</Button>
{/* Sing Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('sing'))}
disabled={isDisabled}
>
<Mic className="size-5 text-purple-500" />
<div className="text-left">
<p className="font-medium">Sing</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Sing a lullaby to your egg'
: 'Sing to your Blobbi'}
</p>
</div>
</Button>
{/* Sleep/Wake Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(onRest)}
disabled={isDisabled}
>
{actionInProgress === 'rest' ? (
<Loader2 className="size-5 animate-spin" />
) : isSleeping ? (
<Sun className="size-5 text-amber-500" />
) : (
<Moon className="size-5 text-violet-500" />
)}
<div className="text-left">
<p className="font-medium">{isSleeping ? 'Wake Up' : 'Sleep'}</p>
<p className="text-xs text-muted-foreground">
{isSleeping ? 'Wake your Blobbi up' : 'Put your Blobbi to sleep'}
</p>
</div>
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,538 +0,0 @@
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
/**
* 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 {
Loader2,
XCircle,
AlertTriangle,
X,
Eye,
Scroll,
Compass,
HelpCircle,
ChevronDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
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,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useState } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
import { TasksPanel } from './TasksPanel';
import { DailyMissionsPanel } from './DailyMissionsPanel';
import { useDailyMissions } from '../hooks/useDailyMissions';
import { useRerollMission } from '../hooks/useRerollMission';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
hatchTasks: HatchTasksResult;
evolveTasks: EvolveTasksResult;
onOpenPostModal: () => void;
onHatch: () => void;
isHatching: boolean;
onEvolve: () => void;
isEvolving: boolean;
onStopIncubation: () => Promise<void>;
isStoppingIncubation: boolean;
onStopEvolution: () => Promise<void>;
isStoppingEvolution: boolean;
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 ───────────────────────────────────────────────────
interface DailyMissionsSectionProps {
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({
availableStages,
disabled,
defaultOpen = true,
}: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayXp,
allComplete,
bonusUnlocked,
bonusXp,
noMissionsAvailable,
rerollsRemaining,
} = useDailyMissions({ availableStages });
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const completedCount = missions.filter((m) => m.complete).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<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">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="tabular-nums">
{completedCount} / {missions.length}
</span>
{allComplete && (
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
</span>
)}
</div>
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
<DailyMissionsPanel
missions={missions}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayXp={todayXp}
disabled={disabled || isRerolling}
bonusUnlocked={bonusUnlocked}
bonusXp={bonusXp}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
interface StopConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companionName: string;
processType: 'incubation' | 'evolution';
onConfirm: () => Promise<void>;
isPending: boolean;
}
function StopConfirmationDialog({
open,
onOpenChange,
companionName,
processType,
onConfirm,
isPending,
}: StopConfirmationDialogProps) {
const handleConfirm = async () => {
await onConfirm();
onOpenChange(false);
};
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Stop {label}?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
<strong>{companionName}</strong>?
</p>
<p>
This will interrupt the {processType} process and clear all task progress.
You can restart {processType} later, but you'll need to complete the tasks again.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending}
className="bg-destructive hover:bg-destructive/90"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
`Stop ${label}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
interface CurrentFocusSectionProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
onOpenPostModal: () => void;
onComplete: () => void;
isCompleting: boolean;
onStop: () => Promise<void>;
isStopping: boolean;
defaultOpen?: boolean;
}
function CurrentFocusSection({
companion,
tasks,
processType,
onOpenPostModal,
onComplete,
isCompleting,
onStop,
isStopping,
defaultOpen = true,
}: CurrentFocusSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const isIncubation = processType === 'incubation';
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
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 totalTasks = tasks.tasks.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<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 tabular-nums',
tasks.allCompleted
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)}
>
{completedCount} / {totalTasks}
</span>
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
<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 — 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>
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
companionName={companion.name}
processType={processType}
onConfirm={onStop}
isPending={isStopping}
/>
</Collapsible>
);
}
// ─── 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({
open,
onOpenChange,
companion,
hatchTasks,
evolveTasks,
onOpenPostModal,
onHatch,
isHatching,
onEvolve,
isEvolving,
onStopIncubation,
isStoppingIncubation,
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';
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">
{/* ── 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>
</div>
</div>
{/* ── 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 ? (
<CurrentFocusSection
companion={companion}
tasks={hatchTasks}
processType="incubation"
onOpenPostModal={onOpenPostModal}
onComplete={onHatch}
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
/>
) : isEvolvingState && isBaby ? (
<CurrentFocusSection
companion={companion}
tasks={evolveTasks}
processType="evolution"
onOpenPostModal={onOpenPostModal}
onComplete={onEvolve}
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
/>
) : null}
</>
) : (
<EmptyFocusState />
)}
{/* Divider */}
<div className="h-px bg-border/60" />
{/* 2. Daily Bounties */}
<DailyMissionsSection
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>
</Dialog>
);
}
@@ -1,294 +0,0 @@
// src/blobbi/actions/components/BlobbiPostModal.tsx
/**
* Modal for creating a Blobbi post (hatch or evolve).
*
* Requirements:
* - Prefilled with stage-aware text:
* - Hatch: "Hello Nostr! Posting to hatch #<blobbiName> #blobbi #ditto #nostr"
* - Evolve: "Hello Nostr! Posting to evolve #<blobbiName> #blobbi #ditto #nostr"
* - User can ADD text but CANNOT delete the prefix or required hashtags
* - Blobbi name is sanitized into a valid hashtag format
* - Enforced programmatically
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { X, Loader2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
BLOBBI_POST_REQUIRED_HASHTAGS,
buildHatchPhrase,
} from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The process type for the post */
export type BlobbiPostProcess = 'hatch' | 'evolve';
interface BlobbiPostModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's name (will be converted to hashtag) */
blobbiName: string;
/** The process type - 'hatch' for incubation, 'evolve' for evolution */
process?: BlobbiPostProcess;
onSuccess?: () => void;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Build the required prefix text based on process type.
*/
function buildPrefix(process: BlobbiPostProcess): string {
return process === 'evolve'
? 'Posting to evolve'
: 'Posting to hatch';
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function BlobbiPostModal({
open,
onOpenChange,
blobbiName,
process = 'hatch',
onSuccess,
}: BlobbiPostModalProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending } = useNostrPublish();
// Compute the required elements based on props
const prefix = useMemo(() => buildPrefix(process), [process]);
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
// 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 (the phrase itself is enough)
const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]);
const [content, setContent] = useState(defaultContent);
const [validationError, setValidationError] = useState<string | null>(null);
// Reset content when modal opens or props change
useEffect(() => {
if (open) {
setContent(defaultContent);
setValidationError(null);
}
}, [open, defaultContent]);
/**
* Validate that the content contains the required phrase.
*/
const validateContent = useCallback((text: string): string | null => {
if (!text.includes(requiredPhrase)) {
return `The post must contain: "${requiredPhrase}"`;
}
return null;
}, [requiredPhrase]);
/**
* Handle content change with validation.
* Prevents deletion of required content.
*/
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value;
// Allow content changes only if it preserves the required elements
const error = validateContent(newContent);
if (error) {
setValidationError(error);
// Still update content but show error
// This allows the user to see what they're trying to do
// but the post button will be disabled
} else {
setValidationError(null);
}
setContent(newContent);
}, [validateContent]);
/**
* Handle post creation.
*/
const handlePost = useCallback(async () => {
if (!user?.pubkey) {
toast({
title: 'Not logged in',
description: 'Please log in to create a post',
variant: 'destructive',
});
return;
}
// Final validation
const error = validateContent(content);
if (error) {
setValidationError(error);
return;
}
try {
// Build tags for the post: extract all hashtags from content
const tags: string[][] = [];
const seen = new Set<string>();
// 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 from the content
const contentHashtags = content.match(/#(\w+)/g) || [];
for (const tag of contentHashtags) {
const tagValue = tag.slice(1).toLowerCase();
if (!seen.has(tagValue)) {
tags.push(['t', tagValue]);
seen.add(tagValue);
}
}
await createEvent({
kind: 1,
content,
tags,
});
toast({
title: 'Post created!',
description: process === 'evolve'
? 'Your Blobbi evolution post has been published.'
: 'Your Blobbi hatch post has been published.',
});
onOpenChange(false);
onSuccess?.();
} catch (error) {
toast({
title: 'Failed to create post',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
const canPost = !validationError && content.trim().length > 0;
const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post';
const alertText = process === 'evolve'
? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post."
: "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post.";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0">
{/* Header */}
<div className="flex items-center justify-between px-4 h-14 border-b">
<DialogTitle className="text-base font-semibold">
{dialogTitle}
</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Info alert */}
<Alert className="border-primary/20 bg-primary/5">
<AlertDescription className="text-sm">
{alertText}
</AlertDescription>
</Alert>
{/* Textarea */}
<div className="space-y-2">
<Textarea
value={content}
onChange={handleContentChange}
placeholder="Write your post..."
className="min-h-[150px] resize-none"
disabled={isPending}
/>
{/* Character count and validation */}
<div className="flex items-center justify-between text-sm">
<div>
{validationError && (
<span className="text-destructive flex items-center gap-1">
<AlertCircle className="size-3.5" />
{validationError}
</span>
)}
</div>
<span className="text-muted-foreground">
{content.length} characters
</span>
</div>
</div>
{/* Preview of required content */}
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
<p className="text-xs text-muted-foreground mb-1">Required phrase:</p>
<p className="text-sm font-medium text-primary">
{requiredPhrase}
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/30">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handlePost}
disabled={!canPost || isPending}
className="min-w-24"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Posting...
</>
) : (
'Post'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,284 +0,0 @@
/**
* 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 and reroll.
* Only one card expanded at a time.
* Completion is implicit (derived from progress vs target).
*/
import { useState } from 'react';
import {
Check,
Sparkles,
Gift,
Egg,
Trophy,
RefreshCw,
Heart,
Utensils,
Droplets,
Moon,
Camera,
Mic,
Music,
Pill,
CircleDot,
Zap,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMissionAction } from '../lib/daily-missions';
import type { DailyMissionView } from '../hooks/useDailyMissions';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
missions: DailyMissionView[];
onRerollMission?: (missionId: string) => void;
todayXp: number;
disabled?: boolean;
bonusUnlocked?: boolean;
bonusXp?: number;
noMissionsAvailable?: boolean;
rerollsRemaining?: number;
isRerolling?: boolean;
}
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
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} />;
}
}
// ─── Bonus Card ───────────────────────────────────────────────────────────────
interface BonusCardProps {
isUnlocked: boolean;
xp: number;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusCard({ isUnlocked, xp, isExpanded, onToggle }: BonusCardProps) {
return (
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isUnlocked}
progress={isUnlocked ? 1 : 0}
isExpanded={isExpanded}
onToggle={onToggle}
>
<MissionDescription>
{isUnlocked
? 'Bonus XP for completing all daily missions!'
: 'Complete all missions to unlock this bonus'}
</MissionDescription>
<div className="flex items-center gap-1 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
+{formatCompactNumber(xp)} XP
</div>
</ExpandableMissionCard>
);
}
// ─── Empty / Done States ──────────────────────────────────────────────────────
function NoMissionsState() {
return (
<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>
);
}
function AllCompleteState({ todayXp }: { todayXp: number }) {
return (
<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-violet-600 dark:text-violet-400">
{formatCompactNumber(todayXp)} XP
</span>{' '}
come back tomorrow!
</p>
</div>
</div>
);
}
// ─── Reroll Counter ───────────────────────────────────────────────────────────
function RerollCounter({ remaining }: { remaining: number }) {
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 text-[11px] text-muted-foreground col-span-full">
<RefreshCw className="size-2.5" />
<span>{text}</span>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function DailyMissionsPanel({
missions,
onRerollMission,
todayXp,
disabled,
bonusUnlocked = false,
bonusXp = 50,
noMissionsAvailable = false,
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
if (noMissionsAvailable) return <NoMissionsState />;
const allComplete = missions.every((m) => m.complete);
if (allComplete && bonusUnlocked) return <AllCompleteState todayXp={todayXp} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<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 progressFrac = mission.target > 0 ? mission.progress / mission.target : 0;
const showReroll = onRerollMission && !mission.complete && canReroll;
return (
<ExpandableMissionCard
key={mission.id}
id={mission.id}
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.complete}
progress={Math.min(progressFrac, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
{/* Description */}
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.complete && (
<MissionProgress
current={mission.progress}
required={mission.target}
completed={mission.complete}
/>
)}
{/* XP + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
{formatCompactNumber(mission.xp)} XP
</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.complete && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
</span>
)}
</div>
{/* Complete indicator */}
{mission.complete && (
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<Gift className="size-3.5" />
+{formatCompactNumber(mission.xp)} XP earned
</div>
)}
</ExpandableMissionCard>
);
})}
{/* Bonus card */}
<BonusCard
isUnlocked={bonusUnlocked}
xp={bonusXp}
isExpanded={expandedId === 'bonus'}
onToggle={handleToggle}
/>
</div>
);
}
@@ -1,250 +0,0 @@
// 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>
);
}
@@ -1,221 +0,0 @@
// src/blobbi/actions/components/HatchTasksPanel.tsx
/**
* UI component for displaying hatch task progress.
* Shows a list of tasks with progress indicators and action buttons.
*/
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { 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';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HatchTasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching?: boolean;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
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 items-center gap-4 p-4 rounded-xl border transition-all",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Status indicator */}
<div className={cn(
"size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-5" />
) : task.required > 1 ? (
<span className="text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className={cn(
"font-medium",
task.completed && "text-emerald-600 dark:text-emerald-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs">
Complete
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{task.description}
</p>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !task.completed && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
</div>
{/* Action button */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2"
>
{task.actionLabel}
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
</Button>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HatchTasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onHatch,
isHatching = false,
}: HatchTasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = Math.round((completedCount / totalTasks) * 100);
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🥚</span>
Hatch Tasks
</CardTitle>
<CardDescription>
Complete these tasks to hatch your Blobbi
</CardDescription>
</div>
<Badge variant="outline" className="text-base px-3 py-1">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Overall progress</span>
<span className="font-medium">{overallProgress}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{tasks.map(task => (
<TaskRow
key={task.id}
task={task}
onOpenPostModal={onOpenPostModal}
/>
))}
{/* Hatch button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<Button
onClick={onHatch}
disabled={isHatching}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
{isHatching ? (
<>
<Loader2 className="size-5 animate-spin" />
Hatching...
</>
) : (
<>
<span className="text-xl">🐣</span>
Hatch Your Blobbi!
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -1,251 +0,0 @@
// src/blobbi/actions/components/InlineMusicPlayer.tsx
import { useCallback, useEffect } from 'react';
import { Music, Play, Pause, RotateCcw, MoreHorizontal, Loader2, AlertCircle, X, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import type { SelectedTrack } from './PlayMusicModal';
// Re-export for external use
export type { SelectedTrack } from './PlayMusicModal';
interface InlineMusicPlayerProps {
/** The selected track */
selection: SelectedTrack;
/** Called when user wants to change the track */
onChangeTrack: () => void;
/** Called when user closes the player */
onClose: () => void;
/** Called when playback starts (for Blobbi reaction state) */
onPlaybackStart?: () => void;
/** Called when playback stops/pauses (for Blobbi reaction state) */
onPlaybackStop?: () => void;
/** Whether the action has been published (playback only starts after publish) */
isPublished: boolean;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineMusicPlayer({
selection,
onChangeTrack,
onClose,
onPlaybackStart,
onPlaybackStop,
isPublished,
isPublishing,
}: InlineMusicPlayerProps) {
const {
state: playbackState,
error: playbackError,
load,
toggle,
restart,
stop,
isPlaying,
volume,
setVolume,
cleanup,
} = useAudioPlayback({
onEnded: () => {
onPlaybackStop?.();
},
});
// Auto-start playback when first published (idle -> playing)
// Note: 'stopped' state is NOT included here - stop is a terminal state
// that requires explicit user action (play button) to restart
useEffect(() => {
if (isPublished && playbackState === 'idle') {
load(selection.url, true);
onPlaybackStart?.();
}
}, [isPublished, playbackState, selection.url, load, onPlaybackStart]);
// Force reload when source URL changes while already playing/paused
useEffect(() => {
// Only trigger reload if we're in an active playback state with a different URL
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
// The load function will check if URL changed and reload if needed
load(selection.url, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to selection.url changes
}, [selection.url]);
// Notify on playback state changes
useEffect(() => {
if (isPlaying) {
onPlaybackStart?.();
} else if (playbackState === 'paused' || playbackState === 'stopped') {
onPlaybackStop?.();
}
}, [isPlaying, playbackState, onPlaybackStart, onPlaybackStop]);
// Cleanup on close
const handleClose = useCallback(() => {
stop();
cleanup();
onPlaybackStop?.();
onClose();
}, [stop, cleanup, onPlaybackStop, onClose]);
// Handle play/pause toggle
const handleToggle = useCallback(async () => {
if (playbackState === 'idle' || playbackState === 'stopped') {
load(selection.url, true);
} else {
await toggle();
}
}, [playbackState, selection.url, load, toggle]);
// Track info
const trackTitle = selection.track.title;
const trackArtist = selection.track.artist;
const isLoading = playbackState === 'loading' || isPublishing;
const hasError = playbackState === 'error';
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isPlaying && "ring-2 ring-pink-500/30"
)}>
{/* Main content row */}
<div className="flex items-center gap-3 p-3">
{/* Music icon / Now Playing indicator */}
<div className={cn(
"size-10 rounded-lg flex items-center justify-center shrink-0",
isPlaying
? "bg-pink-500/20"
: "bg-muted"
)}>
<Music className={cn(
"size-5",
isPlaying ? "text-pink-500 animate-pulse" : "text-muted-foreground"
)} />
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{trackTitle}</p>
{trackArtist && (
<p className="text-xs text-muted-foreground truncate">{trackArtist}</p>
)}
{!trackArtist && (
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : isPublishing ? 'Starting...' : 'Ready to play'}
</p>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink-0">
{/* Play/Pause button */}
<Button
size="icon"
variant="ghost"
onClick={handleToggle}
disabled={isLoading || !isPublished}
className="size-9 rounded-full"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
{/* Restart button - only show when actively playing or paused */}
{isPublished && (playbackState === 'playing' || playbackState === 'paused') && (
<Button
size="icon"
variant="ghost"
onClick={() => {
restart();
}}
className="size-9 rounded-full"
title="Restart from beginning"
>
<RotateCcw className="size-3.5" />
</Button>
)}
{/* Volume control */}
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-9 rounded-full"
title={volume === 0 ? 'Unmute' : 'Volume'}
>
{volume === 0 ? (
<VolumeX className="size-4" />
) : (
<Volume2 className="size-4" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="center"
className="w-32 p-3"
>
<Slider
value={[volume * 100]}
onValueChange={([val]) => setVolume(val / 100)}
max={100}
step={1}
className="w-full"
/>
</PopoverContent>
</Popover>
{/* Change track button */}
<Button
size="icon"
variant="ghost"
onClick={onChangeTrack}
disabled={isPublishing}
className="size-9 rounded-full"
>
<MoreHorizontal className="size-4" />
</Button>
{/* Close button */}
<Button
size="icon"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="size-9 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
</div>
</div>
{/* Error message */}
{hasError && playbackError && (
<div className="px-3 pb-3">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{playbackError.message}</p>
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,487 +0,0 @@
// src/blobbi/actions/components/InlineSingCard.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import {
Mic,
Play,
Pause,
Square,
FileText,
Check,
X,
Loader2,
AlertCircle,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'error';
interface InlineSingCardProps {
/** Called when user confirms the singing action (publish the action) */
onConfirm: () => Promise<void>;
/** Called when user closes the sing card */
onClose: () => void;
/** Called when recording starts (for Blobbi reaction) */
onRecordingStart?: () => void;
/** Called when recording stops (for Blobbi reaction) */
onRecordingStop?: () => void;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── MIME Type Selection ──────────────────────────────────────────────────────
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineSingCard({
onConfirm,
onClose,
onRecordingStart,
onRecordingStop,
isPublishing,
}: InlineSingCardProps) {
// Recording state
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [recordingError, setRecordingError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
// Lyrics state
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
// Refs
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Audio playback for preview
const {
state: playbackState,
error: playbackError,
load: loadAudio,
toggle: togglePlayback,
stop: stopPlayback,
isPlaying,
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
try {
mediaRecorderRef.current.stop();
} catch {
// Ignore errors during cleanup
}
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Cleanup playback
cleanupPlayback();
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl, cleanupPlayback]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, [cleanupAll]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
setRecordingState('idle');
setRecordingError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
actualMimeTypeRef.current = undefined;
// Keep lyrics
}, [cleanupAll]);
// Check browser support
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setRecordingError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setRecordingError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get supported MIME type
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
mediaRecorder = new MediaRecorder(stream);
}
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setRecordingError('Recording failed. Please try again.');
setRecordingState('error');
};
mediaRecorder.start(100);
setRecordingState('recording');
setRecordingDuration(0);
// Notify parent that recording started (for Blobbi reaction)
onRecordingStart?.();
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setRecordingError('Microphone access was denied.');
} else if (err.name === 'NotFoundError') {
setRecordingError('No microphone found.');
} else {
setRecordingError(err.message);
}
} else {
setRecordingError('Failed to access microphone.');
}
setRecordingState('error');
}
}, [onRecordingStart]);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
// Notify parent that recording stopped (for Blobbi reaction)
onRecordingStop?.();
}, [onRecordingStop]);
// Handle preview playback
const handlePreview = useCallback(() => {
if (!audioUrl) return;
if (playbackState === 'idle') {
loadAudio(audioUrl, true);
} else {
togglePlayback();
}
}, [audioUrl, playbackState, loadAudio, togglePlayback]);
// Handle confirm
const handleConfirm = useCallback(async () => {
stopPlayback();
await onConfirm();
// After successful publish, close the card
onClose();
}, [stopPlayback, onConfirm, onClose]);
// Handle close
const handleClose = useCallback(() => {
cleanupAll();
onClose();
}, [cleanupAll, onClose]);
// Handle lyrics toggle
const handleLyricsToggle = useCallback(() => {
if (!currentLyrics && !showLyrics) {
// Generate lyrics on first open
setCurrentLyrics(getRandomLyrics());
}
setShowLyrics(!showLyrics);
}, [currentLyrics, showLyrics]);
// Get new lyrics
const handleNewLyrics = useCallback(() => {
setCurrentLyrics(getRandomLyrics());
}, []);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded';
const isRecording = recordingState === 'recording';
const canConfirm = hasRecording && !isPublishing;
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isRecording && "ring-2 ring-red-500/30"
)}>
{/* Lyrics panel (expands upward visually by being above controls) */}
{showLyrics && currentLyrics && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{currentLyrics.title}</span>
<Button
size="icon"
variant="ghost"
onClick={handleNewLyrics}
className="size-7 rounded-full"
>
<RefreshCw className="size-3" />
</Button>
</div>
<div className="p-3 rounded-lg bg-muted/50 text-sm leading-relaxed whitespace-pre-line max-h-32 overflow-y-auto">
{currentLyrics.lines.join('\n')}
</div>
</div>
)}
{/* Status row (recording/recorded info) */}
{(isRecording || hasRecording) && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-center gap-2">
{isRecording && (
<>
<div className="size-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-sm font-mono font-medium text-red-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recording...</span>
</>
)}
{hasRecording && !isRecording && (
<>
<Check className="size-4 text-purple-500" />
<span className="text-sm font-mono font-medium text-purple-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recorded</span>
</>
)}
</div>
</div>
)}
{/* Error message */}
{(recordingError || playbackError) && (
<div className="px-3 pt-2">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{recordingError || playbackError?.message}</p>
</div>
</div>
)}
{/* Main controls row */}
<div className="flex items-center justify-between gap-2 p-3">
{/* Left: Lyrics button */}
<Button
size="icon"
variant={showLyrics ? "secondary" : "ghost"}
onClick={handleLyricsToggle}
className="size-10 rounded-full shrink-0"
>
<FileText className="size-4" />
</Button>
{/* Center: Record/Stop button */}
<div className="flex items-center gap-2">
{!isRecording && !hasRecording && (
<Button
onClick={startRecording}
disabled={isPublishing}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-4 mr-2" />
Sing
</Button>
)}
{isRecording && (
<Button
onClick={stopRecording}
variant="destructive"
className="rounded-full px-6"
>
<Square className="size-4 mr-2" />
Stop
</Button>
)}
{hasRecording && !isRecording && (
<>
<Button
onClick={resetRecording}
variant="outline"
size="icon"
className="size-10 rounded-full"
>
<RefreshCw className="size-4" />
</Button>
<Button
onClick={handleConfirm}
disabled={!canConfirm}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
{isPublishing ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Check className="size-4 mr-2" />
)}
{isPublishing ? 'Singing...' : 'Sing for Blobbi'}
</Button>
</>
)}
</div>
{/* Right: Preview button (when recording exists) */}
{hasRecording ? (
<Button
size="icon"
variant="ghost"
onClick={handlePreview}
disabled={isPublishing}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
) : (
/* Close button when no recording */
<Button
size="icon"
variant="ghost"
onClick={handleClose}
className="size-10 rounded-full shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
)}
</div>
{/* Close button row when recording exists */}
{hasRecording && (
<div className="px-3 pb-3 pt-0 flex justify-end">
<Button
size="sm"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3 mr-1" />
Cancel
</Button>
</div>
)}
</div>
</div>
);
}
@@ -1,301 +0,0 @@
// src/blobbi/actions/components/PlayMusicModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Music, Play, Pause, Check, Loader2, Volume2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
getAllTracks,
formatTrackDuration,
type BlobbiTrack,
} from '../lib/blobbi-track-catalog';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Selected track for the music player
*/
export interface SelectedTrack {
track: BlobbiTrack;
url: string;
}
interface PlayMusicModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with the selected track when user confirms */
onConfirm: (selection: SelectedTrack) => void;
isLoading: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function PlayMusicModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: PlayMusicModalProps) {
const [selectedTrack, setSelectedTrack] = useState<SelectedTrack | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Track the current audio source URL to detect changes
const currentAudioUrlRef = useRef<string | null>(null);
const tracks = getAllTracks();
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
setSelectedTrack(null);
setIsPlaying(false);
setError(null);
currentAudioUrlRef.current = null;
}
}, [open]);
// Handle selecting a track
const handleSelectTrack = useCallback((track: BlobbiTrack) => {
// Stop current playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
setSelectedTrack({ track, url: track.url });
setError(null);
}, []);
// Handle play/pause preview
const handleTogglePlay = useCallback(() => {
if (!selectedTrack) return;
const audioUrl = selectedTrack.url;
// Check if we need to create a new Audio instance (source changed or first time)
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Stop and cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the correct source
audioRef.current = new Audio(audioUrl);
currentAudioUrlRef.current = audioUrl;
audioRef.current.onended = () => setIsPlaying(false);
audioRef.current.onerror = () => {
setError('Failed to load this track. Please try another one.');
setIsPlaying(false);
};
}
if (isPlaying && !needsNewAudio) {
// Pause current playback
audioRef.current?.pause();
setIsPlaying(false);
} else {
// Start playback (either new source or resuming)
audioRef.current?.play().catch(() => {
setError('Failed to play this track. Please try another one.');
setIsPlaying(false);
});
setIsPlaying(true);
}
}, [selectedTrack, isPlaying]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (!selectedTrack) return;
// Stop playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onConfirm(selectedTrack);
}, [selectedTrack, onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen && audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onOpenChange(isOpen);
}, [onOpenChange]);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-pink-500/20 to-pink-500/5 flex items-center justify-center">
<Music className="size-5 text-pink-500" />
</div>
<div>
<DialogTitle className="text-xl">Play Music</DialogTitle>
<p className="text-sm text-muted-foreground">
Choose a track to play for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content - Track List */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="grid gap-2">
{tracks.map((track) => (
<TrackRow
key={track.id}
track={track}
isSelected={selectedTrack?.track.id === track.id}
onSelect={() => handleSelectTrack(track)}
/>
))}
</div>
{error && (
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{error}</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
{/* Preview Controls */}
{selectedTrack && (
<div className="mb-4 p-3 rounded-lg bg-card border">
<div className="flex items-center gap-3">
<Button
size="icon"
variant="outline"
onClick={handleTogglePlay}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate text-sm">{selectedTrack.track.title}</p>
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : 'Click to preview'}
</p>
</div>
{isPlaying && (
<Volume2 className="size-4 text-primary animate-pulse shrink-0" />
)}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedTrack || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Playing...
</>
) : (
<>
<Music className="size-4 mr-2" />
Play for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Track Row Component ──────────────────────────────────────────────────────
interface TrackRowProps {
track: BlobbiTrack;
isSelected: boolean;
onSelect: () => void;
}
function TrackRow({ track, isSelected, onSelect }: TrackRowProps) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"w-full p-3 rounded-xl text-left transition-all",
"border hover:border-primary/30",
isSelected
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border bg-card/60"
)}
>
<div className="flex items-center gap-3">
<div className={cn(
"size-10 rounded-lg flex items-center justify-center",
isSelected ? "bg-primary/20" : "bg-muted"
)}>
<Music className={cn(
"size-5",
isSelected ? "text-primary" : "text-muted-foreground"
)} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm text-muted-foreground">
{formatTrackDuration(track.durationSeconds)}
</span>
{isSelected && <Check className="size-4 text-primary" />}
</div>
</div>
</button>
);
}
-601
View File
@@ -1,601 +0,0 @@
// src/blobbi/actions/components/SingModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isLoading: boolean;
}
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
/**
* Ordered list of MIME types to try for audio recording.
* The first supported type will be used.
*/
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
/**
* Get the first supported MIME type for MediaRecorder.
* Returns undefined if no explicit MIME type is supported (let browser decide).
*/
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
// No explicit MIME type supported, let browser use default
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function SingModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: SingModalProps) {
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [error, setError] = useState<string | null>(null);
const [playbackError, setPlaybackError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Stop audio playback
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl]);
const resetRecording = useCallback(() => {
cleanup();
setRecordingState('idle');
setError(null);
setPlaybackError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
currentPlaybackUrlRef.current = null;
actualMimeTypeRef.current = undefined;
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open, cleanup, resetRecording]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
setCurrentLyrics(lyrics);
setShowLyrics(true);
}, []);
// Check if browser supports media recording
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setError(null);
setPlaybackError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get the first supported MIME type using our helper
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder with or without explicit MIME type
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
// Let browser choose default MIME type
mediaRecorder = new MediaRecorder(stream);
}
// Store the actual MIME type being used (may differ from what we requested)
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
// Create blob from chunks using the actual MIME type used by the recorder
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setError('Recording failed. Please try again.');
setRecordingState('error');
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setRecordingState('recording');
setRecordingDuration(0);
// Start timer
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('Microphone access was denied. Please allow microphone access and try again.');
} else if (err.name === 'NotFoundError') {
setError('No microphone found. Please connect a microphone and try again.');
} else {
setError(`Failed to access microphone: ${err.message}`);
}
} else {
setError('Failed to access microphone. Please try again.');
}
setRecordingState('error');
}
}, []);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
}, []);
// Track the current audio URL to detect changes
const currentPlaybackUrlRef = useRef<string | null>(null);
// Play/pause preview
const togglePlayback = useCallback(() => {
if (!audioUrl) return;
// Clear previous playback error when attempting to play
setPlaybackError(null);
if (recordingState === 'playing') {
if (audioRef.current) {
audioRef.current.pause();
}
setRecordingState('recorded');
} else {
// Check if we need to create a new Audio instance (URL changed or first time)
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the recorded audio URL
audioRef.current = new Audio(audioUrl);
currentPlaybackUrlRef.current = audioUrl;
audioRef.current.onended = () => setRecordingState('recorded');
// Handle playback errors with user-visible message
audioRef.current.onerror = () => {
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
setRecordingState('recorded');
};
}
audioRef.current?.play()
.then(() => {
setRecordingState('playing');
})
.catch((err) => {
console.error('Failed to play recording:', err);
// Provide user-friendly error message
if (err.name === 'NotSupportedError') {
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
} else if (err.name === 'NotAllowedError') {
setPlaybackError('Playback was blocked. Try interacting with the page first.');
} else {
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
}
setRecordingState('recorded');
});
}
}, [audioUrl, recordingState]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
}
onConfirm();
}, [onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen) {
cleanup();
}
onOpenChange(isOpen);
}, [onOpenChange, cleanup]);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
<Mic className="size-5 text-purple-500" />
</div>
<div>
<DialogTitle className="text-xl">Sing</DialogTitle>
<p className="text-sm text-muted-foreground">
Record yourself singing for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 px-6 py-8">
<div className="flex flex-col items-center justify-center gap-6">
{/* Recording Visualization */}
<div className={cn(
"relative size-40 rounded-full flex items-center justify-center transition-all",
recordingState === 'recording' && "animate-pulse",
recordingState === 'recording'
? "bg-red-500/10 ring-4 ring-red-500/30"
: hasRecording
? "bg-purple-500/10 ring-4 ring-purple-500/30"
: "bg-muted"
)}>
{/* Animated rings for recording */}
{recordingState === 'recording' && (
<>
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
</>
)}
{/* Icon */}
<div className={cn(
"relative size-20 rounded-full flex items-center justify-center",
recordingState === 'recording'
? "bg-red-500 text-white"
: hasRecording
? "bg-purple-500 text-white"
: "bg-muted-foreground/20"
)}>
{recordingState === 'requesting' ? (
<Loader2 className="size-8 animate-spin" />
) : recordingState === 'recording' ? (
<Mic className="size-8" />
) : hasRecording ? (
recordingState === 'playing' ? (
<Pause className="size-8" />
) : (
<Play className="size-8 ml-1" />
)
) : (
<MicOff className="size-8 text-muted-foreground" />
)}
</div>
</div>
{/* Duration / Status */}
<div className="text-center">
{recordingState === 'idle' && (
<p className="text-muted-foreground">Tap the button below to start recording</p>
)}
{recordingState === 'requesting' && (
<p className="text-muted-foreground">Requesting microphone access...</p>
)}
{recordingState === 'recording' && (
<>
<p className="text-3xl font-mono font-bold text-red-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
</>
)}
{hasRecording && (
<>
<p className="text-3xl font-mono font-bold text-purple-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
</p>
</>
)}
{recordingState === 'error' && (
<p className="text-destructive">Recording failed</p>
)}
</div>
{/* Error Message */}
{error && (
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
)}
{/* Playback Error Message (non-fatal, recording still works) */}
{playbackError && (
<div className="w-full p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{playbackError}</p>
</div>
</div>
)}
{/* Lyrics Helper */}
<div className="w-full">
{!currentLyrics ? (
<Button
variant="outline"
size="sm"
onClick={handleRandomLyrics}
className="w-full gap-2"
>
<Sparkles className="size-4" />
Need lyrics? Get random lyrics
</Button>
) : (
<div className="rounded-lg border bg-card/60">
<button
type="button"
onClick={() => setShowLyrics(!showLyrics)}
className="w-full flex items-center justify-between p-3 text-left"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-purple-500" />
<span className="font-medium text-sm">{currentLyrics.title}</span>
</div>
{showLyrics ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</button>
{showLyrics && (
<div className="px-3 pb-3 pt-0">
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
{currentLyrics.lines.join('\n')}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRandomLyrics}
className="w-full mt-2 gap-2 text-muted-foreground"
>
<RotateCcw className="size-3" />
Get different lyrics
</Button>
</div>
)}
</div>
)}
</div>
{/* Recording Controls */}
<div className="flex items-center gap-3">
{recordingState === 'idle' || recordingState === 'error' ? (
<Button
size="lg"
onClick={startRecording}
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-5 mr-2" />
Start Recording
</Button>
) : recordingState === 'recording' ? (
<Button
size="lg"
variant="destructive"
onClick={stopRecording}
className="rounded-full px-8"
>
<Square className="size-5 mr-2" />
Stop
</Button>
) : hasRecording ? (
<>
<Button
size="lg"
variant="outline"
onClick={togglePlayback}
className="rounded-full"
>
{recordingState === 'playing' ? (
<>
<Pause className="size-5 mr-2" />
Pause
</>
) : (
<>
<Play className="size-5 mr-2" />
Preview
</>
)}
</Button>
<Button
size="lg"
variant="ghost"
onClick={resetRecording}
className="rounded-full"
>
<RotateCcw className="size-5 mr-2" />
Re-record
</Button>
</>
) : null}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!hasRecording || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Singing...
</>
) : (
<>
<Mic className="size-4 mr-2" />
Sing for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,125 +0,0 @@
// src/blobbi/actions/components/StartEvolutionDialog.tsx
/**
* Dialog for confirming start of evolution.
*
* Evolution is simpler than incubation:
* - Only baby Blobbis can evolve
* - Shows restart confirmation if already evolving
* - Otherwise shows normal start confirmation
*/
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartEvolutionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start evolving */
companion: BlobbiCompanion | null;
/** Called when confirmed */
onConfirm: () => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartEvolutionDialog({
open,
onOpenChange,
companion,
onConfirm,
isPending,
}: StartEvolutionDialogProps) {
// Check if the current Blobbi is already evolving
const isAlreadyEvolving = companion?.state === 'evolving';
// Determine title and description based on state
const getDialogContent = () => {
if (isAlreadyEvolving) {
return {
title: 'Restart Evolution?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Evolution',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Evolution',
icon: <Sparkles className="size-5 text-primary" />,
description: (
<>
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
Complete all the tasks to evolve your baby Blobbi into an adult!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Evolution',
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,180 +0,0 @@
// src/blobbi/actions/components/StartIncubationDialog.tsx
/**
* Dialog for confirming start of incubation.
*
* Determines the mode and passes it explicitly to the confirm callback:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating)
* - 'switch': Stop another Blobbi first, then start this one
*
* The mode is determined by UI state, NOT auto-detected by the hook.
* This makes the flow explicit and predictable.
*/
import { useMemo } from 'react';
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartIncubationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start incubating */
companion: BlobbiCompanion | null;
/** All companions in the collection (to check for other incubating Blobbis) */
companions?: BlobbiCompanion[];
/** Called with explicit mode and optional stopOtherD when confirmed */
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartIncubationDialog({
open,
onOpenChange,
companion,
companions = [],
onConfirm,
isPending,
}: StartIncubationDialogProps) {
// Check if the current Blobbi is already in a task state
const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving';
// Check if another Blobbi (not this one) is currently incubating
const otherIncubatingBlobbi = useMemo(() => {
if (!companion) return null;
return companions.find(c =>
c.d !== companion.d &&
c.state === 'incubating' &&
c.stage === 'egg'
) ?? null;
}, [companion, companions]);
// Determine the mode based on current state
const mode: StartIncubationMode = useMemo(() => {
if (isAlreadyInTaskState) return 'restart';
if (otherIncubatingBlobbi) return 'switch';
return 'start';
}, [isAlreadyInTaskState, otherIncubatingBlobbi]);
// Handle confirm with explicit mode
const handleConfirm = () => {
if (mode === 'switch' && otherIncubatingBlobbi) {
onConfirm(mode, otherIncubatingBlobbi.d);
} else {
onConfirm(mode);
}
};
// Determine title and description based on mode
const getDialogContent = () => {
if (mode === 'restart') {
return {
title: 'Restart Incubation?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
Your Blobbi is already {companion?.state}. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Incubation',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
if (mode === 'switch') {
return {
title: 'Switch Incubation?',
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
description: (
<>
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
Only one Blobbi can incubate at a time.
<br /><br />
Starting incubation for <strong>{companion?.name}</strong> will{' '}
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
reset their task progress.
<br /><br />
Do you want to switch?
</>
),
buttonText: 'Switch & Start',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Incubation',
icon: null,
description: (
<>
Starting incubation begins your Blobbi's hatching journey.
Complete all the tasks to hatch your egg into a baby Blobbi!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Incubation',
buttonClass: undefined,
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,195 +0,0 @@
// src/blobbi/actions/components/TasksPanel.tsx
/**
* 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 { 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 type { HatchTask } from '../hooks/useHatchTasks';
import type { MissionCategory } from './ExpandableMissionCard';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
MissionAction,
DynamicHint,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface TasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
onOpenPostModal: () => void;
onComplete: () => void;
isCompleting?: boolean;
completeLabel: string;
completingLabel: string;
completeEmoji: string;
/** Mission category for styling the cards */
category?: MissionCategory;
}
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
/** Map task ids to lucide icons. Falls back to a generic icon. */
function TaskIcon({ taskId }: { taskId: string }) {
const iconClass = 'size-5';
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 ───────────────────────────────────────────────────────────
export function TasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onComplete,
isCompleting = false,
completeLabel,
completingLabel,
completeEmoji,
category = 'hatch',
}: TasksPanelProps) {
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 (
<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>
);
}
@@ -1,232 +0,0 @@
// src/blobbi/actions/hooks/useActiveTaskProcess.ts
/**
* Central abstraction for the active task process (hatch or evolve).
*
* This hook consolidates all scattered if/else logic for determining:
* - Which process is active (incubating vs evolving)
* - Which tasks to use (hatch vs evolve)
* - Thresholds and configuration
* - Badge-related computed values
*
* ARCHITECTURE RULES:
* - Computed tasks remain the source of truth
* - Tags are cache only for PERSISTENT tasks
* - Dynamic tasks are NEVER persisted
* - Badge counts ALL incomplete tasks (persistent + dynamic)
*/
import { useMemo } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
import type { EvolveTasksResult } from './useEvolveTasks';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The type of task process currently active */
export type TaskProcessType = 'hatch' | 'evolve' | null;
/**
* Configuration for the active task process.
* This provides a unified interface regardless of whether
* the process is hatch or evolve.
*/
export interface TaskProcessConfig {
/** The type of process ('hatch' | 'evolve' | null) */
type: TaskProcessType;
/** Whether there is an active task process */
isActive: boolean;
/** Required interactions threshold for the current process */
interactionThreshold: number;
}
/**
* Result of the active task process hook.
* Provides unified access to all task-related state.
*/
export interface ActiveTaskProcessResult {
/** Configuration for the current process */
config: TaskProcessConfig;
/** All tasks for the current process (empty if no active process) */
tasks: HatchTask[];
/** Whether tasks are still loading */
isLoading: boolean;
/** Whether all tasks (persistent + dynamic) are complete */
allCompleted: boolean;
/** Whether all persistent tasks are complete */
persistentTasksComplete: boolean;
/** Whether the dynamic task is complete */
dynamicTaskComplete: boolean;
/** Refetch function for current tasks */
refetch: () => void;
// ─── Badge-related computed values ───
/**
* Count of ALL remaining incomplete tasks (persistent + dynamic).
* This is used for the badge display.
* Dynamic tasks ARE counted here but are NEVER synced to tags.
*/
remainingTasksCount: number;
/**
* Only persistent tasks that are incomplete.
* Used for sync logic - dynamic tasks must NEVER be synced.
*/
incompletePersistentTasks: HatchTask[];
/**
* Only persistent tasks that are complete.
* Used for sync logic.
*/
completedPersistentTasks: HatchTask[];
/**
* Stable string key of completed persistent task IDs.
* Used for sync anti-loop protection.
*/
completedPersistentTaskIds: string;
/**
* Tasks to sync (persistent only, with completion status).
* Dynamic tasks are excluded.
*/
tasksToSync: Array<{ taskId: string; completed: boolean }>;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Filter tasks to only persistent tasks.
* Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
/**
* Filter tasks to only dynamic tasks.
*/
export function filterDynamicTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'dynamic');
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook that provides a unified interface for the active task process.
*
* Usage:
* ```ts
* const taskProcess = useActiveTaskProcess(companion, hatchTasks, evolveTasks);
*
* // Access unified data
* taskProcess.config.type // 'hatch' | 'evolve' | null
* taskProcess.tasks // current tasks
* taskProcess.remainingTasksCount // for badge (includes dynamic)
* taskProcess.tasksToSync // for sync (excludes dynamic)
* ```
*/
export function useActiveTaskProcess(
companion: BlobbiCompanion | null,
hatchTasks: HatchTasksResult,
evolveTasks: EvolveTasksResult
): ActiveTaskProcessResult {
// Determine which process is active
const processType = useMemo((): TaskProcessType => {
if (!companion) return null;
if (companion.state === 'incubating') return 'hatch';
if (companion.state === 'evolving') return 'evolve';
return null;
}, [companion]);
// Build configuration
const config = useMemo((): TaskProcessConfig => {
const isActive = processType !== null;
const interactionThreshold = processType === 'hatch'
? HATCH_REQUIRED_INTERACTIONS
: processType === 'evolve'
? EVOLVE_REQUIRED_INTERACTIONS
: 0;
return {
type: processType,
isActive,
interactionThreshold,
};
}, [processType]);
// Get the active tasks result based on process type
const activeResult = useMemo(() => {
if (processType === 'hatch') return hatchTasks;
if (processType === 'evolve') return evolveTasks;
return null;
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
const dynamicTaskComplete = activeResult?.dynamicTaskComplete ?? false;
const refetch = activeResult?.refetch ?? (() => {});
// Compute persistent task list (dynamic tasks computed for badge count directly from tasks array)
const persistentTasks = useMemo(() => filterPersistentTasks(tasks), [tasks]);
// Compute incomplete tasks (for badge - includes BOTH persistent and dynamic)
const remainingTasksCount = useMemo(() => {
// Count ALL incomplete tasks - persistent AND dynamic
// Dynamic tasks are included in badge count but NEVER synced to tags
return tasks.filter(t => !t.completed).length;
}, [tasks]);
// Compute persistent task lists for sync
const incompletePersistentTasks = useMemo(() =>
persistentTasks.filter(t => !t.completed),
[persistentTasks]
);
const completedPersistentTasks = useMemo(() =>
persistentTasks.filter(t => t.completed),
[persistentTasks]
);
// Compute stable string key for completed persistent tasks (anti-loop)
const completedPersistentTaskIds = useMemo(() => {
if (!completedPersistentTasks.length) return '';
return completedPersistentTasks
.map(t => t.id)
.sort()
.join(',');
}, [completedPersistentTasks]);
// Compute tasks to sync (persistent only)
// CRITICAL: Dynamic tasks must NEVER be included here
const tasksToSync = useMemo(() => {
if (!persistentTasks.length) return [];
return persistentTasks.map(t => ({
taskId: t.id,
completed: t.completed,
}));
}, [persistentTasks]);
return {
config,
tasks,
isLoading,
allCompleted,
persistentTasksComplete,
dynamicTaskComplete,
refetch,
remainingTasksCount,
incompletePersistentTasks,
completedPersistentTasks,
completedPersistentTaskIds,
tasksToSync,
};
}
@@ -1,287 +0,0 @@
// src/blobbi/actions/hooks/useAudioPlayback.ts
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* Audio playback state
* - idle: No audio loaded
* - loading: Audio is being loaded
* - playing: Audio is playing
* - paused: Audio is paused (can resume)
* - stopped: Audio was stopped (must reload to play again)
* - error: An error occurred
*/
export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
/**
* Audio playback error info
*/
export interface PlaybackError {
message: string;
code?: string;
}
/** Default volume level (0-1) */
const DEFAULT_VOLUME = 0.8;
/**
* Options for the useAudioPlayback hook
*/
export interface UseAudioPlaybackOptions {
/** Called when playback ends naturally */
onEnded?: () => void;
/** Called when an error occurs */
onError?: (error: PlaybackError) => void;
/** Initial volume level (0-1), defaults to 0.8 */
initialVolume?: number;
}
/**
* Return type for useAudioPlayback hook
*/
export interface UseAudioPlaybackReturn {
/** Current playback state */
state: PlaybackState;
/** Current error (if any) */
error: PlaybackError | null;
/** Current audio URL being played */
currentUrl: string | null;
/** Load and optionally start playing an audio URL */
load: (url: string, autoplay?: boolean) => void;
/** Play the current audio */
play: () => Promise<void>;
/** Pause the current audio */
pause: () => void;
/** Stop playback and reset */
stop: () => void;
/** Restart playback from the beginning */
restart: () => Promise<void>;
/** Toggle play/pause */
toggle: () => Promise<void>;
/** Whether audio is currently playing */
isPlaying: boolean;
/** Current volume level (0-1) */
volume: number;
/** Set volume level (0-1) */
setVolume: (volume: number) => void;
/** Cleanup function to release resources */
cleanup: () => void;
}
/**
* Reusable hook for audio playback.
* Handles Audio element lifecycle, error handling, and state management.
*/
export function useAudioPlayback(options: UseAudioPlaybackOptions = {}): UseAudioPlaybackReturn {
const { onEnded, onError, initialVolume = DEFAULT_VOLUME } = options;
const [state, setState] = useState<PlaybackState>('idle');
const [error, setError] = useState<PlaybackError | null>(null);
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
const [volume, setVolumeState] = useState<number>(initialVolume);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentUrlRef = useRef<string | null>(null);
const volumeRef = useRef<number>(initialVolume);
// Cleanup audio element
const cleanup = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
audioRef.current = null;
}
currentUrlRef.current = null;
setState('idle');
setCurrentUrl(null);
setError(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Load audio from URL
const load = useCallback((url: string, autoplay = false) => {
// If same URL, don't reload
if (currentUrlRef.current === url && audioRef.current) {
if (autoplay) {
audioRef.current.play().catch(() => {});
}
return;
}
// Cleanup previous audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
}
setState('loading');
setError(null);
setCurrentUrl(url);
currentUrlRef.current = url;
const audio = new Audio(url);
audio.volume = volumeRef.current; // Apply current volume to new audio
audioRef.current = audio;
audio.oncanplay = () => {
if (autoplay) {
audio.play().catch((err) => {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err.name,
};
setError(playbackError);
setState('error');
onError?.(playbackError);
});
} else {
setState('paused');
}
};
audio.onplaying = () => {
setState('playing');
};
audio.onpause = () => {
if (state === 'playing') {
setState('paused');
}
};
audio.onended = () => {
setState('paused');
onEnded?.();
};
audio.onerror = () => {
const playbackError: PlaybackError = {
message: 'Failed to load audio. The format may not be supported.',
code: 'MEDIA_ERR',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
};
// Start loading
audio.load();
}, [onEnded, onError, state]);
// Play current audio
const play = useCallback(async () => {
if (!audioRef.current) return;
try {
setError(null);
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Pause current audio
const pause = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
setState('paused');
}, []);
// Stop playback completely (requires reload to play again)
const stop = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
audioRef.current.currentTime = 0;
// Clear URL ref so next load() will actually reload
currentUrlRef.current = null;
setState('stopped');
}, []);
// Restart playback from the beginning
const restart = useCallback(async () => {
if (!audioRef.current) return;
audioRef.current.currentTime = 0;
try {
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Toggle play/pause
const toggle = useCallback(async () => {
if (state === 'playing') {
pause();
} else {
await play();
}
}, [state, play, pause]);
// Set volume (0-1)
const setVolume = useCallback((newVolume: number) => {
const clampedVolume = Math.max(0, Math.min(1, newVolume));
volumeRef.current = clampedVolume;
setVolumeState(clampedVolume);
if (audioRef.current) {
audioRef.current.volume = clampedVolume;
}
}, []);
return {
state,
error,
currentUrl,
load,
play,
pause,
stop,
restart,
toggle,
isPlaying: state === 'playing',
volume,
setVolume,
cleanup,
};
}
/**
* Get a user-friendly error message for playback errors
*/
function getPlaybackErrorMessage(err: unknown): string {
if (err instanceof Error) {
if (err.name === 'NotSupportedError') {
return 'This audio format is not supported by your browser.';
}
if (err.name === 'NotAllowedError') {
return 'Playback was blocked. Try interacting with the page first.';
}
return err.message;
}
return 'An unknown error occurred during playback.';
}
@@ -1,200 +0,0 @@
/**
* useBlobbiCareActivity - Hook for registering care activity and updating streaks
*
* This hook provides a centralized way to register care activity for a Blobbi companion.
* It handles:
* - Calculating streak updates based on the last activity day
* - Publishing updated Blobbi state to Nostr
* - Updating local cache
*
* Use this hook whenever care activity should count toward the streak:
* - Opening the Blobbi page (page check-in)
* - Performing care actions (feed, clean, play, etc.)
* - Any other care interaction
*
* The streak only increments once per calendar day, regardless of how many
* activities are performed.
*/
import { useCallback, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
export interface CareActivityResult {
/** Whether the streak was updated */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** Description of what happened */
action: StreakUpdateResult['action'];
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to register care activity and update streaks.
*
* Returns a function to register activity and a mutation for the actual update.
* The register function is idempotent - calling it multiple times on the same day
* will only update once.
*/
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
}: UseBlobbiCareActivityParams) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
// Track if we've already registered activity this session to avoid duplicate calls
// This is a performance optimization - the actual idempotency is handled by day comparison
const lastRegisteredDay = useRef<string | null>(null);
const mutation = useMutation({
mutationFn: async (): Promise<CareActivityResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to register care activity');
}
if (!companion) {
throw new Error('No companion available');
}
// Fetch fresh companion from relays (read-modify-write pattern)
const freshEvents = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [companion.d],
}]);
const freshCompanion = freshEvents
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at)
.map(e => parseBlobbiEvent(e))
.find(Boolean) ?? companion;
const now = new Date();
// Calculate what the streak update should be using fresh data
const result = calculateStreakUpdate(
freshCompanion.careStreak,
freshCompanion.careStreakLastDay,
now
);
// If no update needed (same day), return early without publishing
if (!result.wasUpdated) {
return {
wasUpdated: false,
newStreak: result.newStreak,
action: result.action,
};
}
// Get the tag updates using fresh data
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: freshCompanion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags from fresh data
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: freshCompanion.event.content,
tags: updatedTags,
});
// Update local cache (optimistic — no invalidation needed)
updateCompanionEvent(event);
// Update session tracker
lastRegisteredDay.current = result.newLastDay;
// Log for debugging (dev only)
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: freshCompanion.careStreak,
newStreak: result.newStreak,
lastDay: freshCompanion.careStreakLastDay,
newDay: result.newLastDay,
});
}
return {
wasUpdated: true,
newStreak: result.newStreak,
action: result.action,
};
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
});
/**
* Register care activity. Call this when care-related activity happens.
* Safe to call multiple times - only updates streak once per day.
*
* @returns Promise with the result of the activity registration
*/
const registerCareActivity = useCallback(async (): Promise<CareActivityResult | null> => {
if (!companion) {
return null;
}
// Quick check if we've already registered for this companion's last day (session cache)
// This is an optimization to avoid unnecessary mutation calls
if (lastRegisteredDay.current === companion.careStreakLastDay) {
// Already processed this day in this session, skip
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
return mutation.mutateAsync();
}, [companion, mutation]);
return {
/** Register care activity - call when page opens or care action happens */
registerCareActivity,
/** Whether an update is currently in progress */
isUpdating: mutation.isPending,
/** The last update result */
lastResult: mutation.data,
/** Any error from the last update attempt */
error: mutation.error,
};
}
@@ -1,211 +0,0 @@
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import {
clampStat,
applyStat,
DIRECT_ACTION_METADATA,
type DirectAction,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } 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 NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Configuration for direct action happiness effects.
* These are the happiness deltas for each direct action.
*/
export const DIRECT_ACTION_HAPPINESS_EFFECTS: Record<DirectAction, number> = {
play_music: 15,
sing: 20,
};
/**
* Request payload for executing a direct action
*/
export interface DirectActionRequest {
action: DirectAction;
}
/**
* Result of executing a direct action
*/
export interface DirectActionResult {
action: DirectAction;
happinessChange: number;
xpGained: number;
newXP: number;
}
/**
* Parameters for the useBlobbiDirectAction hook
*/
export interface UseBlobbiDirectActionParams {
companion: BlobbiCompanion | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Hook to execute a direct action on a Blobbi companion.
* Direct actions (play_music, sing) don't require selecting an item.
* They directly affect happiness stat.
*
* This hook:
* 1. Validates the companion exists
* 2. Ensures canonical format before action
* 3. Applies accumulated decay
* 4. Applies happiness boost
* 5. Updates Blobbi state (kind 31124)
* 6. Invalidates relevant queries
*/
export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to perform actions');
}
if (!companion) {
throw new Error('No companion selected');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
const statsAfterDecay = decayResult.stats;
// ─── Apply Happiness Effect ───
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
// Track if happiness actually changed
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
// Build stats update
const isEgg = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {
happiness: newHappiness.toString(),
health: statsAfterDecay.health.toString(),
hygiene: statsAfterDecay.hygiene.toString(),
};
if (isEgg) {
// Eggs have fixed hunger and energy
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
statsUpdate.hunger = clampStat(statsAfterDecay.hunger).toString();
statsUpdate.energy = clampStat(statsAfterDecay.energy).toString();
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter in evolution missions
const companionState = canonical.companion.state;
const updatedTags = canonical.allTags;
if (companionState === 'incubating' || companionState === 'evolving') {
trackEvolutionMissionTally('interactions', 1, user.pubkey);
}
// 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,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
return {
action,
happinessChange: happinessDelta,
xpGained,
newXP,
};
},
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}! ${xpText}`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'sing') dailyActions.push('sing');
if (action === 'play_music') dailyActions.push('play_music');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Action failed',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -1,742 +0,0 @@
// src/blobbi/actions/hooks/useBlobbiIncubation.ts
/**
* Hooks for Blobbi incubation task system.
*
* When a user starts incubation:
* 1. Apply accumulated decay from last_decay_at to now
* 2. Set state to 'incubating'
* 3. Add state_started_at timestamp
* 4. Update last_decay_at to the same timestamp
* 5. Clear any previous task progress
*
* Tasks are computed from Nostr events with created_at >= state_started_at
*/
import { useMutation } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { createHatchMissions, createEvolveMissions } from '../lib/evolution-missions';
import {
ensureSessionStore,
writeMissionsToStorage,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mode for starting incubation.
* This makes the intent explicit rather than auto-detecting behavior.
*/
export type StartIncubationMode =
| 'start' // Normal start (no other Blobbi incubating)
| 'restart' // Restart same Blobbi (already incubating)
| 'switch'; // Switch from another incubating Blobbi
/**
* Request to start incubation with explicit mode.
*/
export interface StartIncubationRequest {
/** Explicit mode for this operation */
mode: StartIncubationMode;
/** The d-tag of the other Blobbi to stop (required when mode === 'switch') */
stopOtherD?: string;
}
/**
* Parameters for start incubation hook.
*/
export interface UseStartIncubationParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of starting incubation.
*/
export interface StartIncubationResult {
/** The Blobbi's name */
name: string;
/** Timestamp when incubation started */
stateStartedAt: number;
/** Mode that was used */
mode: StartIncubationMode;
/** Name of other Blobbi that was stopped (if mode === 'switch') */
stoppedOtherName?: string;
}
// ─── Start Incubation Hook ────────────────────────────────────────────────────
/**
* Hook to start the incubation process for an egg.
*
* This sets the Blobbi state to 'incubating' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* IMPORTANT: The mode must be explicitly specified by the caller (UI).
* This hook does NOT auto-detect whether to switch or restart.
* The UI dialog determines the mode and passes it explicitly.
*
* Modes:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating), resets task progress
* - 'switch': Stop another Blobbi first, then start this one
*
* Requirements:
* - Blobbi must be in egg stage
* - User must be logged in
*/
export function useStartIncubation({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (request: StartIncubationRequest): Promise<StartIncubationResult> => {
const { mode, stopOtherD } = request;
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be incubated');
}
// Validate switch mode requires stopOtherD
if (mode === 'switch' && !stopOtherD) {
throw new Error('Switch mode requires stopOtherD parameter');
}
let stoppedOtherName: string | undefined;
// ─── Stop Other Incubating Blobbi (switch mode only) ───
if (mode === 'switch' && stopOtherD) {
// Fetch the current event for the other Blobbi
const [otherEvent] = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [stopOtherD],
limit: 1,
}]);
if (otherEvent) {
// Get name from the event for the result
const nameTag = otherEvent.tags.find(t => t[0] === 'name');
stoppedOtherName = nameTag?.[1] ?? stopOtherD;
// Stop the other Blobbi's incubation
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
// Parse stats from the event
const getTagValue = (tags: string[][], name: string): number =>
parseInt(tags.find(t => t[0] === name)?.[1] ?? '50', 10);
const otherStats = {
hunger: getTagValue(otherEvent.tags, 'hunger'),
happiness: getTagValue(otherEvent.tags, 'happiness'),
health: getTagValue(otherEvent.tags, 'health'),
hygiene: getTagValue(otherEvent.tags, 'hygiene'),
energy: getTagValue(otherEvent.tags, 'energy'),
};
const otherLastDecayAt = getTagValue(otherEvent.tags, 'last_decay_at') || now;
// Apply decay to the other Blobbi
const otherDecayResult = applyBlobbiDecay({
stage: 'egg',
state: 'incubating',
stats: otherStats,
lastDecayAt: otherLastDecayAt,
now,
});
// Remove task tags and state_started_at from the other Blobbi
const otherCleanedTags = otherEvent.tags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
const otherNewTags = updateBlobbiTags(otherCleanedTags, {
health: otherDecayResult.stats.health.toString(),
hygiene: otherDecayResult.stats.hygiene.toString(),
happiness: otherDecayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// Publish the stop event for the other Blobbi
const stopEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: otherEvent.content,
tags: otherNewTags,
});
// Update the cache for the stopped Blobbi
updateCompanionEvent(stopEvent);
}
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for incubation');
}
// ─── Apply Accumulated Decay ───
// CRITICAL: Apply decay from last_decay_at to now before changing state
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh (for all modes)
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'incubating',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
// ─── Populate evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: createHatchMissions() },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
return {
name: canonical.companion.name,
stateStartedAt: now,
mode,
stoppedOtherName,
};
},
onSuccess: ({ name, mode, stoppedOtherName }) => {
if (mode === 'switch' && stoppedOtherName) {
toast({
title: 'Switched incubation!',
description: `Stopped ${stoppedOtherName}, now incubating ${name}.`,
});
} else if (mode === 'restart') {
toast({
title: 'Incubation restarted!',
description: `${name}'s task progress has been reset.`,
});
} else {
toast({
title: 'Incubation started!',
description: `${name} is now incubating. Complete the tasks to hatch!`,
});
}
},
onError: (error: Error) => {
toast({
title: 'Failed to start incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Incubation Hook ─────────────────────────────────────────────────────
/**
* Parameters for stop incubation hook.
*/
export interface UseStopIncubationParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of stopping incubation.
*/
export interface StopIncubationResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the incubation process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart incubation later, but will need to complete tasks again.
*
* When stopping incubation:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in incubating state
* - User must be logged in
*/
export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopIncubationResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'incubating') {
throw new Error('This Blobbi is not incubating');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
// ─── Clear evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: [] },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
return {
name: canonical.companion.name,
};
},
});
}
// ─── Start Evolution Hook ─────────────────────────────────────────────────────
/**
* Parameters for start evolution hook.
*/
export interface UseStartEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of starting evolution.
*/
export interface StartEvolutionResult {
/** The Blobbi's name */
name: string;
/** Timestamp when evolution started */
stateStartedAt: number;
}
/**
* Hook to start the evolution process for a baby Blobbi.
*
* This sets the Blobbi state to 'evolving' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* Requirements:
* - Blobbi must be in baby stage
* - Blobbi must not already be evolving
* - User must be logged in
*/
export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StartEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.stage !== 'baby') {
throw new Error('Only baby Blobbis can evolve');
}
if (companion.state === 'evolving') {
throw new Error('This Blobbi is already evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'evolving',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
// ─── Populate evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: createEvolveMissions() },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
return {
name: canonical.companion.name,
stateStartedAt: now,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution started!',
description: `${name} is now working towards evolution. Complete the tasks to evolve!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to start evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Evolution Hook ──────────────────────────────────────────────────────
/**
* Parameters for stop evolution hook.
*/
export interface UseStopEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of stopping evolution.
*/
export interface StopEvolutionResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the evolution process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart evolution later, but will need to complete tasks again.
*
* When stopping evolution:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in evolving state
* - User must be logged in
*/
export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'evolving') {
throw new Error('This Blobbi is not evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
// ─── Clear evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: [] },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution stopped',
description: `${name} is no longer evolving. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -1,394 +0,0 @@
// src/blobbi/actions/hooks/useBlobbiStageTransition.ts
/**
* Hooks for Blobbi stage transitions (hatch, evolve).
*
* Both transitions follow the same decay pattern:
* 1. Apply accumulated decay from `last_decay_at` to `now`
* 2. Use decayed stats as the source of truth for the transition
* 3. Publish new event with decayed stats + new stage
* 4. Reset `last_decay_at` to current timestamp
*
* @see docs/blobbi/decay-system.md
*/
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
STAT_MAX,
updateBlobbiTags,
} 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 ──────────────────────────────────────────────────────────
/**
* Generate the content string for a Blobbi at a given stage.
* Format: "{name} is a {stage} Blobbi."
*
* Uses correct grammar: "an egg" vs "a baby/adult"
*/
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of ensuring canonical companion before action.
* This is the same interface used by useBlobbiUseInventoryItem.
*/
export interface CanonicalActionResult {
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
}
/**
* Parameters for stage transition hooks.
*/
export interface UseBlobbiStageTransitionParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of a stage transition.
*/
export interface StageTransitionResult {
/** Previous stage before transition */
previousStage: BlobbiStage;
/** New stage after transition */
newStage: BlobbiStage;
/** The Blobbi's name */
name: string;
/** Stats after decay was applied (before any transition bonuses) */
decayedStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
}
// ─── Hatch Hook ───────────────────────────────────────────────────────────────
/**
* Hook to hatch an egg into a baby Blobbi.
*
* Transition: egg -> baby
*
* Requirements:
* - Blobbi must be in egg stage
* - Applies accumulated decay before transition
* - Resets stats to healthy baby defaults (inherits health from egg)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiHatch({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to hatch');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be hatched');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for hatching');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Calculate Baby Stats ───
// All stats reset to 100 when hatching — the baby starts fresh
const babyStats = {
hunger: STAT_MAX,
happiness: STAT_MAX,
health: STAT_MAX,
hygiene: STAT_MAX,
energy: STAT_MAX,
};
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Build the updated tags using the central merge function
// Get streak updates (hatching counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'baby',
state: 'active', // Newly hatched babies are awake
hunger: babyStats.hunger.toString(),
happiness: babyStats.happiness.toString(),
health: babyStats.health.toString(),
hygiene: babyStats.hygiene.toString(),
energy: babyStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Hatch] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
}
// ─── Auto-start evolution for newly hatched babies ───
// Applied AFTER tag validation because cleanupTaskTags repairs
// task-process states to 'active'. We intentionally set 'evolving'
// here so the baby starts its evolution journey immediately.
const newTags = updateBlobbiTags(repairResult.tags, {
state: 'evolving',
state_started_at: nowStr,
});
// ─── Generate New Content for Baby Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
return {
previousStage: 'egg',
newStage: 'baby',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Your egg hatched!',
description: `${name} is now a baby Blobbi! Take good care of them.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to hatch',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Evolve Hook ──────────────────────────────────────────────────────────────
/**
* Hook to evolve a baby Blobbi into an adult.
*
* Transition: baby -> adult
*
* Requirements:
* - Blobbi must be in baby stage
* - Applies accumulated decay before transition
* - Preserves all stats (decay already applied)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiEvolve({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to evolve');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'baby') {
if (companion.stage === 'egg') {
throw new Error('Eggs must hatch before they can evolve');
}
if (companion.stage === 'adult') {
throw new Error('This Blobbi is already fully evolved');
}
throw new Error('Only baby Blobbis can evolve');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Adult Stats ───
// Adult inherits all decayed stats from baby
// No stat reset - evolution preserves current condition
const adultStats = decayResult.stats;
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Get streak updates (evolving counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// Build the updated tags using the central merge function
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'adult',
state: 'active', // Evolution completes with active state
hunger: adultStats.hunger.toString(),
happiness: adultStats.happiness.toString(),
health: adultStats.health.toString(),
hygiene: adultStats.hygiene.toString(),
energy: adultStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Evolve] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Evolve] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Adult Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
return {
previousStage: 'baby',
newStage: 'adult',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution complete!',
description: `${name} has evolved into an adult Blobbi!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to evolve',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -1,310 +0,0 @@
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
canUseAction,
getStageRestrictionMessage,
clampStat,
applyStat,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
type InventoryAction,
ACTION_METADATA,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } 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';
/**
* Request payload for using an item on a Blobbi companion
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
}
/**
* Result of using an item on a Blobbi companion
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
}
/**
* Parameters for the useBlobbiUseInventoryItem hook
*/
export interface UseBlobbiUseInventoryItemParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Update profile event in local cache */
updateProfileEvent: (event: NostrEvent) => void;
}
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an item on a Blobbi companion.
*
* Items are reusable abilities sourced from the shop catalog — no
* inventory ownership or quantity is required.
*
* This hook:
* 1. Validates the companion and item compatibility
* 2. Ensures canonical format before action
* 3. Applies accumulated decay, then item effects to Blobbi stats
* 4. Updates Blobbi state (kind 31124)
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent: _updateProfileEvent,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
throw new Error(message ?? 'This companion cannot use this item');
}
// Validate item exists in shop catalog
const shopItem = getShopItemById(itemId);
if (!shopItem) {
throw new Error('Item not found in catalog');
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
}
// For eggs, validate that items have applicable effects
const isEgg = companion.stage === 'egg';
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
throw new Error('This medicine has no effect on eggs');
}
if (isEgg && action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect)) {
throw new Error('This item has no cleaning effect on eggs');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any user interaction updates stats.
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Validate Play Energy Requirements ───
// For play actions, validate the Blobbi has enough energy AFTER decay
if (action === 'play') {
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
const currentEnergy = statsAfterDecay.energy;
if (energyCost > 0 && currentEnergy < energyCost) {
throw new Error(
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
);
}
// Also check if playing would have any effect at all
// If happiness is maxed AND we can't spend energy, playing is pointless
const happinessGain = shopItem.effect.happiness ?? 0;
const currentHappiness = statsAfterDecay.happiness;
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
if (!wouldGainHappiness && !wouldSpendEnergy) {
throw new Error(
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
);
}
}
// ─── Apply Item Effects (single use) ───
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
statsUpdate.happiness = currentHappiness.toString();
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (totalHappinessChange !== 0) {
statsChanged.happiness = totalHappinessChange;
}
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
statsUpdate.energy = clampStat(currentStats.energy).toString();
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
statsUpdate.health = clampStat(currentStats.health).toString();
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter in evolution missions
const companionState = canonical.companion.state;
const updatedTags = canonical.allTags;
if (companionState === 'incubating' || companionState === 'evolving') {
trackEvolutionMissionTally('interactions', 1, user?.pubkey);
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain ───
const xpGained = calculateInventoryActionXP(action, 1);
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
// Items are free to use — no storage decrement needed.
// No query invalidation needed — the optimistic update above keeps the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
return {
itemName: shopItem.name,
action,
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Failed to use item',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -1,121 +0,0 @@
/**
* useAwardDailyXp - Award XP for completed daily missions
*
* Completion is implicit (derived from progress vs target).
* This hook calculates the total XP earned today and persists
* the updated XP total to kind 11125 tags.
*
* Uses fetchFreshEvent to avoid stale-read overwrites when
* multiple mutations race (e.g. item use XP + daily XP).
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
parseBlobbonautEvent,
} from '@/blobbi/core/lib/blobbi';
import { buildXpTagUpdates } from '@/blobbi/core/lib/progression';
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { totalDailyXp } from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface AwardDailyXpRequest {
/** Current missions state to calculate XP from */
missions: MissionsContent;
}
export interface AwardDailyXpResult {
xpAwarded: number;
newTotalXp: number;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to award XP for completed daily missions.
*
* @param updateProfileEvent - Callback to update profile in query cache
*/
export function useAwardDailyXp(
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ missions }: AwardDailyXpRequest): Promise<AwardDailyXpResult> => {
if (!user?.pubkey) throw new Error('Must be logged in');
const xpToAward = totalDailyXp(missions);
if (xpToAward <= 0) return { xpAwarded: 0, newTotalXp: 0 };
// Fetch fresh profile from relays to avoid stale-read overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [user.pubkey],
});
const freshProfile = prev ? parseBlobbonautEvent(prev) : undefined;
const currentXp = freshProfile?.xp ?? 0;
const newTotalXp = currentXp + xpToAward;
// Update XP and level tags on the fresh event's tags
const updatedTags = updateBlobbonautTags(
prev?.tags ?? [],
buildXpTagUpdates(newTotalXp),
);
// Persist missions state to content field
const content = serializeProfileContent(
prev?.content ?? '',
{ missions },
);
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content,
tags: updatedTags,
prev: prev ?? undefined,
});
updateProfileEvent(event);
return { xpAwarded: xpToAward, newTotalXp };
},
onSuccess: ({ xpAwarded }) => {
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
}
if (xpAwarded > 0) {
toast({
title: 'XP Earned!',
description: `You earned ${xpAwarded} XP from daily missions.`,
});
}
},
onError: (error: Error) => {
toast({
title: 'Failed to Award XP',
description: error.message,
variant: 'destructive',
});
},
});
}
// Legacy export name for backward compatibility during migration
export const useClaimMissionReward = useAwardDailyXp;
export type ClaimMissionRequest = AwardDailyXpRequest;
export type ClaimMissionResult = AwardDailyXpResult;
@@ -1,227 +0,0 @@
/**
* useDailyMissions - Hook for reading daily mission state
*
* Provides reactive access to the current day's missions.
* Progress tracking is done via the tracker module (non-React).
* Completion is implicit (derived from count/events vs target).
* XP is awarded automatically when missions complete.
*
* State lives in a pubkey-scoped in-memory Map. On mount or account
* switch, hydrates from kind 11125 content JSON if the session store
* is empty. Completed missions are persisted by `useAwardDailyXp`;
* intermediate progress resets on page refresh.
*/
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
import { parseProfileContent } from '@/blobbi/core/lib/missions';
import {
type BlobbiStage,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsContent,
areAllDailyComplete,
totalDailyXp,
getDefinition,
MAX_DAILY_REROLLS,
DAILY_BONUS_XP,
} from '../lib/daily-missions';
import {
readMissionsFromStorage,
writeMissionsToStorage,
hydrateFromPersisted,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface DailyMissionView {
/** Mission ID (matches pool definition) */
id: string;
/** Display title */
title: string;
/** Description */
description: string;
/** Action type */
action: DailyMissionAction;
/** Required count */
target: number;
/** Current progress */
progress: number;
/** Whether mission is complete */
complete: boolean;
/** XP reward */
xp: number;
}
export interface UseDailyMissionsOptions {
/** Available Blobbi stages the user has (filters eligible missions) */
availableStages?: BlobbiStage[];
/**
* Raw content string from the kind 11125 profile event.
* Pass `profile.content` here. The hook parses it to extract
* persisted missions and hydrates the session store on first load.
*/
profileContent?: string;
}
export interface UseDailyMissionsResult {
/** Today's daily missions with computed progress */
missions: DailyMissionView[];
/** The raw missions content (for persistence/mutation hooks) */
raw: MissionsContent | undefined;
/** Whether all daily missions are complete */
allComplete: boolean;
/** Total XP earned today (completed missions + bonus) */
todayXp: number;
/** Whether the daily bonus is unlocked (all missions complete) */
bonusUnlocked: boolean;
/** Bonus XP amount */
bonusXp: number;
/** Whether user has no eligible missions */
noMissionsAvailable: boolean;
/** Rerolls remaining today */
rerollsRemaining: number;
/** Max rerolls per day */
maxRerolls: number;
/** Force refresh missions (testing) */
forceReset: () => void;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
const { availableStages, profileContent } = options;
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
// Version counter to trigger re-reads from session store
const [version, setVersion] = useState(0);
// Track whether we've hydrated for this pubkey
const hydratedRef = useRef<string | null>(null);
// Hydrate session store from kind 11125 content on mount / account switch
useEffect(() => {
if (!pubkey || !profileContent) return;
if (hydratedRef.current === pubkey) return; // already hydrated this session
// Check if session store already has data for this pubkey
const existing = readMissionsFromStorage(pubkey);
if (existing) {
hydratedRef.current = pubkey;
return;
}
// Parse persisted missions from profile content
const parsed = parseProfileContent(profileContent);
if (parsed.missions && !needsDailyReset(parsed.missions)) {
// Daily missions are still current — hydrate the full object
hydrateFromPersisted(parsed.missions, pubkey);
} else if (parsed.missions?.evolution?.length) {
// Daily missions need a reset, but evolution missions survive across days.
// Seed the store with fresh dailies + persisted evolution so the raw memo
// picks them up instead of creating missions with evolution: [].
const fresh = createDailyMissionsContent(
getTodayDateString(),
parsed.missions.evolution,
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
}
hydratedRef.current = pubkey;
setVersion((v) => v + 1);
}, [pubkey, profileContent]); // eslint-disable-line react-hooks/exhaustive-deps
// Listen for tracker events
useEffect(() => {
const handler = () => setVersion((v) => v + 1);
window.addEventListener('daily-missions-updated', handler);
return () => window.removeEventListener('daily-missions-updated', handler);
}, []);
// Stable stages key for deps
const stagesKey = availableStages?.sort().join(',') ?? '';
// Read and ensure current state.
// CRITICAL: Don't create a fresh store entry until hydration is complete.
// Creating one prematurely would overwrite persisted evolution missions
// because `hydrateFromPersisted` no-ops when the store already has data.
const hydrated = hydratedRef.current === pubkey;
const raw = useMemo((): MissionsContent | undefined => {
const stored = readMissionsFromStorage(pubkey);
if (!needsDailyReset(stored)) return stored;
// If the store is empty and we haven't hydrated yet, wait for the
// hydration effect to seed persisted data before creating fresh missions.
if (!stored && !hydrated) return undefined;
// Reset for new day, preserve evolution missions
const fresh = createDailyMissionsContent(
getTodayDateString(),
stored?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
return fresh;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version, pubkey, stagesKey, hydrated]);
// Build view models
const missions: DailyMissionView[] = useMemo(() => {
if (!raw?.daily) return [];
return raw.daily.map((m) => {
const def = getDefinition(m.id);
return {
id: m.id,
title: def?.title ?? m.id,
description: def?.description ?? '',
action: def?.action ?? 'interact',
target: m.target,
progress: missionProgress(m),
complete: isMissionComplete(m),
xp: def?.xp ?? 0,
};
});
}, [raw]);
const allComplete = raw ? areAllDailyComplete(raw) : false;
const todayXp = raw ? totalDailyXp(raw) : 0;
const bonusUnlocked = allComplete;
const noMissionsAvailable = missions.length === 0;
const rerollsRemaining = raw?.rerolls ?? MAX_DAILY_REROLLS;
const forceReset = useCallback(() => {
const fresh = createDailyMissionsContent(
getTodayDateString(),
raw?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
setVersion((v) => v + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey, stagesKey, raw?.evolution]);
return {
missions,
raw,
allComplete,
todayXp,
bonusUnlocked,
bonusXp: DAILY_BONUS_XP,
noMissionsAvailable,
rerollsRemaining,
maxRerolls: MAX_DAILY_REROLLS,
forceReset,
};
}
-257
View File
@@ -1,257 +0,0 @@
// src/blobbi/actions/hooks/useEvolveTasks.ts
/**
* Hook to compute evolve task progress.
*
* Progress is stored in `MissionsContent.evolution[]` on kind 11125.
* - Interactions: TallyMission tracked via `trackEvolutionMissionTally`
* - Event-based tasks: EventMission, backfilled from retroactive Nostr queries
* - Dynamic task (maintain_stats): computed from current companion stats, NEVER stored
*/
import { useEffect, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions';
import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker';
import {
EVOLVE_MISSIONS,
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_STAT_THRESHOLD,
findEvolutionMission,
createEvolveMissions,
} from '../lib/evolution-missions';
import {
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
KIND_PROFILE_METADATA,
type HatchTask,
type TaskType,
} from './useHatchTasks';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for custom profile tabs event */
export const KIND_PROFILE_TABS = 16769;
// Re-export for backward compat
export {
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_STAT_THRESHOLD,
};
// Re-export task types for convenience
export type { HatchTask as EvolveTask, TaskType };
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of computing evolve tasks.
*/
export interface EvolveTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to evolve */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute evolve task progress from evolution missions + Nostr event backfill.
*
* @param companion - The Blobbi companion (must be in evolving state)
* @param missions - Current MissionsContent from the session store
*/
export function useEvolveTasks(
companion: BlobbiCompanion | null,
missions: MissionsContent | undefined,
): EvolveTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const isEvolving = companion?.state === 'evolving';
const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]);
// ─── Ensure evolution missions exist in session store ───
// Safety net: if the companion is evolving but evolution[] is empty
// (e.g. persist didn't fire, hydration lost them), re-populate from
// the static definitions so tally tracking works immediately.
const ensuredRef = useRef(false);
useEffect(() => {
if (!isEvolving || !pubkey || ensuredRef.current) return;
if (evolution.length > 0) { ensuredRef.current = true; return; }
const store = ensureSessionStore(pubkey);
if (store.evolution.length === 0) {
writeMissionsToStorage({ ...store, evolution: createEvolveMissions() }, pubkey);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
}
ensuredRef.current = true;
}, [isEvolving, pubkey, evolution]);
// ─── Retroactive Nostr Queries (discover event IDs to backfill) ───
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['evolve-tasks', pubkey],
queryFn: async () => {
if (!pubkey) return null;
const filters: NostrFilter[] = [
{ kinds: [KIND_THEME_DEFINITION], authors: [pubkey], limit: EVOLVE_REQUIRED_THEMES },
{ kinds: [KIND_COLOR_MOMENT], authors: [pubkey], limit: EVOLVE_REQUIRED_COLOR_MOMENTS },
{ kinds: [KIND_PROFILE_TABS], authors: [pubkey], limit: 1 },
{ kinds: [KIND_PROFILE_METADATA], authors: [pubkey], limit: 1 },
];
const events = await nostr.query(filters);
return {
themeEvents: events.filter(e => e.kind === KIND_THEME_DEFINITION),
colorMomentEvents: events.filter(e => e.kind === KIND_COLOR_MOMENT),
profileTabsEvents: events.filter(e => e.kind === KIND_PROFILE_TABS),
hasProfileMetadata: events.some(e => e.kind === KIND_PROFILE_METADATA),
};
},
enabled: !!pubkey && isEvolving,
staleTime: 30_000,
refetchInterval: 60_000,
});
// ─── Compute event counts directly from Nostr query results ───
// These are the authoritative counts for event-based tasks.
const queryCounts: Record<string, number> = useMemo(() => {
if (!data) return {} as Record<string, number>;
return {
create_themes: data.themeEvents.length,
color_moments: data.colorMomentEvents.length,
edit_profile: (data.profileTabsEvents.length >= 1 || data.hasProfileMetadata) ? 1 : 0,
};
}, [data]);
// ─── Backfill event IDs into evolution missions (for persistence only) ───
const lastBackfilledDataRef = useRef<typeof data>(null);
useEffect(() => {
if (!data || !pubkey || evolution.length === 0) return;
if (data === lastBackfilledDataRef.current) return;
lastBackfilledDataRef.current = data;
const current = readMissionsFromStorage(pubkey);
if (!current || current.evolution.length === 0) return;
const evo = current.evolution;
for (const event of data.themeEvents) {
const m = findEvolutionMission(evo, 'create_themes');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('create_themes', event.id, pubkey);
}
}
for (const event of data.colorMomentEvents) {
const m = findEvolutionMission(evo, 'color_moments');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('color_moments', event.id, pubkey);
}
}
const profileEditEvents = [
...data.profileTabsEvents,
...(data.hasProfileMetadata ? [{ id: 'profile-metadata' }] : []),
];
for (const event of profileEditEvents) {
const m = findEvolutionMission(evo, 'edit_profile');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('edit_profile', event.id, pubkey);
}
}
}, [data, pubkey, evolution]);
// ─── Build task view models ───
// For event-based tasks, use the MAX of the Nostr query count and the
// evolution mission progress. The query is authoritative but the mission
// store may have progress from a previous session that hasn't been
// re-queried yet.
const tasks: HatchTask[] = EVOLVE_MISSIONS.map((def) => {
const mission = findEvolutionMission(evolution, def.id);
const missionCount = mission ? missionProgress(mission) : 0;
const queryCount = queryCounts[def.id] ?? 0;
const current = Math.max(missionCount, queryCount);
const completed = current >= def.target;
return {
id: def.id,
name: def.title,
description: def.description,
current: Math.min(current, def.target),
required: def.target,
completed,
type: 'persistent' as TaskType,
action: def.action,
actionTarget: def.actionTarget,
actionLabel: def.actionLabel,
};
});
// ─── Dynamic Task: Maintain All Stats >= 80 ───
const stats = companion?.stats ?? {};
const hunger = stats.hunger ?? 0;
const happiness = stats.happiness ?? 0;
const health = stats.health ?? 0;
const hygiene = stats.hygiene ?? 0;
const energy = stats.energy ?? 0;
const statsOk =
hunger >= EVOLVE_STAT_THRESHOLD &&
happiness >= EVOLVE_STAT_THRESHOLD &&
health >= EVOLVE_STAT_THRESHOLD &&
hygiene >= EVOLVE_STAT_THRESHOLD &&
energy >= EVOLVE_STAT_THRESHOLD;
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
tasks.push({
id: 'maintain_stats',
name: 'Peak Condition',
description: `Keep all stats above ${EVOLVE_STAT_THRESHOLD}`,
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
required: EVOLVE_STAT_THRESHOLD,
completed: statsOk,
type: 'dynamic',
});
// ─── Completion ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
-277
View File
@@ -1,277 +0,0 @@
// src/blobbi/actions/hooks/useHatchTasks.ts
/**
* Hook to compute hatch task progress.
*
* Progress is stored in `MissionsContent.evolution[]` on kind 11125.
* - Interactions: TallyMission tracked via `trackEvolutionMissionTally`
* - Event-based tasks: EventMission, backfilled from retroactive Nostr queries
*
* The Nostr queries discover event IDs that satisfy event-based tasks and
* feed them into the evolution tracker. The evolution array is the source of
* truth for completion state.
*/
import { useEffect, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions';
import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker';
import {
HATCH_MISSIONS,
HATCH_REQUIRED_INTERACTIONS,
findEvolutionMission,
createHatchMissions,
} from '../lib/evolution-missions';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for theme definition events */
export const KIND_THEME_DEFINITION = 36767;
/** Kind for color moment events (espy.you) */
export const KIND_COLOR_MOMENT = 3367;
/** Kind for profile metadata */
export const KIND_PROFILE_METADATA = 0;
/** Kind for short text notes */
export const KIND_SHORT_TEXT_NOTE = 1;
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
/** 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 { HATCH_REQUIRED_INTERACTIONS };
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Task type classification.
* - persistent: Based on Nostr events or tallies, stored in evolution[]
* - dynamic: Based on current stats, NEVER stored
*/
export type TaskType = 'persistent' | 'dynamic';
/**
* Individual task view model for the UI.
*/
export interface HatchTask {
id: string;
name: string;
description: string;
/** Current progress value */
current: number;
/** Required value for completion */
required: number;
/** Whether the task is complete */
completed: boolean;
/** Task type - persistent or dynamic */
type: TaskType;
/** Action to perform (if applicable) */
action?: 'navigate' | 'open_modal' | 'external_link';
/** Target for the action */
actionTarget?: string;
/** Button label */
actionLabel?: string;
}
/**
* Result of computing hatch tasks.
*/
export interface HatchTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to hatch */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* 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-related post.
*/
export function isValidHatchPost(event: NostrEvent): boolean {
const hasBlobbiTag = event.tags.some(
tag => tag[0] === 't' && tag[1]?.toLowerCase() === 'blobbi',
);
if (hasBlobbiTag) return true;
return /#blobbi\b/i.test(event.content);
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute hatch task progress from evolution missions + Nostr event backfill.
*
* @param companion - The Blobbi companion (must be incubating)
* @param missions - Current MissionsContent from the session store
*/
export function useHatchTasks(
companion: BlobbiCompanion | null,
missions: MissionsContent | undefined,
): HatchTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const isIncubating = companion?.state === 'incubating';
const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]);
// ─── Ensure evolution missions exist in session store ───
// Safety net: if the companion is incubating but evolution[] is empty
// (e.g. persist didn't fire, hydration lost them), re-populate from
// the static definitions so tally tracking works immediately.
const ensuredRef = useRef(false);
useEffect(() => {
if (!isIncubating || !pubkey || ensuredRef.current) return;
if (evolution.length > 0) { ensuredRef.current = true; return; }
const store = ensureSessionStore(pubkey);
if (store.evolution.length === 0) {
writeMissionsToStorage({ ...store, evolution: createHatchMissions() }, pubkey);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
}
ensuredRef.current = true;
}, [isIncubating, pubkey, evolution]);
// ─── Retroactive Nostr Queries (discover event IDs to backfill) ───
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['hatch-tasks', pubkey],
queryFn: async () => {
if (!pubkey) return null;
const filters: NostrFilter[] = [
{ kinds: [KIND_THEME_DEFINITION], authors: [pubkey], limit: 1 },
{ kinds: [KIND_COLOR_MOMENT], authors: [pubkey], limit: 1 },
{ kinds: [KIND_SHORT_TEXT_NOTE], authors: [pubkey], '#t': ['blobbi'], limit: 1 },
];
const events = await nostr.query(filters);
return {
themeEvents: events.filter(e => e.kind === KIND_THEME_DEFINITION),
colorMomentEvents: events.filter(e => e.kind === KIND_COLOR_MOMENT),
postEvents: events.filter(e => e.kind === KIND_SHORT_TEXT_NOTE),
};
},
enabled: !!pubkey && isIncubating,
staleTime: 30_000,
refetchInterval: 60_000,
});
// ─── Compute event counts directly from Nostr query results ───
// These are the authoritative counts for event-based tasks.
const queryCounts: Record<string, number> = useMemo(() => {
if (!data) return {} as Record<string, number>;
const validPosts = data.postEvents.filter(e => isValidHatchPost(e));
return {
create_theme: data.themeEvents.length,
color_moment: data.colorMomentEvents.length,
create_post: validPosts.length,
};
}, [data]);
// ─── Backfill event IDs into evolution missions (for persistence only) ───
const lastBackfilledDataRef = useRef<typeof data>(null);
useEffect(() => {
if (!data || !pubkey || evolution.length === 0) return;
if (data === lastBackfilledDataRef.current) return;
lastBackfilledDataRef.current = data;
const current = readMissionsFromStorage(pubkey);
if (!current || current.evolution.length === 0) return;
const evo = current.evolution;
for (const event of data.themeEvents) {
const m = findEvolutionMission(evo, 'create_theme');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('create_theme', event.id, pubkey);
}
}
for (const event of data.colorMomentEvents) {
const m = findEvolutionMission(evo, 'color_moment');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('color_moment', event.id, pubkey);
}
}
for (const event of data.postEvents) {
if (!isValidHatchPost(event)) continue;
const m = findEvolutionMission(evo, 'create_post');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('create_post', event.id, pubkey);
}
}
}, [data, pubkey, evolution]);
// ─── Build task view models ───
// For event-based tasks, use the MAX of the Nostr query count and the
// evolution mission progress. The query is authoritative but the mission
// store may have progress from a previous session that hasn't been
// re-queried yet.
const tasks: HatchTask[] = HATCH_MISSIONS.map((def) => {
const mission = findEvolutionMission(evolution, def.id);
const missionCount = mission ? missionProgress(mission) : 0;
const queryCount = queryCounts[def.id] ?? 0;
const current = Math.max(missionCount, queryCount);
const completed = current >= def.target;
return {
id: def.id,
name: def.title,
description: def.description,
current: Math.min(current, def.target),
required: def.target,
completed,
type: 'persistent' as TaskType,
action: def.action,
actionTarget: def.actionTarget,
actionLabel: def.actionLabel,
};
});
const persistentTasksComplete = tasks.every(t => t.completed);
const dynamicTaskComplete = true; // No dynamic tasks for hatching
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Filter tasks to only persistent tasks (for tag sync).
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
@@ -1,42 +0,0 @@
/**
* useItemCooldown — React hook for per-item cooldown state.
*
* Subscribes to the shared item-cooldown singleton so components
* re-render when any item's cooldown starts or expires.
*
* Usage:
* ```tsx
* const { isOnCooldown } = useItemCooldown();
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
* ```
*/
import { useCallback, useSyncExternalStore } from 'react';
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
/** Monotonic version counter bumped by the subscription callback. */
let snapshotVersion = 0;
function subscribe(onStoreChange: () => void): () => void {
// subscribeCooldowns returns an unsubscribe function.
// The callback bumps the version AND notifies React.
return subscribeCooldowns(() => {
snapshotVersion++;
onStoreChange();
});
}
function getSnapshot(): number {
return snapshotVersion;
}
export function useItemCooldown() {
useSyncExternalStore(subscribe, getSnapshot);
const isOnCooldown = useCallback((itemId: string): boolean => {
return isItemOnCooldown(itemId);
}, []);
return { isOnCooldown };
}
@@ -1,107 +0,0 @@
/**
* usePersistEvolutionProgress - Debounced persistence for evolution mission progress.
*
* Evolution missions (hatch/evolve tasks) live in `MissionsContent.evolution[]`
* in the in-memory session store. This hook listens for changes and debounce-
* publishes the updated state to kind 11125 content JSON so progress survives
* page refreshes.
*
* Design:
* - Listens to 'daily-missions-updated' CustomEvent (same event the tracker fires)
* - Only acts on events with `detail.evolution === true`
* - Debounces by PERSIST_DELAY_MS to batch rapid interactions
* - Uses fetchFreshEvent to avoid stale-read overwrites
* - Skips publish if evolution[] is empty (no active task process)
*/
import { useEffect, useRef, useCallback } from 'react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import {
KIND_BLOBBONAUT_PROFILE,
} from '@/blobbi/core/lib/blobbi';
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
import { readMissionsFromStorage } from '../lib/daily-mission-tracker';
import type { NostrEvent } from '@nostrify/nostrify';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Delay before persisting evolution progress (ms). */
const PERSIST_DELAY_MS = 5_000;
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* @param updateProfileEvent - Callback to update profile in query cache
*/
export function usePersistEvolutionProgress(
updateProfileEvent: (event: NostrEvent) => void,
): void {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishingRef = useRef(false);
const persist = useCallback(async () => {
const pubkey = user?.pubkey;
if (!pubkey || publishingRef.current) return;
const missions = readMissionsFromStorage(pubkey);
if (!missions || missions.evolution.length === 0) return;
publishingRef.current = true;
try {
const prev = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [pubkey],
});
const content = serializeProfileContent(
prev?.content ?? '',
{ missions },
);
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content,
tags: prev?.tags ?? [],
prev: prev ?? undefined,
});
updateProfileEvent(event);
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', pubkey] });
} finally {
publishingRef.current = false;
}
}, [user?.pubkey, nostr, publishEvent, updateProfileEvent, queryClient]);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.evolution) return;
// Clear any pending timer and restart the debounce
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
persist().catch((err) => {
console.warn('[PersistEvolution] Failed to persist:', err);
});
}, PERSIST_DELAY_MS);
};
window.addEventListener('daily-missions-updated', handler);
return () => {
window.removeEventListener('daily-missions-updated', handler);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [persist]);
}
@@ -1,83 +0,0 @@
/**
* useRerollMission - Replace a daily mission with a new one from the pool
*
* Updates the in-memory session store.
*/
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import type { BlobbiStage } from '../lib/daily-missions';
import { rerollMission, getDefinition } from '../lib/daily-missions';
import {
readMissionsFromStorage,
writeMissionsToStorage,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface RerollMissionRequest {
missionId: string;
availableStages?: BlobbiStage[];
}
export interface RerollMissionResult {
oldMissionId: string;
newMissionId: string;
rerollsRemaining: number;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useRerollMission() {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
if (!user?.pubkey) throw new Error('Must be logged in');
const current = readMissionsFromStorage(user.pubkey);
if (!current) throw new Error('No missions state');
const updated = rerollMission(current, missionId, availableStages);
if (!updated) throw new Error('Cannot reroll this mission');
writeMissionsToStorage(updated, user.pubkey);
// Notify React
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, rerolled: true },
}));
// Find the new mission ID at the same index
const oldIdx = current.daily.findIndex((m) => m.id === missionId);
const newMissionId = updated.daily[oldIdx]?.id ?? missionId;
return {
oldMissionId: missionId,
newMissionId,
rerollsRemaining: updated.rerolls,
};
},
onSuccess: ({ newMissionId, rerollsRemaining }) => {
const def = getDefinition(newMissionId);
const rerollText = rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} reroll${rerollsRemaining === 1 ? '' : 's'} left`;
toast({
title: 'Mission Replaced',
description: `New mission: ${def?.title ?? newMissionId}. ${rerollText}.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to Reroll',
description: error.message,
variant: 'destructive',
});
},
});
}
-223
View File
@@ -1,223 +0,0 @@
// src/blobbi/actions/index.ts
// Components
export { BlobbiActionsModal } from './components/BlobbiActionsModal';
export { BlobbiActionInventoryModal } from './components/BlobbiActionInventoryModal';
export { PlayMusicModal } from './components/PlayMusicModal';
export { SingModal } from './components/SingModal';
export { InlineMusicPlayer } from './components/InlineMusicPlayer';
export { InlineSingCard } from './components/InlineSingCard';
export { HatchTasksPanel } from './components/HatchTasksPanel';
export { TasksPanel } from './components/TasksPanel';
export { BlobbiPostModal } from './components/BlobbiPostModal';
export { StartIncubationDialog } from './components/StartIncubationDialog';
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
// Hooks
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
export type { UseItemRequest, UseItemResult, UseBlobbiUseInventoryItemParams } from './hooks/useBlobbiUseInventoryItem';
export { useBlobbiHatch, useBlobbiEvolve } from './hooks/useBlobbiStageTransition';
export type {
UseBlobbiStageTransitionParams,
StageTransitionResult,
CanonicalActionResult,
} from './hooks/useBlobbiStageTransition';
export {
useStartIncubation,
useStopIncubation,
useStartEvolution,
useStopEvolution,
} from './hooks/useBlobbiIncubation';
export type {
StartIncubationMode,
StartIncubationRequest,
UseStartIncubationParams,
StartIncubationResult,
UseStopIncubationParams,
StopIncubationResult,
UseStartEvolutionParams,
StartEvolutionResult,
UseStopEvolutionParams,
StopEvolutionResult,
} from './hooks/useBlobbiIncubation';
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from './hooks/useActiveTaskProcess';
export {
useHatchTasks,
filterPersistentTasks,
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
REQUIRED_INTERACTIONS, // Legacy export
BLOBBI_POST_PREFIX,
BLOBBI_POST_REQUIRED_HASHTAGS,
} from './hooks/useHatchTasks';
export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTasks';
export {
useEvolveTasks,
KIND_PROFILE_TABS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_STAT_THRESHOLD,
} from './hooks/useEvolveTasks';
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
export { useBlobbiDirectAction, DIRECT_ACTION_HAPPINESS_EFFECTS } from './hooks/useBlobbiDirectAction';
export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionParams } from './hooks/useBlobbiDirectAction';
export { useAudioPlayback } from './hooks/useAudioPlayback';
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
// Track catalog
export {
BLOBBI_TRACK_CATALOG,
getAllTracks,
getTrackById,
formatTrackDuration,
type BlobbiTrack,
} from './lib/blobbi-track-catalog';
// Activity state
export {
createMusicActivity,
createSingActivity,
createNoActivity,
type InlineActivityType,
type InlineActivityState,
type MusicActivityState,
type SingActivityState,
type NoActivityState,
type BlobbiReactionState,
type SelectedTrack,
} from './lib/blobbi-activity-state';
// Re-export stat bounds from canonical source
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
// Utilities
export {
// Types
type InventoryAction,
type DirectAction,
type BlobbiAction,
type ResolvedInventoryItem,
type EggStatPreview,
type ItemUsabilityResult,
// Constants
ACTION_TO_ITEM_TYPE,
ACTION_METADATA,
DIRECT_ACTION_METADATA,
ALL_ACTION_METADATA,
GENERAL_ITEM_USABLE_STAGES,
EGG_ALLOWED_ACTIONS,
EGG_ALLOWED_INVENTORY_ACTIONS,
EGG_ALLOWED_DIRECT_ACTIONS,
EGG_VISIBLE_INVENTORY_ACTIONS,
EGG_VISIBLE_ACTIONS,
SHELL_REPAIR_KIT_ID,
// Functions
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
isActionVisibleForStage,
canUseInventoryItems,
getStageRestrictionMessage,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
canUseItemForStage,
getActionForItem,
} from './lib/blobbi-action-utils';
// Daily Missions
export { useDailyMissions } from './hooks/useDailyMissions';
export type { DailyMissionView, UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useAwardDailyXp, useClaimMissionReward } from './hooks/useClaimMissionReward';
export { usePersistEvolutionProgress } from './hooks/usePersistEvolutionProgress';
export type { AwardDailyXpRequest, AwardDailyXpResult, ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export { useRerollMission } from './hooks/useRerollMission';
export type { RerollMissionRequest, RerollMissionResult } from './hooks/useRerollMission';
export {
trackDailyMissionProgress,
trackDailyMissionEvent,
trackMultipleDailyMissionActions,
} from './lib/daily-mission-tracker';
export type {
DailyMissionAction,
DailyMissionDefinition,
Mission,
TallyMission,
EventMission,
MissionsContent,
} from './lib/daily-missions';
// Progression
export {
xpToLevel,
levelToXp,
xpProgress,
xpToNextLevel,
getUnlocks,
buildXpTagUpdates,
MAX_LEVEL,
} from '@/blobbi/core/lib/progression';
export type { Unlocks } from '@/blobbi/core/lib/progression';
// Missions content model
export {
parseProfileContent,
serializeProfileContent,
isMissionComplete,
isTallyMission,
isEventMission,
missionProgress,
} from '@/blobbi/core/lib/missions';
export type { ProfileContent } from '@/blobbi/core/lib/missions';
// Item cooldown
export { isItemOnCooldown, setItemCooldown, subscribeCooldowns } from './lib/item-cooldown';
export { ITEM_COOLDOWN_SUCCESS_MS, ITEM_COOLDOWN_FAILURE_MS } from './lib/item-cooldown';
export { useItemCooldown } from './hooks/useItemCooldown';
// Action XP
export {
ACTION_XP,
INVENTORY_ACTION_XP,
DIRECT_ACTION_XP,
POOP_CLEANUP_XP,
calculateActionXP,
calculateInventoryActionXP,
applyXPGain,
formatXPGain,
} from './lib/blobbi-xp';
// Streak tracking
export {
calculateStreakUpdate,
getStreakTagUpdates,
needsStreakUpdate,
getStreakStatus,
} from './lib/blobbi-streak';
export type {
StreakUpdateResult,
StreakTagUpdates,
} from './lib/blobbi-streak';
export { useBlobbiCareActivity } from './hooks/useBlobbiCareActivity';
export type {
UseBlobbiCareActivityParams,
CareActivityResult,
} from './hooks/useBlobbiCareActivity';
@@ -1,548 +0,0 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
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, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Item-based care actions (use a shop catalog item on the companion)
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (item-based + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
/**
* Mapping from action type to allowed item categories
*/
export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
feed: 'food',
play: 'toy',
clean: 'hygiene',
medicine: 'medicine',
};
/**
* Action metadata for UI display (item-based care actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
label: 'Feed',
description: 'Feed your Blobbi',
icon: '🍎',
},
play: {
label: 'Play',
description: 'Play with your Blobbi',
icon: '⚽',
},
clean: {
label: 'Clean',
description: 'Clean your Blobbi',
icon: '🧼',
},
medicine: {
label: 'Medicine',
description: 'Heal your Blobbi',
icon: '💊',
},
};
/**
* Action metadata for direct actions (no item required)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
label: 'Play Music',
description: 'Play music for your Blobbi',
icon: '🎵',
},
sing: {
label: 'Sing',
description: 'Sing to your Blobbi',
icon: '🎤',
},
};
/**
* Combined action metadata for all action types
*/
export const ALL_ACTION_METADATA: Record<BlobbiAction, { label: string; description: string; icon: string }> = {
...ACTION_METADATA,
...DIRECT_ACTION_METADATA,
};
// ─── Stat Helpers ─────────────────────────────────────────────────────────────
// STAT_MIN and STAT_MAX are imported from @/lib/blobbi (single source of truth)
/**
* Clamp a stat value between STAT_MIN (1) and STAT_MAX (100).
* Safe for undefined values (returns STAT_MIN).
*
* The minimum of 1 (instead of 0) ensures:
* - Blobbi is never in an unrecoverable state
* - Visual feedback shows critical state without being "dead"
* - Recovery is always possible with any healing item
*/
export function clampStat(value: number | undefined): number {
if (value === undefined) return STAT_MIN;
return Math.max(STAT_MIN, Math.min(STAT_MAX, Math.round(value)));
}
/**
* Apply a delta to a stat, clamping the result to STAT_MIN-STAT_MAX.
*/
export function applyStat(current: number | undefined, delta: number): number {
const currentValue = current ?? STAT_MIN;
return clampStat(currentValue + delta);
}
/**
* Apply item effects to current stats.
* Returns a new partial stats object with all affected stats clamped.
* Only modifies stats that have corresponding effects.
*/
export function applyItemEffects(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect
): Partial<BlobbiStats> {
const newStats: Partial<BlobbiStats> = { ...currentStats };
if (effects.hunger !== undefined) {
newStats.hunger = applyStat(currentStats.hunger, effects.hunger);
}
if (effects.happiness !== undefined) {
newStats.happiness = applyStat(currentStats.happiness, effects.happiness);
}
if (effects.energy !== undefined) {
newStats.energy = applyStat(currentStats.energy, effects.energy);
}
if (effects.hygiene !== undefined) {
newStats.hygiene = applyStat(currentStats.hygiene, effects.hygiene);
}
if (effects.health !== undefined) {
newStats.health = applyStat(currentStats.health, effects.health);
}
return newStats;
}
// ─── Egg-Specific Item Helpers ────────────────────────────────────────────────
/**
* The Shell Repair Kit is a special medicine item only usable by eggs.
*/
export const SHELL_REPAIR_KIT_ID = 'med_shell_repair';
/**
* Result of checking if an item can be used by a specific Blobbi stage.
*/
export interface ItemUsabilityResult {
canUse: boolean;
reason?: string;
}
/**
* Check if a specific item can be used by a companion at the given stage.
*
* This is the centralized item usability logic:
* - Shell Repair Kit: Only usable by eggs
* - Food items: Only usable by baby/adult (not eggs)
* - Toy items: Only usable by baby/adult (not eggs)
* - Medicine items (except Shell Repair Kit): Usable by all stages with health effect
* - Hygiene items: Usable by all stages
*
* @param itemId - The shop item ID
* @param stage - The companion's life stage
* @returns Object with canUse boolean and optional reason string
*/
export function canUseItemForStage(
itemId: string,
stage: 'egg' | 'baby' | 'adult'
): ItemUsabilityResult {
const shopItem = getShopItemById(itemId);
if (!shopItem) {
return { canUse: false, reason: 'Item not found' };
}
const isEgg = stage === 'egg';
// Shell Repair Kit special case: only for eggs
if (itemId === SHELL_REPAIR_KIT_ID) {
if (!isEgg) {
return { canUse: false, reason: 'Only usable for eggs' };
}
return { canUse: true };
}
// Food items: not usable by eggs
if (shopItem.type === 'food') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot eat food' };
}
return { canUse: true };
}
// Toy items: not usable by eggs
if (shopItem.type === 'toy') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot use toys' };
}
return { canUse: true };
}
// Medicine items (except Shell Repair Kit): check for health effect
if (shopItem.type === 'medicine') {
if (!hasMedicineEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This medicine has no effect' };
}
return { canUse: true };
}
// Hygiene items: all stages can use
if (shopItem.type === 'hygiene') {
if (!hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This item has no cleaning effect' };
}
return { canUse: true };
}
return { canUse: true };
}
/**
* Get the action type for a given item.
*/
export function getActionForItem(itemId: string): InventoryAction | null {
const shopItem = getShopItemById(itemId);
if (!shopItem) return null;
const typeToAction: Record<string, InventoryAction> = {
food: 'feed',
toy: 'play',
hygiene: 'clean',
medicine: 'medicine',
};
return typeToAction[shopItem.type] ?? null;
}
/**
* Check if a medicine item has any effect on an egg.
*
* Eggs use the standard 3-stat model:
* - health
* - hygiene
* - happiness
*
* Medicine with a health effect will directly affect the egg's health stat.
*/
export function hasMedicineEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.health !== undefined && effects.health !== 0;
}
/**
* Check if a hygiene item has any effect on an egg.
* Hygiene items with a hygiene effect will directly affect the egg's hygiene stat.
*/
export function hasHygieneEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.hygiene !== undefined && effects.hygiene !== 0;
}
/**
* Check if an item has a happiness effect for an egg.
* Some items (like bubble bath) give happiness bonus in addition to primary effects.
*/
export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved catalog item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
quantity: number;
name: string;
icon: string;
type: ShopItemCategory;
effect?: ItemEffect;
}
/**
* Options for filtering catalog items by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
stage?: 'egg' | 'baby' | 'adult';
}
/**
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
* - Shell Repair Kit only appears in medicine modal for eggs
* - For eggs: only items with egg-compatible effects are returned
* - medicine action: only items with health effect
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
_storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
const allItems = getLiveShopItems();
for (const shopItem of allItems) {
if (shopItem.type !== allowedType) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
// For eggs, filter items by egg-compatible effects
if (isEgg) {
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
continue; // Skip medicine without health effect
}
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
continue; // Skip hygiene items without hygiene or happiness effect
}
}
result.push({
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
effect: shopItem.effect,
});
}
return result;
}
/**
* Decrement item quantity in storage array.
* If quantity becomes 0, removes the item entirely.
* Returns a new storage array (immutable).
*/
export function decrementStorageItem(
storage: StorageItem[],
itemId: string,
amount = 1
): StorageItem[] {
const result: StorageItem[] = [];
for (const item of storage) {
if (item.itemId !== itemId) {
result.push(item);
continue;
}
const newQuantity = item.quantity - amount;
if (newQuantity > 0) {
result.push({ ...item, quantity: newQuantity });
}
// If newQuantity <= 0, we don't add it (remove item)
}
return result;
}
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
/**
* Inventory actions that are allowed for eggs.
* Eggs can use: medicine (health), clean (hygiene)
*/
export const EGG_ALLOWED_INVENTORY_ACTIONS: InventoryAction[] = ['medicine', 'clean'];
/**
* Direct actions that are allowed for eggs.
* All direct actions work on eggs.
*/
export const EGG_ALLOWED_DIRECT_ACTIONS: DirectAction[] = ['play_music', 'sing'];
/**
* Inventory actions visible in the egg UI.
* Note: feed, play, sleep are hidden in the UI for eggs but not hard-blocked.
*/
export const EGG_VISIBLE_INVENTORY_ACTIONS: InventoryAction[] = ['clean', 'medicine'];
/**
* All actions visible in the egg UI.
*/
export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_music', 'sing'];
/**
* @deprecated Use EGG_ALLOWED_INVENTORY_ACTIONS instead
*/
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific item action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all item actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
/**
* Check if a companion can use a specific direct action.
* Direct actions (play_music, sing) are available for all stages.
*/
export function canUseDirectAction(_companion: BlobbiCompanion, _action: DirectAction): boolean {
// All stages can use direct actions
return true;
}
/**
* Check if an action should be visible in the UI for a given stage.
* This is for UI filtering only - some actions are hidden but not blocked.
*/
export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action: BlobbiAction): boolean {
if (stage === 'egg') {
return EGG_VISIBLE_ACTIONS.includes(action);
}
return true; // baby and adult see all actions
}
/**
* Check if a companion can use general items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
export function canUseInventoryItems(companion: BlobbiCompanion): boolean {
return GENERAL_ITEM_USABLE_STAGES.includes(companion.stage as typeof GENERAL_ITEM_USABLE_STAGES[number]);
}
/**
* Get a user-friendly message explaining why an action can't be used.
*/
export function getStageRestrictionMessage(companion: BlobbiCompanion, action?: InventoryAction): string | null {
if (companion.stage === 'egg') {
if (action && EGG_ALLOWED_INVENTORY_ACTIONS.includes(action)) {
return null; // Medicine and clean are allowed for eggs
}
return 'Eggs cannot use this item. Wait for your Blobbi to hatch!';
}
return null;
}
// ─── Stats Preview ────────────────────────────────────────────────────────────
/**
* Preview stats after applying an item's effects.
* Useful for showing the user what will happen before confirming.
*/
export function previewStatChanges(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect | undefined
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
if (!effects) return [];
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'energy', 'hygiene', 'health'];
for (const stat of statKeys) {
const delta = effects[stat];
if (delta !== undefined && delta !== 0) {
const current = currentStats[stat] ?? 0;
const after = clampStat(current + delta);
changes.push({ stat, current, after, delta });
}
}
return changes;
}
/**
* Preview stat change for an egg.
* Eggs use the 3-stat model: health, hygiene, happiness.
*/
export type EggStatPreview = { stat: 'health' | 'hygiene' | 'happiness'; current: number; after: number; delta: number };
/**
* Preview medicine effects for an egg.
* Medicine directly affects the egg's health stat.
*/
export function previewMedicineForEgg(
currentHealth: number | undefined,
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects || effects.health === undefined || effects.health === 0) {
return [];
}
const current = currentHealth ?? 100;
const delta = effects.health;
const after = clampStat(current + delta);
return [{ stat: 'health', current, after, delta }];
}
/**
* Preview clean (hygiene) effects for an egg.
* Hygiene items directly affect the egg's hygiene stat.
* May also include happiness bonus if the item has one.
*/
export function previewCleanForEgg(
currentStats: { hygiene?: number; happiness?: number },
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects) return [];
const results: EggStatPreview[] = [];
// Hygiene effect
if (effects.hygiene !== undefined && effects.hygiene !== 0) {
const current = currentStats.hygiene ?? 100;
const delta = effects.hygiene;
const after = clampStat(current + delta);
results.push({ stat: 'hygiene', current, after, delta });
}
// Happiness bonus (some hygiene items like bubble bath give happiness)
if (effects.happiness !== undefined && effects.happiness !== 0) {
const current = currentStats.happiness ?? 100;
const delta = effects.happiness;
const after = clampStat(current + delta);
results.push({ stat: 'happiness', current, after, delta });
}
return results;
}
@@ -1,81 +0,0 @@
// src/blobbi/actions/lib/blobbi-activity-state.ts
import type { SelectedTrack } from '../components/PlayMusicModal';
/**
* Types of inline activities that can be displayed in BlobbiPage
*/
export type InlineActivityType = 'none' | 'music' | 'sing';
// Re-export for convenience
export type { SelectedTrack } from '../components/PlayMusicModal';
/**
* State for the music inline activity
*/
export interface MusicActivityState {
type: 'music';
selection: SelectedTrack;
isPublished: boolean;
}
/**
* State for the sing inline activity
*/
export interface SingActivityState {
type: 'sing';
}
/**
* No active inline activity
*/
export interface NoActivityState {
type: 'none';
}
/**
* Union type for all inline activity states
*/
export type InlineActivityState =
| NoActivityState
| MusicActivityState
| SingActivityState;
/**
* Blobbi reaction state - indicates how Blobbi should visually react
*/
export type BlobbiReactionState =
| 'idle' // No special reaction
| 'listening' // Music is playing, Blobbi is listening
| 'swaying' // Blobbi is swaying to music
| 'singing' // User is singing, Blobbi is engaged
| 'happy'; // General happy reaction
/**
* Helper to create a music activity state
*/
export function createMusicActivity(selection: SelectedTrack): MusicActivityState {
return {
type: 'music',
selection,
isPublished: false,
};
}
/**
* Helper to create a sing activity state
*/
export function createSingActivity(): SingActivityState {
return {
type: 'sing',
};
}
/**
* Helper to create no activity state
*/
export function createNoActivity(): NoActivityState {
return {
type: 'none',
};
}
@@ -1,121 +0,0 @@
// src/blobbi/actions/lib/blobbi-random-lyrics.ts
/**
* Random lyrics for the Sing action.
* These are fun, simple lyrics that users can sing to their Blobbi.
*/
export interface LyricsEntry {
id: string;
title: string;
lines: string[];
}
/**
* Collection of placeholder lyrics for singing to a Blobbi.
* Simple, fun, and appropriate for all ages.
*/
export const BLOBBI_LYRICS: LyricsEntry[] = [
{
id: 'lullaby-1',
title: 'Blobbi Lullaby',
lines: [
'Little Blobbi, close your eyes,',
'Dream of stars up in the skies.',
'Safe and warm, you drift away,',
"We'll play again another day.",
],
},
{
id: 'happy-song-1',
title: 'Happy Blobbi Song',
lines: [
'Blobbi, Blobbi, jump around!',
"You're the happiest friend I've found!",
'Dancing, playing, full of cheer,',
"I'm so glad that you are here!",
],
},
{
id: 'adventure-1',
title: 'Adventure Time',
lines: [
"Let's go on an adventure today,",
'Through the clouds and far away!',
'Mountains high and valleys deep,',
'Memories to always keep.',
],
},
{
id: 'breakfast-song',
title: 'Breakfast Song',
lines: [
'Wake up, wake up, sleepy head,',
"Time to get out of your bed!",
"Breakfast's waiting, fresh and yummy,",
'Food to fill your happy tummy!',
],
},
{
id: 'rainy-day',
title: 'Rainy Day',
lines: [
'Pitter patter on the roof,',
'Rainy days can be so nice.',
"We'll stay cozy, me and you,",
'Watching raindrops, one by two.',
],
},
{
id: 'sunshine-song',
title: 'Sunshine Song',
lines: [
'Good morning, sunshine, bright and warm,',
'A brand new day is being born!',
'Blue sky smiling down on me,',
'Happy as can be, so free!',
],
},
{
id: 'bedtime-1',
title: 'Bedtime Blues',
lines: [
'The moon is up, the stars are bright,',
'Time to say a soft goodnight.',
'Snuggle up and close your eyes,',
'Sweet dreams under starry skies.',
],
},
{
id: 'play-time',
title: 'Play Time',
lines: [
"Bounce and jump and run around,",
"Spin and twirl without a sound!",
"Playing games is so much fun,",
"Laughing underneath the sun!",
],
},
];
/**
* Get a random lyrics entry.
*/
export function getRandomLyrics(): LyricsEntry {
const index = Math.floor(Math.random() * BLOBBI_LYRICS.length);
return BLOBBI_LYRICS[index];
}
/**
* Get all available lyrics entries.
*/
export function getAllLyrics(): LyricsEntry[] {
return BLOBBI_LYRICS;
}
/**
* Format lyrics for display (joined with newlines).
*/
export function formatLyrics(lyrics: LyricsEntry): string {
return lyrics.lines.join('\n');
}
-202
View File
@@ -1,202 +0,0 @@
/**
* Blobbi Care Streak Management
*
* This module provides centralized logic for tracking care streaks on Blobbi companions.
* A streak represents consecutive days of care activity (opening Blobbi page, performing
* care actions, etc.).
*
* Streak Rules:
* - Starts at 1 on first activity
* - Increments when activity happens on the NEXT local calendar day
* - Same-day activity does not increment (at most once per day)
* - Missing 2+ days resets streak to 1
*
* Tags managed:
* - care_streak: The current streak count (positive integer)
* - care_streak_last_at: Unix timestamp (seconds) of last streak update
* - care_streak_last_day: Local calendar day string (YYYY-MM-DD) of last update
*/
import {
getLocalDayString,
getDaysDifference,
type BlobbiCompanion,
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of calculating a streak update.
*/
export interface StreakUpdateResult {
/** Whether the streak was updated (incremented or reset) */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** The new timestamp for care_streak_last_at */
newLastAt: number;
/** The new day string for care_streak_last_day */
newLastDay: string;
/** Description of what happened (for debugging/logging) */
action: 'initialized' | 'incremented' | 'reset' | 'same_day';
}
/**
* Tag updates to apply to the Blobbi event.
* Only present if wasUpdated is true.
* Uses index signature for compatibility with updateBlobbiTags.
*/
export interface StreakTagUpdates {
care_streak: string;
care_streak_last_at: string;
care_streak_last_day: string;
[key: string]: string;
}
// ─── Core Logic ───────────────────────────────────────────────────────────────
/**
* Calculate what the streak should be updated to based on current state and activity.
*
* This is a pure function that calculates the new streak state without side effects.
* Use this to determine if/how the streak should be updated.
*
* @param currentStreak - Current streak value (0 or undefined means no streak yet)
* @param lastDay - The last day string (YYYY-MM-DD) when streak was updated, or undefined
* @param now - Current timestamp (defaults to now)
* @returns StreakUpdateResult describing the update
*/
export function calculateStreakUpdate(
currentStreak: number | undefined,
lastDay: string | undefined,
now: Date = new Date()
): StreakUpdateResult {
const nowTimestamp = Math.floor(now.getTime() / 1000);
const todayString = getLocalDayString(now);
// Case 1: No existing streak - initialize to 1
if (currentStreak === undefined || currentStreak === 0 || !lastDay) {
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'initialized',
};
}
// Case 2: Activity on the same day - no update needed
if (lastDay === todayString) {
return {
wasUpdated: false,
newStreak: currentStreak,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'same_day',
};
}
// Calculate days since last activity
const daysMissed = getDaysDifference(lastDay, todayString);
// Case 3: Next day (1 day difference) - increment streak
if (daysMissed === 1) {
return {
wasUpdated: true,
newStreak: currentStreak + 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'incremented',
};
}
// Case 4: Missed 2+ days - reset to 1
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'reset',
};
}
/**
* Get the tag updates to apply to a Blobbi event for a streak update.
* Returns undefined if no update is needed (same day activity).
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns Tag updates to apply, or undefined if no update needed
*/
export function getStreakTagUpdates(
companion: BlobbiCompanion,
now: Date = new Date()
): StreakTagUpdates | undefined {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
if (!result.wasUpdated) {
return undefined;
}
return {
care_streak: result.newStreak.toString(),
care_streak_last_at: result.newLastAt.toString(),
care_streak_last_day: result.newLastDay,
};
}
/**
* Check if a streak update is needed for the companion.
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns true if the streak should be updated
*/
export function needsStreakUpdate(
companion: BlobbiCompanion,
now: Date = new Date()
): boolean {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
return result.wasUpdated;
}
/**
* Get the current streak status for display purposes.
*
* @param companion - The current Blobbi companion state
* @returns Object with streak info for UI display
*/
export function getStreakStatus(companion: BlobbiCompanion): {
streak: number;
lastDay: string | undefined;
isActive: boolean;
daysSinceLastActivity: number | undefined;
} {
const streak = companion.careStreak ?? 0;
const lastDay = companion.careStreakLastDay;
const today = getLocalDayString();
let daysSinceLastActivity: number | undefined;
let isActive = false;
if (lastDay) {
daysSinceLastActivity = getDaysDifference(lastDay, today);
// Streak is "active" if we've had activity today or yesterday
isActive = daysSinceLastActivity <= 1;
}
return {
streak,
lastDay,
isActive,
daysSinceLastActivity,
};
}
@@ -1,118 +0,0 @@
// src/blobbi/actions/lib/blobbi-track-catalog.ts
/**
* Blobbi Track Catalog
*
* Music tracks for the Blobbi "Play Music" action.
* All tracks are hosted on remote Blossom servers and streamed on-demand.
*
* ## Adding New Tracks
*
* 1. Convert the audio file to M4A (AAC-LC):
* `ffmpeg -i input.m4a -c:a aac -b:a 64k -ar 48000 output.m4a`
* 2. Upload the M4A file to a Blossom server
* 3. Add a new entry to `BLOBBI_TRACK_CATALOG` below
* 4. Set `url` to the full Blossom URL
* 5. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
*
* ## Supported Formats
*
* M4A (AAC-LC) is required for iOS/Safari compatibility and small file size.
*/
export interface BlobbiTrack {
/** Unique identifier for the track (used in state/events) */
id: string;
/** Display title shown in the UI */
title: string;
/** Artist or source attribution */
artist: string;
/** Full URL to the remote audio file (Blossom server) */
url: string;
/** Duration in seconds (for display, get via ffprobe) */
durationSeconds: number;
/** Optional cover art URL */
coverArt?: string;
/** Optional tags for categorization/filtering */
tags?: string[];
}
/**
* Blobbi track catalog.
*
* All tracks are royalty-free/Creative Commons licensed.
* Audio files hosted on remote Blossom servers.
*/
export const BLOBBI_TRACK_CATALOG: BlobbiTrack[] = [
{
id: 'nap_in_the_meadow',
title: 'Nap in the Meadow',
artist: 'Chilltape FM',
url: 'https://blossom.ditto.pub/6be1c95e879187f83af2a661ccac2bd96196f7bc334af44529ede6270b2811fc.m4a',
durationSeconds: 240, // 4:00
tags: ['relaxing', 'nature'],
},
{
id: 'happy_kids',
title: 'Happy Kids',
artist: 'Dmitrii Kolesnikov',
url: 'https://blossom.ditto.pub/94d49abd178aa8afb14737a55e0a7143f6b337f618d74858d011232bb2db845d.m4a',
durationSeconds: 129, // 2:09
tags: ['upbeat', 'fun'],
},
{
id: 'soft_piano',
title: 'Soft Piano',
artist: 'Dmitrii Kolesnikov',
url: 'https://blossom.ditto.pub/5367242d3dc555c77f5c637fd153df1166708a24c5a4c222bb4dcaeabf740743.m4a',
durationSeconds: 124, // 2:04
tags: ['calming', 'sleep'],
},
{
id: 'epic_sacred_light',
title: 'Epic Sacred Light',
artist: 'Ura Megis',
url: 'https://blossom.dreamith.to/c22953791d686605958165fd44a84cd7d9fd3d4423ebf786e47891ed3a82c6db.m4a',
durationSeconds: 223, // 3:43
tags: ['energetic', 'adventure'],
},
{
id: 'split_memories',
title: 'Split Memories',
artist: 'ido berg',
url: 'https://blossom.ditto.pub/57ba2e2122a732449880ae531d4bfac9a580bc19693c7dda735afbfa336b35fe.m4a',
durationSeconds: 153, // 2:33
tags: ['ambient', 'relaxing'],
},
{
id: 'minhas_mensagens',
title: 'Minhas Mensagens',
artist: 'PReis',
url: 'https://blossom.ditto.pub/0945064dc8f946f3392be23629b166e72090cafca7cca865a20b5395dd83ff46.m4a',
durationSeconds: 248, // 4:08
tags: ['ambient', 'relaxing'],
},
];
/**
* Get a track by ID from the catalog
*/
export function getTrackById(id: string): BlobbiTrack | undefined {
return BLOBBI_TRACK_CATALOG.find(track => track.id === id);
}
/**
* Get all tracks from the catalog
*/
export function getAllTracks(): BlobbiTrack[] {
return BLOBBI_TRACK_CATALOG;
}
/**
* Format duration in seconds to MM:SS string
*/
export function formatTrackDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
-135
View File
@@ -1,135 +0,0 @@
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);
}
});
});
-141
View File
@@ -1,141 +0,0 @@
/**
* Blobbi XP (Experience Points) System
*
* This module defines XP values for all Blobbi care actions and provides
* utilities for calculating and applying XP gains.
*
* Design Philosophy:
* - Different actions award different XP to reflect their complexity/value
* - XP values are balanced to encourage variety in care activities
* - Item actions (feed, play, clean, medicine) give varied XP per action type
* - Direct actions (sing, play_music) give moderate XP
* - XP accumulates across all life stages and never resets
*/
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for item-based care actions (feed, play, clean, medicine).
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is critical - highest item XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't require selecting an item.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
sing: 9, // Singing requires more user effort - higher XP
};
/**
* Combined XP lookup for all action types.
* Use this for a unified XP calculation interface.
*/
export const ACTION_XP: Record<BlobbiAction, number> = {
...INVENTORY_ACTION_XP,
...DIRECT_ACTION_XP,
};
/**
* XP awarded for cleaning up poop.
*/
export const POOP_CLEANUP_XP = 5;
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
/**
* Calculate XP gain for a single action.
*
* @param action - The action performed
* @returns XP points earned
*/
export function calculateActionXP(action: BlobbiAction): number {
return ACTION_XP[action] ?? 0;
}
/**
* Calculate XP gain for an item-based care action.
*
* @param action - The action performed
* @param quantity - Number of times performed (always 1 in current usage)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
if (quantity < 1) return 0;
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
return baseXP * quantity;
}
/**
* Apply XP gain to current experience value.
*
* @param currentXP - Current experience points (undefined = 0)
* @param xpGain - XP points to add
* @returns New total XP (never negative)
*/
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
const current = currentXP ?? 0;
const newXP = current + xpGain;
return Math.max(0, newXP);
}
/**
* Get XP gain summary for displaying to the user.
*
* @param action - The action performed
* @param quantity - Number of times the action was performed (always 1 in current usage)
* @returns Object with xpGained and quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
quantity: number = 1
): { xpGained: number; quantity: number } {
const baseXP = ACTION_XP[action] ?? 0;
const xpGained = baseXP * quantity;
return { xpGained, quantity };
}
// ─── XP Display Utilities ─────────────────────────────────────────────────────
/**
* Format XP gain for display in toasts/notifications.
*
* @param xpGained - Amount of XP gained
* @returns Formatted string like "+15 XP"
*/
export function formatXPGain(xpGained: number): string {
if (xpGained <= 0) return '';
return `+${xpGained} XP`;
}
/**
* Get a descriptive message about XP gain.
*
* @param action - The action that earned XP
* @param xpGained - Amount of XP gained
* @param newTotal - New total XP (optional, for "You now have X XP" message)
* @returns Formatted message for user feedback
*/
export function getXPGainMessage(
action: BlobbiAction,
xpGained: number,
newTotal?: number
): string {
if (xpGained <= 0) return '';
const xpText = formatXPGain(xpGained);
if (newTotal !== undefined) {
return `${xpText} earned! Total: ${newTotal} XP`;
}
return `${xpText} earned!`;
}
@@ -1,167 +0,0 @@
/**
* Daily Mission Tracker - Standalone progress tracking utility
*
* Provides a way to record daily mission progress from anywhere
* (hooks, event handlers, etc.) without requiring React context.
*
* Uses a pubkey-scoped in-memory Map. Kind 11125 content JSON is the
* persistent source of truth. Completed missions are persisted by
* `useAwardDailyXp`; intermediate progress resets on page refresh.
*
* Dispatches 'daily-missions-updated' CustomEvent so React hooks re-render.
*/
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import type { DailyMissionAction } from './daily-missions';
import {
getTodayDateString,
needsDailyReset,
createDailyMissionsContent,
trackTally,
trackEvent,
trackEvolutionTally,
trackEvolutionEvent,
} from './daily-missions';
// ─── In-Memory Session Store ──────────────────────────────────────────────────
/**
* Pubkey-scoped session cache. Each logged-in user gets their own entry.
* Cleared on page refresh (intentional — kind 11125 is the persistent store).
*/
const sessionStore = new Map<string, MissionsContent>();
function key(pubkey: string | undefined): string {
return pubkey ?? '';
}
function ensureCurrent(pubkey?: string): MissionsContent {
const current = sessionStore.get(key(pubkey));
if (!needsDailyReset(current)) return current!;
const fresh = createDailyMissionsContent(
getTodayDateString(),
current?.evolution ?? [],
pubkey,
);
sessionStore.set(key(pubkey), fresh);
return fresh;
}
function notify(detail?: Record<string, unknown>): void {
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail }));
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Record a tally-based action (feed, clean, interact, etc.).
*/
export function trackDailyMissionProgress(
action: DailyMissionAction,
count: number = 1,
pubkey?: string,
): void {
const current = ensureCurrent(pubkey);
const updated = trackTally(current, action, count);
sessionStore.set(key(pubkey), updated);
notify({ action, count });
}
/**
* Record an event-based action (take_photo, etc.) with its Nostr event ID.
*/
export function trackDailyMissionEvent(
action: DailyMissionAction,
eventId: string,
pubkey?: string,
): void {
const current = ensureCurrent(pubkey);
const updated = trackEvent(current, action, eventId);
sessionStore.set(key(pubkey), updated);
notify({ action, eventId });
}
/**
* Track multiple tally actions at once.
*/
export function trackMultipleDailyMissionActions(
actions: DailyMissionAction[],
pubkey?: string,
): void {
let current = ensureCurrent(pubkey);
for (const action of actions) {
current = trackTally(current, action, 1);
}
sessionStore.set(key(pubkey), current);
notify({ actions });
}
// ─── Evolution Mission Tracking ───────────────────────────────────────────────
/**
* Increment tally for an evolution mission (e.g. interactions).
* No-ops if pubkey missing or session store empty.
*/
export function trackEvolutionMissionTally(
missionId: string,
count: number = 1,
pubkey?: string,
): void {
const current = sessionStore.get(key(pubkey));
if (!current) return;
const updated = trackEvolutionTally(current, missionId, count);
sessionStore.set(key(pubkey), updated);
notify({ evolution: true, missionId, count });
}
/**
* Append a Nostr event ID to an evolution mission (e.g. create_theme).
* Deduplicates by event ID. No-ops if pubkey missing or session store empty.
*/
export function trackEvolutionMissionEvent(
missionId: string,
eventId: string,
pubkey?: string,
): void {
const current = sessionStore.get(key(pubkey));
if (!current) return;
const updated = trackEvolutionEvent(current, missionId, eventId);
sessionStore.set(key(pubkey), updated);
notify({ evolution: true, missionId, eventId });
}
// ─── Storage Access ──────────────────────────────────────────────────────────
/** Read current session state for a pubkey. */
export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined {
return sessionStore.get(key(pubkey));
}
/**
* Ensure the session store has an entry for the given pubkey.
* If the store is empty or needs a daily reset, a fresh entry is created.
* Returns the current (possibly newly-created) MissionsContent.
*
* Use this before writing evolution missions into the store, to avoid
* silent no-ops when the store hasn't been hydrated yet.
*/
export function ensureSessionStore(pubkey?: string): MissionsContent {
return ensureCurrent(pubkey);
}
/** Write state to session store for a pubkey. */
export function writeMissionsToStorage(missions: MissionsContent, pubkey?: string): void {
sessionStore.set(key(pubkey), missions);
}
/**
* Hydrate the session store from kind 11125 persisted data.
* Called once on mount / account switch when the session store is empty.
* No-op if the store already has data for this pubkey.
*/
export function hydrateFromPersisted(missions: MissionsContent, pubkey: string): void {
if (sessionStore.has(pubkey)) return;
sessionStore.set(pubkey, missions);
}
-453
View File
@@ -1,453 +0,0 @@
/**
* Daily Missions System for Blobbi
*
* Defines the daily mission pool, selection logic, and state management.
* Missions use the tally/event model from missions.ts:
* - Tally missions: { id, target, count }
* - Event missions: { id, target, events }
* Completion is derived: count >= target or events.length >= target.
* No explicit completed/claimed flags.
*/
import type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
import { isTallyMission, isEventMission, isMissionComplete } from '@/blobbi/core/lib/missions';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Actions that can trigger daily mission progress.
* Tally actions increment a counter. Event actions append an event ID.
*/
export type DailyMissionAction =
| 'interact' // Any care interaction (tally)
| 'feed' // Feeding action (tally)
| 'clean' // Cleaning action (tally)
| 'sing' // Sing direct action (tally)
| 'play_music' // Play music direct action (tally)
| 'sleep' // Put Blobbi to sleep (tally)
| 'take_photo' // Take a photo (event)
| 'medicine'; // Give medicine (tally)
/** Whether a mission action tracks events or tallies */
export type MissionTrackingType = 'tally' | 'event';
/** Blobbi stage type for filtering missions */
export type BlobbiStage = 'egg' | 'baby' | 'adult';
/**
* Definition of a daily mission in the pool.
* This is the static template -- not the runtime state.
*/
export interface DailyMissionDefinition {
/** Unique identifier */
id: string;
/** Display title */
title: string;
/** Description of what to do */
description: string;
/** Action that triggers progress */
action: DailyMissionAction;
/** Number of times the action must be performed */
target: number;
/** Whether this mission tracks events or tallies */
tracking: MissionTrackingType;
/** XP reward for completing this mission */
xp: number;
/** Selection weight (higher = more likely) */
weight: number;
/** Required stages to show this mission */
requiredStages?: BlobbiStage[];
}
// ─── Constants ────────────────────────────────────────────────────────────────
/** Maximum number of mission rerolls allowed per day */
export const MAX_DAILY_REROLLS = 3;
/** Number of daily missions selected each day */
export const DAILY_MISSION_COUNT = 3;
/** XP bonus for completing all daily missions */
export const DAILY_BONUS_XP = 50;
// ─── Mission Pool ─────────────────────────────────────────────────────────────
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
// ── Baby/Adult only ──────────────────────────────────────────────────────
{
id: 'interact_3', title: 'Quick Care',
description: 'Interact with your Blobbi 3 times',
action: 'interact', target: 3, tracking: 'tally', xp: 15, weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'interact_6', title: 'Attentive Caretaker',
description: 'Interact with your Blobbi 6 times',
action: 'interact', target: 6, tracking: 'tally', xp: 30, weight: 8,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_1', title: 'Snack Time',
description: 'Feed your Blobbi once',
action: 'feed', target: 1, tracking: 'tally', xp: 10, weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_2', title: 'Hungry Blobbi',
description: 'Feed your Blobbi 2 times',
action: 'feed', target: 2, tracking: 'tally', xp: 20, weight: 8,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_3', title: 'Feast Day',
description: 'Feed your Blobbi 3 times',
action: 'feed', target: 3, tracking: 'tally', xp: 35, weight: 5,
requiredStages: ['baby', 'adult'],
},
{
id: 'sleep_1', title: 'Nap Time',
description: 'Put your Blobbi to sleep',
action: 'sleep', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_1', title: 'Snapshot',
description: 'Take a photo of your Blobbi',
action: 'take_photo', target: 1, tracking: 'event', xp: 25, weight: 4,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_2', title: 'Photo Album',
description: 'Take 2 photos of your Blobbi',
action: 'take_photo', target: 2, tracking: 'event', xp: 40, weight: 2,
requiredStages: ['baby', 'adult'],
},
// ── All stages ───────────────────────────────────────────────────────────
{
id: 'clean_1', title: 'Quick Cleanup',
description: 'Clean your Blobbi once',
action: 'clean', target: 1, tracking: 'tally', xp: 10, weight: 10,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'clean_2', title: 'Squeaky Clean',
description: 'Clean your Blobbi 2 times',
action: 'clean', target: 2, tracking: 'tally', xp: 20, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'sing_1', title: 'Sing Along',
description: 'Sing a song to your Blobbi',
action: 'sing', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'sing_2', title: 'Karaoke Session',
description: 'Sing 2 songs to your Blobbi',
action: 'sing', target: 2, tracking: 'tally', xp: 25, weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'play_music_1', title: 'DJ Time',
description: 'Play a song for your Blobbi',
action: 'play_music', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'play_music_2', title: 'Music Marathon',
description: 'Play 2 songs for your Blobbi',
action: 'play_music', target: 2, tracking: 'tally', xp: 25, weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'medicine_1', title: 'Health Check',
description: 'Give medicine to your Blobbi',
action: 'medicine', target: 1, tracking: 'tally', xp: 20, weight: 5,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'medicine_2', title: 'Doctor Visit',
description: 'Give medicine to your Blobbi 2 times',
action: 'medicine', target: 2, tracking: 'tally', xp: 35, weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
];
// ─── Lookup ──────────────────────────────────────────────────────────────────
const POOL_BY_ID = new Map(DAILY_MISSION_POOL.map((d) => [d.id, d]));
/** Look up a mission definition by ID */
export function getDefinition(id: string): DailyMissionDefinition | undefined {
return POOL_BY_ID.get(id);
}
// ─── Date Utilities ──────────────────────────────────────────────────────────
/** YYYY-MM-DD in local timezone */
export function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/** Whether the missions content needs a daily reset */
export function needsDailyReset(missions: MissionsContent | undefined): boolean {
if (!missions) return true;
return missions.date !== getTodayDateString();
}
// ─── Selection ───────────────────────────────────────────────────────────────
/** Seeded PRNG (Mulberry32) */
function seededRandom(seed: number): () => number {
return function () {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function generateDailySeed(dateString: string, pubkey?: string): number {
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash) + input.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash);
}
function isMissionAvailableForStages(def: DailyMissionDefinition, stages: BlobbiStage[]): boolean {
const required = def.requiredStages ?? ['baby', 'adult'];
return required.some((s) => stages.includes(s));
}
/**
* Select N missions deterministically from the pool.
* Seeded by date + pubkey so the same user gets the same missions for a given day.
*/
export function selectDailyMissions(
count: number,
dateString: string,
pubkey?: string,
availableStages?: BlobbiStage[],
): DailyMissionDefinition[] {
const stages = availableStages ?? ['baby', 'adult'];
const eligible = DAILY_MISSION_POOL.filter((m) => isMissionAvailableForStages(m, stages));
if (eligible.length === 0) return [];
const random = seededRandom(generateDailySeed(dateString, pubkey));
const available = [...eligible];
const selected: DailyMissionDefinition[] = [];
while (selected.length < count && available.length > 0) {
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
let pick = random() * totalWeight;
let idx = 0;
for (let i = 0; i < available.length; i++) {
pick -= available[i].weight;
if (pick <= 0) { idx = i; break; }
}
selected.push(available[idx]);
available.splice(idx, 1);
}
return selected;
}
// ─── Mission Instantiation ───────────────────────────────────────────────────
/** Create a fresh Mission from a definition */
export function createMission(def: DailyMissionDefinition): Mission {
if (def.tracking === 'event') {
return { id: def.id, target: def.target, events: [] } satisfies EventMission;
}
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
}
/** Create a fresh MissionsContent for a new day, preserving evolution missions */
export function createDailyMissionsContent(
dateString: string,
existingEvolution: Mission[],
pubkey?: string,
availableStages?: BlobbiStage[],
): MissionsContent {
const defs = selectDailyMissions(DAILY_MISSION_COUNT, dateString, pubkey, availableStages);
return {
date: dateString,
daily: defs.map(createMission),
evolution: existingEvolution,
rerolls: MAX_DAILY_REROLLS,
};
}
// ─── Progress Tracking ───────────────────────────────────────────────────────
/**
* Increment tally for all daily missions matching the given action.
* Returns a new missions content (immutable).
*/
export function trackTally(
missions: MissionsContent,
action: DailyMissionAction,
incrementBy: number = 1,
): MissionsContent {
const updated = missions.daily.map((m) => {
const def = POOL_BY_ID.get(m.id);
if (!def || def.action !== action) return m;
if (!isTallyMission(m)) return m;
if (m.count >= m.target) return m; // already complete
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
});
return { ...missions, daily: updated };
}
/**
* Append an event ID to a daily mission.
* Deduplicates by event ID. Returns new missions content.
*/
export function trackEvent(
missions: MissionsContent,
action: DailyMissionAction,
eventId: string,
): MissionsContent {
const updated = missions.daily.map((m) => {
const def = POOL_BY_ID.get(m.id);
if (!def || def.action !== action) return m;
if (!isEventMission(m)) return m;
if (m.events.length >= m.target) return m; // already complete
if (m.events.includes(eventId)) return m; // dedup
return { ...m, events: [...m.events, eventId] };
});
return { ...missions, daily: updated };
}
/**
* Track progress for an evolution mission by tally.
*/
export function trackEvolutionTally(
missions: MissionsContent,
missionId: string,
incrementBy: number = 1,
): MissionsContent {
const updated = missions.evolution.map((m) => {
if (m.id !== missionId) return m;
if (!isTallyMission(m)) return m;
if (m.count >= m.target) return m;
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
});
return { ...missions, evolution: updated };
}
/**
* Append an event ID to an evolution mission.
*/
export function trackEvolutionEvent(
missions: MissionsContent,
missionId: string,
eventId: string,
): MissionsContent {
const updated = missions.evolution.map((m) => {
if (m.id !== missionId) return m;
if (!isEventMission(m)) return m;
if (m.events.length >= m.target) return m;
if (m.events.includes(eventId)) return m;
return { ...m, events: [...m.events, eventId] };
});
return { ...missions, evolution: updated };
}
// ─── Completion Queries ──────────────────────────────────────────────────────
/** Whether all daily missions are complete */
export function areAllDailyComplete(missions: MissionsContent): boolean {
return missions.daily.length > 0 && missions.daily.every(isMissionComplete);
}
/** Whether all evolution missions are complete */
export function areAllEvolutionComplete(missions: MissionsContent): boolean {
return missions.evolution.length > 0 && missions.evolution.every(isMissionComplete);
}
/** Total XP available from today's daily missions (including bonus if all complete) */
export function totalDailyXp(missions: MissionsContent): number {
const base = missions.daily.reduce((sum, m) => {
const def = POOL_BY_ID.get(m.id);
return sum + (def && isMissionComplete(m) ? def.xp : 0);
}, 0);
const bonus = areAllDailyComplete(missions) ? DAILY_BONUS_XP : 0;
return base + bonus;
}
/** XP earned by a specific daily mission (0 if incomplete or unknown) */
export function missionXp(missionId: string, mission: Mission): number {
const def = POOL_BY_ID.get(missionId);
if (!def || !isMissionComplete(mission)) return 0;
return def.xp;
}
// ─── Reroll ──────────────────────────────────────────────────────────────────
/**
* Select a replacement mission not already in the current set.
* Uses Math.random (rerolls should feel random, not deterministic).
*/
export function selectReplacementMission(
currentMissions: Mission[],
missionToReplaceId: string,
availableStages?: BlobbiStage[],
): DailyMissionDefinition | null {
const stages = availableStages ?? ['baby', 'adult'];
const excludedIds = new Set(currentMissions.map((m) => m.id));
const eligible = DAILY_MISSION_POOL.filter((m) =>
m.id !== missionToReplaceId &&
!excludedIds.has(m.id) &&
isMissionAvailableForStages(m, stages),
);
if (eligible.length === 0) return null;
const totalWeight = eligible.reduce((sum, m) => sum + m.weight, 0);
let pick = Math.random() * totalWeight;
for (const def of eligible) {
pick -= def.weight;
if (pick <= 0) return def;
}
return eligible[0];
}
/**
* Reroll a daily mission. Returns updated missions content or null if not possible.
*/
export function rerollMission(
missions: MissionsContent,
missionId: string,
availableStages?: BlobbiStage[],
): MissionsContent | null {
if (missions.rerolls <= 0) return null;
const idx = missions.daily.findIndex((m) => m.id === missionId);
if (idx === -1) return null;
const existing = missions.daily[idx];
if (isMissionComplete(existing)) return null; // can't reroll completed
const replacement = selectReplacementMission(missions.daily, missionId, availableStages);
if (!replacement) return null;
const updatedDaily = [...missions.daily];
updatedDaily[idx] = createMission(replacement);
return {
...missions,
daily: updatedDaily,
rerolls: missions.rerolls - 1,
};
}
// Re-export mission utilities for convenience
export { isTallyMission, isEventMission, isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
export type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
@@ -1,167 +0,0 @@
/**
* Evolution Missions - Static definitions for hatch and evolve tasks.
*
* These are the lifecycle tasks that gate stage transitions (egg→baby, baby→adult).
* Progress is tracked in `MissionsContent.evolution[]` on kind 11125, using the
* same TallyMission / EventMission model as daily missions.
*
* Unlike daily missions, evolution missions:
* - Are populated when incubation/evolution starts
* - Are cleared when the stage transition completes (or is cancelled)
* - Are NOT deterministically seeded — the full set is always used
*/
import type { Mission, TallyMission, EventMission } from '@/blobbi/core/lib/missions';
// ─── Shared Helpers ──────────────────────────────────────────────────────────
/** Find an evolution mission by ID in the given array. */
export function findEvolutionMission(evolution: Mission[], id: string): Mission | undefined {
return evolution.find((m) => m.id === id);
}
// ─── Tracking Type ───────────────────────────────────────────────────────────
export type EvolutionTrackingType = 'tally' | 'event';
// ─── Definition ──────────────────────────────────────────────────────────────
export interface EvolutionMissionDefinition {
/** Unique identifier (matches Mission.id) */
id: string;
/** Display title */
title: string;
/** Description shown in the UI */
description: string;
/** Number of times the action must be performed / events collected */
target: number;
/** Whether this mission tracks a counter or event IDs */
tracking: EvolutionTrackingType;
/** UI action hint */
action?: 'navigate' | 'open_modal' | 'external_link';
/** Target for the action */
actionTarget?: string;
/** Button label */
actionLabel?: string;
}
// ─── Hatch Mission Pool ──────────────────────────────────────────────────────
export const HATCH_MISSIONS: readonly EvolutionMissionDefinition[] = [
{
id: 'create_theme',
title: 'Create Theme',
description: 'Create a custom theme for your profile',
target: 1,
tracking: 'event',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
},
{
id: 'color_moment',
title: 'Color Moment',
description: 'Share a color moment on espy',
target: 1,
tracking: 'event',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
},
{
id: 'create_post',
title: 'Create Post',
description: 'Share a post with the #blobbi hashtag',
target: 1,
tracking: 'event',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
},
{
id: 'interactions',
title: 'Interact with Blobbi',
description: 'Care for your Blobbi 7 times',
target: 7,
tracking: 'tally',
},
] as const;
// ─── Evolve Mission Pool ─────────────────────────────────────────────────────
export const EVOLVE_MISSIONS: readonly EvolutionMissionDefinition[] = [
{
id: 'create_themes',
title: 'Create Themes',
description: 'Create 3 custom themes',
target: 3,
tracking: 'event',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
},
{
id: 'color_moments',
title: 'Color Moments',
description: 'Share 3 color moments on espy',
target: 3,
tracking: 'event',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
},
{
id: 'interactions',
title: 'Interact with Blobbi',
description: 'Care for your Blobbi 21 times',
target: 21,
tracking: 'tally',
},
{
id: 'edit_profile',
title: 'Edit Your Profile',
description: 'Update your profile info or customize your profile tabs',
target: 1,
tracking: 'event',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Profile',
},
] as const;
// ─── Instantiation ───────────────────────────────────────────────────────────
/** Create a fresh Mission from an evolution definition */
export function createEvolutionMission(def: EvolutionMissionDefinition): Mission {
if (def.tracking === 'event') {
return { id: def.id, target: def.target, events: [] } satisfies EventMission;
}
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
}
/** Create the full set of hatch missions (for starting incubation) */
export function createHatchMissions(): Mission[] {
return HATCH_MISSIONS.map(createEvolutionMission);
}
/** Create the full set of evolve missions (for starting evolution) */
export function createEvolveMissions(): Mission[] {
return EVOLVE_MISSIONS.map(createEvolutionMission);
}
// ─── Constants (re-exported for backward compat) ─────────────────────────────
/** Required interactions to complete the hatch interactions task */
export const HATCH_REQUIRED_INTERACTIONS = 7;
/** Required interactions to complete the evolve interactions task */
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
/** Required color moments for evolve task */
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
/** Stat threshold for evolve dynamic task (all stats >= 80) */
export const EVOLVE_STAT_THRESHOLD = 80;
-68
View File
@@ -1,68 +0,0 @@
/**
* Centralized item-use cooldown tracking.
*
* Module-level singleton shared by every item-use path
* (dashboard, companion layer, shop modal, falling items).
*
* Keyed by item type ID (e.g. "food_apple"), not instance IDs.
* Separate durations for success (short) and failure (longer).
* Built-in subscriber system for React via useSyncExternalStore.
*/
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown after a successful item use (ms). */
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
/** Cooldown after a failed item use (ms). */
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
// ─── Singleton State ──────────────────────────────────────────────────────────
interface CooldownEntry {
expiresAt: number;
timerId: ReturnType<typeof setTimeout>;
}
const cooldowns = new Map<string, CooldownEntry>();
const subscribers = new Set<() => void>();
function notify(): void {
subscribers.forEach((cb) => cb());
}
// ─── Public API ───────────────────────────────────────────────────────────────
/** Check whether an item is currently on cooldown. */
export function isItemOnCooldown(itemId: string): boolean {
const entry = cooldowns.get(itemId);
if (!entry) return false;
if (Date.now() >= entry.expiresAt) {
clearTimeout(entry.timerId);
cooldowns.delete(itemId);
return false;
}
return true;
}
/** Put an item on cooldown. Notifies subscribers on start and expiry. */
export function setItemCooldown(itemId: string, success: boolean): void {
const prev = cooldowns.get(itemId);
if (prev) clearTimeout(prev.timerId);
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
const timerId = setTimeout(() => {
cooldowns.delete(itemId);
notify();
}, ms);
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
notify();
}
/** Subscribe to cooldown state changes. Returns unsubscribe function. */
export function subscribeCooldowns(callback: () => void): () => void {
subscribers.add(callback);
return () => { subscribers.delete(callback); };
}
@@ -1,100 +0,0 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Grupo das pétalas com rotação -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="10s"
repeatCount="indefinite" />
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
</g>
<!-- Grupo das partículas giratórias -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="20s"
repeatCount="indefinite" />
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.8" />
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.6" />
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.7" />
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.5" />
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.9" />
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="88" cy="105" r="8" fill="white" />
<circle cx="112" cy="105" r="8" fill="white" />
<!-- Pupils (pupil + highlights) -->
<circle cx="88" cy="105" r="5" fill="#1f2937" />
<circle cx="112" cy="105" r="5" fill="#1f2937" />
<circle cx="90" cy="103" r="2" fill="white" />
<circle cx="114" cy="103" r="2" fill="white" />
<!-- Mouth -->
<path d="M 90 120 Q 100 128 110 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Bochechas -->
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<!-- Gradientes -->
<defs>
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fed7d7" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#e0e7ff" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dcfce7" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dbeafe" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

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

Before

Width:  |  Height:  |  Size: 4.1 KiB

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

Before

Width:  |  Height:  |  Size: 4.4 KiB

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

Before

Width:  |  Height:  |  Size: 4.3 KiB

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

Before

Width:  |  Height:  |  Size: 3.1 KiB

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

Before

Width:  |  Height:  |  Size: 3.2 KiB

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

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

Before

Width:  |  Height:  |  Size: 4.7 KiB

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

Before

Width:  |  Height:  |  Size: 2.2 KiB

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

Before

Width:  |  Height:  |  Size: 2.4 KiB

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

Before

Width:  |  Height:  |  Size: 4.5 KiB

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

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

Before

Width:  |  Height:  |  Size: 3.9 KiB

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

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

Before

Width:  |  Height:  |  Size: 3.8 KiB

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

Before

Width:  |  Height:  |  Size: 3.8 KiB

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

Before

Width:  |  Height:  |  Size: 5.7 KiB

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

Before

Width:  |  Height:  |  Size: 5.5 KiB

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

Before

Width:  |  Height:  |  Size: 6.1 KiB

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

Before

Width:  |  Height:  |  Size: 6.2 KiB

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

Before

Width:  |  Height:  |  Size: 3.3 KiB

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

Before

Width:  |  Height:  |  Size: 3.5 KiB

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

Before

Width:  |  Height:  |  Size: 4.1 KiB

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

Before

Width:  |  Height:  |  Size: 4.2 KiB

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

Before

Width:  |  Height:  |  Size: 3.1 KiB

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

Before

Width:  |  Height:  |  Size: 3.2 KiB

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

Before

Width:  |  Height:  |  Size: 3.7 KiB

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

Before

Width:  |  Height:  |  Size: 4.2 KiB

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

Before

Width:  |  Height:  |  Size: 4.3 KiB

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

Before

Width:  |  Height:  |  Size: 4.2 KiB

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

Before

Width:  |  Height:  |  Size: 3.8 KiB

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

Before

Width:  |  Height:  |  Size: 4.1 KiB

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

Before

Width:  |  Height:  |  Size: 2.3 KiB

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

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