Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering

# Conflicts:
#	src/pages/BadgesPage.tsx
This commit is contained in:
Chad Curtis
2026-03-28 05:00:19 -05:00
301 changed files with 58538 additions and 1950 deletions
+237
View File
@@ -0,0 +1,237 @@
---
name: release
description: Publish a new app release with versioning, changelog, native build files, and git tagging. Triggered by "publish a new release" or similar requests.
---
# Release Skill
This skill guides you through publishing a new release of the app. It handles version bumping, changelog generation, native build file updates, and git tagging/pushing.
## Overview
- **Version format**: Marketing version (X.Y.Z), starting from 2.0.0. **This is NOT semver.** Version numbers are chosen based on how the release looks to end users, not based on API compatibility or breaking changes. Think of it like an app store version -- the number reflects the perceived significance of the update to a regular user.
- **Version source of truth**: `package.json` `version` field
- **Changelog**: `CHANGELOG.md` in repo root, using [Keep a Changelog](https://keepachangelog.com/) format
- **Version bumping**:
- **Patch (Z)**: Most releases. Bug fixes, tweaks, internal improvements, anything a user wouldn't specifically notice or seek out.
- **Minor (Y)**: Releases with headline features -- things worth announcing. A user should be able to look at the minor bump and think "oh, something new happened."
- **Major (X)**: Only when the user explicitly requests it (milestones, rebrands, major redesigns)
- **CI trigger**: Pushing a version tag (`v2.1.0`) triggers the CI pipeline to build APKs, create a GitLab release, and publish to Zapstore
## Release Procedure
Follow these steps in order. Do NOT skip any step.
### Step 1: Required Reading
Before writing any release notes, you MUST read these pages to understand the product context, voice, and values:
1. **https://soapbox.pub/** -- Soapbox company overview and product suite
2. **https://soapbox.pub/ditto** -- Ditto product page with feature descriptions and positioning
3. **https://about.ditto.pub/** -- Ditto documentation landing page
4. **https://about.ditto.pub/philosophy** -- Ditto's design philosophy, core symbolism, and manifesto
These pages define what Ditto is, how it's positioned, and the tone of voice to use. Changelog entries should reflect this identity: fun, rebellious, user-focused, emphasizing freedom and self-expression. Avoid dry technical jargon -- write for people who use the app, not developers.
### Step 2: Pre-flight Checks
```bash
# Ensure working directory is clean
git status
# Ensure we're on main branch
git branch --show-current
# Run the full test suite
npm run test
```
- If the working directory has uncommitted changes, ask the user whether to commit them first or abort.
- If not on `main`, warn the user and ask whether to proceed.
- If tests fail, stop and fix the issues before continuing.
### Step 3: Determine What Changed
```bash
# Get the current version from package.json
node -p "require('./package.json').version"
# Get commits since the last version tag
git log v$(node -p "require('./package.json').version")..HEAD --oneline
```
- If there are no commits since the last tag, inform the user there is nothing to release and stop.
- Review the commit list to understand the scope of changes.
### Step 4: Decide the Version Bump
Analyze the commits from Step 3 and determine the appropriate bump level:
| Bump | When to use | Example |
|------|-------------|---------|
| **Patch** | Bug fixes, minor tweaks, dependency updates, small UI polish, internal tooling, developer-facing pages, CI/build changes, settings/admin screens | 2.0.0 -> 2.0.1 |
| **Minor** | Significant new product features that change how users interact with the app -- the kind of thing you'd highlight in an app store update or announce on social media (e.g., new content type support, DM redesign, new social features, theme system overhaul) | 2.0.1 -> 2.1.0 |
| **Major** | ONLY when the user explicitly instructs a major bump | 2.1.0 -> 3.0.0 |
**Default to patch** when in doubt. The bar for a minor bump is high -- ask yourself: "Would a regular user notice and care about this change?" If the answer is no, it's a patch. Internal pages (changelog, settings, about screens), infrastructure improvements, CI fixes, and developer tooling are always patch-level regardless of whether they technically add a new page or screen.
When bumping minor, reset patch to 0 (e.g., 2.0.3 -> 2.1.0).
When bumping major, reset minor and patch to 0 (e.g., 2.3.1 -> 3.0.0).
### Step 5: Write the Changelog Entry
Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading.
**Format:**
```markdown
## [X.Y.Z] - YYYY-MM-DD
### Added
- Description of new features
### Changed
- Description of changes to existing features
### Fixed
- Description of bug fixes
### Removed
- Description of removed features
```
**Rules:**
- Only include categories that have entries (omit empty categories)
- Write **user-facing descriptions**, not raw commit messages
- Keep descriptions concise -- one line per change
- Group related commits into single entries where appropriate
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
- Focus on what the user sees/experiences, not internal implementation details
- Use the current date in YYYY-MM-DD format
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
### Step 6: Update Version in All Files
Update the version string in these files:
#### 6a. `package.json`
Update the `version` field:
```json
"version": "X.Y.Z"
```
#### 6b. `android/app/build.gradle`
Update `versionName` (line 17). Do NOT change `versionCode` -- that is managed by CI:
```groovy
versionName "X.Y.Z"
```
#### 6c. `ios/App/App.xcodeproj/project.pbxproj`
Update `MARKETING_VERSION` in all occurrences (Debug + Release configs):
```
MARKETING_VERSION = X.Y.Z;
```
**Important:** All lines containing `MARKETING_VERSION` must be updated to the same value. Use a replaceAll operation.
Do NOT change `CURRENT_PROJECT_VERSION` -- it stays at `1` (may be managed separately for App Store submissions in the future).
### Step 7: Copy Changelog to Public Directory
The changelog is served at runtime by the app from the `public/` directory. After updating `CHANGELOG.md`, copy it:
```bash
cp CHANGELOG.md public/CHANGELOG.md
```
### Step 8: Pull Latest Changes
Before committing the release, pull the latest changes from the remote to ensure the release commit sits on top of the latest code. This **must** happen before committing and tagging.
```bash
git pull origin main
```
**CRITICAL**: Always use `git pull` (merge), NEVER `git pull --rebase`. Rebasing rewrites commit hashes, which would orphan any tag pointing to the original commit. Since version tags are often protected on the remote and cannot be deleted or updated, a broken tag cannot be easily fixed.
If there are merge conflicts with the pulled changes, resolve them before proceeding.
### Step 9: Commit the Release
```bash
git add package.json CHANGELOG.md public/CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj
git commit -m "release: vX.Y.Z"
```
### Step 10: Tag the Release
```bash
git tag vX.Y.Z
```
The tag format is `v` followed by the semver version with no suffix. Examples: `v2.0.0`, `v2.1.0`, `v2.1.1`.
### Step 11: Push
```bash
git push origin main vX.Y.Z
```
**CRITICAL**: Push only the specific tag being released. NEVER use `--tags` -- that pushes ALL local tags, including stale or deleted ones.
This triggers the GitLab CI pipeline which will:
1. Build a signed Android APK and AAB
2. Create a GitLab Release with download links
3. Publish the APK to Zapstore
### Step 12: Confirm
After pushing, inform the user:
- The new version number
- A brief summary of what was released
- That CI will handle building and publishing the artifacts
## File Reference
| File | What to update | Notes |
|------|---------------|-------|
| `package.json` | `version` field | Source of truth for the version |
| `CHANGELOG.md` | Prepend new section | User-facing changelog |
| `public/CHANGELOG.md` | Copy from `CHANGELOG.md` | Served at runtime by the app |
| `android/app/build.gradle` | `versionName` on line 17 | `versionCode` is managed by CI |
| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (all occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 |
## CI Pipeline
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs:
1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build
2. **release**: Creates a GitLab Release with the changelog content and download links
3. **publish-zapstore**: Publishes the APK to Zapstore
## Troubleshooting
### "Nothing to release"
If `git log` shows no commits since the last tag, there genuinely is nothing to release.
### Tests fail
Fix the failing tests before proceeding. The release must not contain broken code.
### Wrong version bumped
If you tagged the wrong version and haven't pushed yet:
```bash
git tag -d vX.Y.Z # delete the local tag
git reset --soft HEAD~1 # undo the commit but keep changes staged
```
Then redo steps 4-10 with the correct version.
### Already pushed a bad release
This requires manual intervention. Inform the user and suggest they delete the tag and release from GitLab manually, then re-run the release process.
+18 -10
View File
@@ -46,7 +46,7 @@ build-apk:
timeout: 15 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+-/
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_HOME: /opt/android-sdk
@@ -97,8 +97,7 @@ build-apk:
storeFile=my-upload-key.keystore
EOF
script:
# Extract version from git tag (e.g., v2026.03.16+974041a)
# versionName: full calver+sha string (e.g., 2026.03.16+974041a)
# Extract semver version from git tag (e.g., v2.1.0 -> 2.1.0)
- TAG="${CI_COMMIT_TAG#v}"
- VERSION_NAME="${TAG}"
- VERSION_CODE="${CI_PIPELINE_IID}"
@@ -152,20 +151,29 @@ release:
needs:
- build-apk
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+-/
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- echo "Creating release for $CI_COMMIT_TAG"
# Extract the latest changelog section for the release description.
# Reads from "## [version]" to the next "## [" or end of file.
- |
VERSION="${CI_COMMIT_TAG#v}"
RELEASE_NOTES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md)
if [ -z "$RELEASE_NOTES" ]; then
RELEASE_NOTES="Ditto ${CI_COMMIT_TAG}"
fi
- echo "$RELEASE_NOTES" > release-notes.md
release:
tag_name: $CI_COMMIT_TAG
name: $CI_COMMIT_TAG
description: "Ditto $CI_COMMIT_TAG"
description: './release-notes.md'
assets:
links:
- name: "Ditto-${CI_COMMIT_TAG}.apk"
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk"
- name: Ditto-${CI_COMMIT_TAG}.apk
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk
link_type: package
- name: "Ditto-${CI_COMMIT_TAG}.aab"
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab"
- name: Ditto-${CI_COMMIT_TAG}.aab
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab
link_type: package
publish-zapstore:
@@ -174,7 +182,7 @@ publish-zapstore:
needs:
- build-apk
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+-/
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
SIGN_WITH: $ZAPSTORE_BUNKER_URL
script:
+63 -1
View File
@@ -12,6 +12,7 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
- **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality
- **TanStack Query**: For data fetching, caching, and state management
- **TypeScript**: For type-safe JavaScript development
- **Capacitor**: Native iOS and Android shell wrapping the web app
## Project Structure
@@ -1246,7 +1247,68 @@ If git is available in your environment (through a `shell` tool, or other git-sp
When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes.
**ALWAYS commit when you are finished making changes.**
**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.**
## Capacitor Compatibility
The app runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
### What Doesn't Work in WKWebView (iOS)
- **`<a download>` file downloads** -- Programmatically creating an anchor element with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
- **`<a target="_blank">` new tabs** -- Programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
- **`window.open()`** -- May be blocked or behave unexpectedly without user gesture context.
### File Downloads and URL Opening
The project provides two utility functions in `src/lib/downloadFile.ts` that handle the web/native split automatically:
#### `downloadTextFile(filename, content)`
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
```typescript
import { downloadTextFile } from '@/lib/downloadFile';
await downloadTextFile('backup.txt', fileContents);
```
#### `openUrl(url)`
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
```typescript
import { openUrl } from '@/lib/downloadFile';
await openUrl('https://example.com/image.jpg');
```
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. Always use the utilities above. They handle the Capacitor/web split and will work correctly on all platforms.
### Detecting Native Platforms
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
```typescript
import { Capacitor } from '@capacitor/core';
if (Capacitor.isNativePlatform()) {
// iOS or Android
} else {
// Web browser
}
```
### Installed Capacitor Plugins
- `@capacitor/app` -- App lifecycle events (deep links, back button)
- `@capacitor/core` -- Core runtime and platform detection
- `@capacitor/filesystem` -- Read/write files on the native filesystem
- `@capacitor/local-notifications` -- Schedule local push notifications
- `@capacitor/share` -- Native share sheet
- `@capacitor/status-bar` -- Control the native status bar style
After adding or removing plugins, run `npx cap sync` to update the native projects.
## CI/CD Pipeline
+36
View File
@@ -0,0 +1,36 @@
# Changelog
## [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.
+20 -5
View File
@@ -7,6 +7,7 @@
| 36767 | Theme Definition | Shareable, named custom UI theme |
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery |
---
@@ -29,7 +30,8 @@ A theme consists of colors, optional fonts, and an optional background. Colors a
["c", "#1a1a2e", "background"],
["c", "#e0e0e0", "text"],
["c", "#6c3ce0", "primary"],
["f", "Inter", "https://example.com/inter.woff2"],
["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"]
@@ -74,7 +76,8 @@ Replaceable event that represents the user's currently active profile theme. Onl
["c", "#1a1a2e", "background"],
["c", "#e0e0e0", "text"],
["c", "#6c3ce0", "primary"],
["f", "Inter", "https://example.com/inter.woff2"],
["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"]
@@ -124,18 +127,30 @@ Format: `["c", "#rrggbb", "<marker>"]`
### Font Tag
Format: `["f", "<family>", "<url>"]`
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 event is allowed.
- The font applies globally to all text (body, headings, UI elements).
- 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
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "pub.ditto.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20260226
versionName "2026.02.26"
versionCode 1
versionName "2.1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2
View File
@@ -10,7 +10,9 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
}
@@ -265,6 +265,7 @@ public class NostrPoller {
}
return "commented on your post";
}
case 8211: return "sent you a letter";
default: return "mentioned you";
}
}
@@ -284,7 +284,7 @@ public class NotificationRelayService extends Service {
try {
JSONObject filter = new JSONObject();
JSONArray kinds = new JSONArray();
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111);
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111); kinds.put(8211);
filter.put("kinds", kinds);
JSONArray pTags = new JSONArray();
pTags.put(userPubkey);
+6
View File
@@ -5,8 +5,14 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
+255
View File
@@ -0,0 +1,255 @@
# Blobbi Tag Schema
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
## Overview
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
- All valid tags and their purposes
- Which tags are required vs optional
- Which tags persist across stage transitions
- Which tags should be removed during transitions
- Deprecated tags that should be filtered out
---
## Tag Categories
### 1. System / Metadata Tags
Core protocol-level tags required for event identification and ecosystem membership.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
### 2. Core Identity Tags
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
### 3. Visual Trait Tags
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
### 4. Personality / Trait Tags
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
### 5. Stat Tags
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
**Stage Transition Behavior**:
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
### 6. State / Lifecycle Tags
Tags that track the Blobbi's current lifecycle state.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
**State Constraints**:
- `incubating` is only valid for `stage: egg`
- `evolving` is only valid for `stage: baby`
- After hatch/evolve completes, `state` MUST be set to `active`
### 7. Task System Tags
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
### 8. Progression Tags
Long-term progress tracking that persists across all stages.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
### 9. Social / Flag Tags
User preferences and computed flags.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `visible_to_others` | No | egg, baby, adult | Yes | user | `true\|false` | true | Public visibility |
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
### 10. Evolution Tags
Tags specific to adult Blobbis.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
### 11. Extension Tags
Optional tags for themes and crossover features.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
---
## Deprecated Tags
These tags are from legacy versions and MUST be removed when republishing events.
| Tag | Reason | Replaced By |
|-----|--------|-------------|
| `shell_integrity` | Eggs use standard `health` stat | `health` |
| `egg_temperature` | Warmth handled via UI props | N/A |
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
| `egg_status` | Replaced by standard state | `state` |
| `fees` | Removed | N/A |
| `incubation_time` | Uses state_started_at | `state_started_at` |
| `start_incubation` | Uses state_started_at | `state_started_at` |
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
---
## Stage Transition Rules
### Hatch (egg → baby)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``baby`
- `state``active`
- `hunger``100`
- `happiness``100`
- `hygiene``100`
- `energy``100`
- `health` → (inherited from egg after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- All system tags (`d`, `b`, `t`, `client`)
- All identity tags (`name`, `seed`, `generation`)
- All visual tags (colors, pattern, size)
- All personality tags (if present)
- All progression tags (`experience`, `care_streak`)
- All social tags (`visible_to_others`, `breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``adult`
- `state``active`
- All stats → (inherited from baby after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- Same as hatch, plus all stats are inherited (not reset)
**Tags to ADD (optional)**:
- `adult_type` → computed based on care history
---
## Migration Rules
When migrating legacy Blobbis to canonical format:
1. **Always preserve existing values** - Do not regenerate tags that already exist
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
---
## Validation Rules
A valid Blobbi event MUST have:
- `d` tag in canonical format
- `b` tag = `blobbi:ecosystem:v1`
- `t` tag = `blobbi`
- `name` tag (non-empty)
- `seed` tag (64 hex chars)
- `stage` tag (valid value)
- `state` tag (valid value)
- `last_interaction` tag (valid timestamp)
---
## Implementation Checklist
When implementing any flow that modifies Blobbi tags:
- [ ] Start from `canonical.allTags` as the base
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
- [ ] Filter out deprecated tags
- [ ] Update only the tags that need to change
- [ ] Validate required tags are present
+7
View File
@@ -39,6 +39,13 @@ export default tseslint.config(
},
],
"custom/no-placeholder-comments": "error",
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.object.type='MetaProperty'][callee.property.name='glob']",
"message": "import.meta.glob is Vite-only and breaks other bundlers. Inline the assets or use standard imports instead.",
},
],
"no-warning-comments": [
"error",
{ terms: ["fixme"] },
+1 -1
View File
@@ -23,7 +23,7 @@
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.1.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.1.1;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 98 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

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

After

Width:  |  Height:  |  Size: 4.4 KiB

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

After

Width:  |  Height:  |  Size: 4.3 KiB

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

After

Width:  |  Height:  |  Size: 3.1 KiB

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

After

Width:  |  Height:  |  Size: 3.2 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 4.7 KiB

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

After

Width:  |  Height:  |  Size: 2.2 KiB

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

After

Width:  |  Height:  |  Size: 2.4 KiB

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

After

Width:  |  Height:  |  Size: 4.5 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 3.9 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 5.7 KiB

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

After

Width:  |  Height:  |  Size: 5.5 KiB

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

After

Width:  |  Height:  |  Size: 6.1 KiB

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

After

Width:  |  Height:  |  Size: 6.2 KiB

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

After

Width:  |  Height:  |  Size: 3.3 KiB

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

After

Width:  |  Height:  |  Size: 3.5 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

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

After

Width:  |  Height:  |  Size: 4.2 KiB

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

After

Width:  |  Height:  |  Size: 3.1 KiB

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

After

Width:  |  Height:  |  Size: 3.2 KiB

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

After

Width:  |  Height:  |  Size: 3.7 KiB

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

After

Width:  |  Height:  |  Size: 4.2 KiB

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

After

Width:  |  Height:  |  Size: 4.3 KiB

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

After

Width:  |  Height:  |  Size: 4.2 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 1.6 KiB

+36
View File
@@ -0,0 +1,36 @@
/**
* Baby Blobbi Module
*
* Self-contained module for baby stage Blobbi visuals and customization.
* This module includes:
* - Baby SVG assets (awake and sleeping)
* - SVG resolution and loading utilities
* - Color and customization utilities
* - Type definitions
*
* This module is designed to be portable and can be moved to other projects.
*/
// Types
export type {
BabyVariant,
BabySvgCustomization,
BabySvgResolverOptions
} from './types/baby.types';
export { extractBabyCustomization } from './types/baby.types';
// SVG Resolution
export {
getBabyBaseSvg,
getBabySleepingSvg,
getBabySvgByVariant,
resolveBabySvg,
preloadBabySvgs,
} from './lib/baby-svg-resolver';
// SVG Customization
export {
customizeBabySvg,
customizeBabySvgFromBlobbi,
} from './lib/baby-svg-customizer';

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