Merge branch 'main' into feat/blobbi-shake-reaction-stability

This commit is contained in:
filemon
2026-05-02 18:05:57 -03:00
276 changed files with 17907 additions and 11901 deletions
+73
View File
@@ -0,0 +1,73 @@
---
name: capacitor-compat
description: Browser-API gotchas inside Capacitor's WKWebView (iOS) and Android WebView — which common web APIs silently fail, the downloadTextFile/openUrl helpers that bridge web and native, platform detection, and the installed Capacitor plugins. Load when writing code that interacts with file downloads, external URLs, or platform-specific behavior.
---
# Capacitor Compatibility
Ditto 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 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.
For a deeper list of Apple Lockdown Mode restrictions that also affect WKWebView, load the **`lockdown-mode`** skill.
## File Downloads and URL Opening
`src/lib/downloadFile.ts` provides two utilities that handle the web/native split automatically. **Always use these** instead of manually constructing anchors.
### `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. The utilities above work correctly on all platforms; manual anchors silently fail on iOS.
## 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
}
```
Reserve platform forks for cases where behavior genuinely differs (share sheets, secure storage, haptics). Most UI code should stay platform-agnostic.
## 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/haptics` — native haptics
- `@capacitor/keyboard` — keyboard control (hide accessory bar, etc.)
- `@capacitor/local-notifications` — schedule local push notifications
- `@capacitor/share` — native share sheet
- `@capacitor/status-bar` — control the native status-bar style
- `@capgo/capacitor-autofill-save-password` — iOS keychain autofill for nsec
- `capacitor-secure-storage-plugin` — OS-level secure storage (iOS Keychain / Android KeyStore)
After adding or removing plugins, run `npm run cap:sync` to update the native projects.
+157
View File
@@ -0,0 +1,157 @@
---
name: ci-cd-publishing
description: Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation.
---
# CI/CD Pipeline and Publishing
Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target.
## Pipeline Overview
| Stage | Runs on | Job |
|-----------|---------------------------|-----------------------------------------|
| `test` | every commit (not tags) | `npm run test` |
| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) |
| `build` | tags only | `build-apk` (signed release APK + AAB) |
| `release` | tags only | GitLab Release with APK artifact |
| `publish` | tags only | `publish-zapstore` + `publish-google-play` |
## Creating a Release
Releases are triggered by pushing a version tag:
```bash
npm run release
```
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` jobs.
For the full versioning / changelog / native-build workflow, load the **`release`** skill.
## Zapstore Publishing
The `publish-zapstore` CI job uploads signed APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI and NIP-46 bunker signing via Amber.
**Configuration files:**
- `zapstore.yaml` — app metadata for Zapstore (name, tags, icon, supported NIPs)
- `.gitlab-ci.yml` — the `publish-zapstore` job definition
**GitLab CI/CD variables** (Settings → CI/CD → Variables):
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `ZAPSTORE_BUNKER_URL` | NIP-46 bunker URL (`bunker://<pubkey>?relay=...`). No `secret` param needed after initial auth. | Yes | No | Yes |
| `ZAPSTORE_CLIENT_KEY` | Hex private key used as the NIP-46 client identity for bunker communication | Yes | Yes | Yes |
| `ANDROID_KEYSTORE_BASE64` | Base64-encoded Android signing keystore | Yes | Yes | Yes |
| `KEYSTORE_PASSWORD` | Android keystore password | Yes | Yes | Yes |
| `KEY_PASSWORD` | Android key password | Yes | Yes | Yes |
### How NIP-46 bunker auth works in CI
NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and a **client key** (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval.
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
### Initial setup (one-time)
Run the NIP-46 client-initiated auth script:
```bash
node scripts/nip46-auth.mjs
```
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script outputs the `bunker://` URI and client key hex, and writes the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
Options:
- `--relay <url>` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
- `--name <name>` — app name shown to the signer (default: `Ditto`)
- `--timeout <sec>` — how long to wait for approval (default: 300)
After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables.
## nsite Publishing
The `deploy-nsite` CI job deploys the Vite build to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The job uploads `dist/` to Blossom servers and publishes site manifest events to Nostr relays.
nsyte uses a NIP-46 bunker credential called **nbunksec** — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via `--sec`.
**GitLab CI/CD variables:**
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
### Initial setup (one-time)
1. Install nsyte locally:
```bash
curl -fsSL https://nsyte.run/get/install.sh | bash
```
2. Generate the CI credential:
```bash
nsyte ci
```
This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an `nbunksec1...` string. The credential is shown only once.
3. Add the `nbunksec1...` value as `NSITE_NBUNKSEC` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**.
### Configured relays and servers
Relays the deploy job publishes to:
- `wss://relay.ditto.pub`
- `wss://relay.nsite.lol`
- `wss://relay.dreamith.to`
- `wss://relay.primal.net`
Blossom servers:
- `https://blossom.primal.net`
- `https://blossom.ditto.pub`
- `https://blossom.dreamith.to`
The `--use-fallback-relays` and `--use-fallback-servers` flags include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
### Credential rotation
To rotate the nsite credential:
1. Revoke the old bunker connection in your signer app.
2. Run `nsyte ci` again to generate a new `nbunksec1...` string.
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings.
## Google Play Publishing
The `publish-google-play` CI job uploads Android AABs to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). It runs after a successful AAB build and uploads directly to the production track.
**GitLab CI/CD variables:**
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON. The CI job decodes with `base64 -d` before passing to `fastlane supply`. | Yes | Yes | No |
### Initial setup (one-time)
1. Create or reuse a project in [Google Cloud Console](https://console.cloud.google.com/projectcreate).
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project.
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it.
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`.
5. **Base64-encode** the key file:
```bash
# Linux
base64 -w0 service-account.json
# macOS
base64 -i service-account.json | tr -d '\n'
```
6. Add the base64-encoded value as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. **Do not paste the raw JSON** — the CI script expects base64 and will fail to decode a raw value.
### Key points
- The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles.
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.).
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
+83
View File
@@ -0,0 +1,83 @@
---
name: file-uploads
description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags.
---
# File Uploads on Nostr
This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.).
## The `useUploadFile` Hook
```tsx
import { useUploadFile } from "@/hooks/useUploadFile";
function MyComponent() {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const handleUpload = async (file: File) => {
try {
// Returns an array of NIP-94-compatible tags.
// The first tag is the `url` tag; its second element is the file URL.
const tags = await uploadFile(file);
const url = tags[0][1];
// ...use the url
} catch (error) {
// ...handle errors (show a toast, etc.)
}
};
// ...rest of component
}
```
The hook is a TanStack Query mutation, so `isPending` can drive loading UI and `mutateAsync` integrates cleanly with `async`/`await` flows.
## Attaching Files to Events
### Kind 0 (profile metadata)
Use the plain URL in the relevant JSON field:
```ts
const tags = await uploadFile(file);
const url = tags[0][1];
createEvent({
kind: 0,
content: JSON.stringify({ ...existingMetadata, picture: url }),
});
```
### Kind 1 (text notes) and other content events
Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned:
```ts
const tags = await uploadFile(file); // e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...]
const url = tags[0][1];
// Flatten the NIP-94 tags into a single imeta tag value.
const imeta = tags.map(([name, value]) => `${name} ${value}`);
createEvent({
kind: 1,
content: `Check this out ${url}`,
tags: [["imeta", ...imeta]],
});
```
Repeat the pattern (one `imeta` tag per file) for multiple attachments.
## Common Patterns
- **Avatar / banner pickers:** wrap an `<input type="file" accept="image/*">` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event.
- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`.
- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton.
- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI.
## Constraints
- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`.
- Don't store or display raw `File` objects after upload — always use the returned URL.
- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL.
+66
View File
@@ -0,0 +1,66 @@
---
name: git-workflow
description: Ditto's git conventions — validating changes before committing, writing commit messages that match project style, and attributing regressions with a Regression-of trailer so the release changelog skill can filter them from the "Fixed" section.
---
# Git Workflow
Ditto expects every completed task to end with a git commit. This skill covers the pre-commit validation loop, commit-message conventions, and the `Regression-of:` trailer used by the release skill to filter intra-release regressions from the changelog.
## Pre-commit Validation
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
1. **Type Checking** (required) — `tsc --noEmit`
2. **Building/Compilation** (required) — `vite build`
3. **Linting** (recommended; fix anything critical) — `eslint`
4. **Tests** (if available) — `vitest run`
5. **Git commit** (required)
The full `npm run test` script runs all of these in sequence; running it is equivalent to steps 14.
## Using Git
Use `git status` and `git diff` to review changes, and `git log` to learn the project's commit-message conventions before writing a new one. If you make a mistake, `git checkout` restores files.
When your changes are complete and validated, create a commit with a message that focuses on **why** the change was made (not just **what**). Summaries should fit on one line; a body is warranted for non-trivial changes.
**Always commit when you are finished making changes. Non-negotiable — every completed task ends with a commit. Don't leave uncommitted changes.**
## Contributing Guide
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
## Attributing Regressions
When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA:
```
Fix missing background on expanded emoji picker in feeds
The compose box overhaul accidentally dropped the bg-background class
when refactoring the picker out of QuickReactMenu.
Regression-of: 3aa08ba9
```
This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the `release` skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster.
### When to add it
- The commit fixes a bug (not a new feature, refactor, or doc change).
- The introducing commit is identifiable with reasonable effort.
### When to skip it
- The bug is pre-existing with no clear single origin.
- The behavior was always wrong (no regression).
- The introducing commit cannot be determined after a brief search.
### Finding the introducing commit
- `git log -S '<removed-or-changed-string>'` — find commits that touched a specific string.
- `git log --oneline -- path/to/file` — list all commits touching a file.
- `git blame -L <start>,<end> -- path/to/file` — find who last changed specific lines.
This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely.
+146
View File
@@ -0,0 +1,146 @@
---
name: nip19-routing
description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events.
---
# NIP-19 Identifier Routing
NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`.
Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system.
## Identifier Reference
| Prefix | Payload | Use when… |
|--------------|------------------------------------------------------------------|--------------------------------------------------------------|
| `npub1` | 32-byte public key | Simple user reference |
| `nprofile1` | Public key + optional relay hints + petname | User reference with relay context |
| `note1` | 32-byte event ID (kind:1 text notes only, per NIP-10) | Referencing a short text note/thread |
| `nevent1` | Event ID + optional relay hints + author pubkey + kind | Any event kind, or notes where you need relay/author context |
| `naddr1` | `kind` + `pubkey` + `identifier` (`d` tag) + optional relay hints | Addressable events (kind 30000-39999): articles, products |
| `nsec1` | Private key | **Never display or route** — treat as a 404 |
| `nrelay1` | Relay URL | Deprecated |
### `note1` vs `nevent1`
- `note1` carries only an event ID, and is canonically tied to kind:1 text notes.
- `nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link.
### `npub1` vs `nprofile1`
- `npub1` is just a pubkey.
- `nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters.
## Routing Rules
1. **All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`.
2. **Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this.
3. **Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag.
## Decoding and Filtering
Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter.
```ts
import { nip19 } from 'nostr-tools';
const decoded = nip19.decode(value); // throws on invalid input
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data; // hex string
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
}
case 'nprofile': {
const { pubkey /*, relays */ } = decoded.data;
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
}
case 'note': {
const id = decoded.data;
return nostr.query([{ ids: [id], kinds: [1], limit: 1 }]);
}
case 'nevent': {
const { id /*, relays, author, kind */ } = decoded.data;
return nostr.query([{ ids: [id], limit: 1 }]);
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data;
return nostr.query([{
kinds: [kind],
authors: [pubkey], // critical: prevents d-tag spoofing
'#d': [identifier],
limit: 1,
}]);
}
default:
// nsec, nrelay, unknown → 404
throw new Error('Unsupported Nostr identifier');
}
```
### Common mistakes
```ts
// ❌ Passing bech32 into a filter
nostr.query([{ ids: [naddr] }]);
// ❌ Addressable lookup without the author — anyone can spoof the d-tag
nostr.query([{ kinds: [30023], '#d': [slug] }]);
// ✅ Decode first, then include author
const { kind, pubkey, identifier } = nip19.decode(naddr).data;
nostr.query([{ kinds: [kind], authors: [pubkey], '#d': [identifier] }]);
```
## Populating `NIP19Page`
`src/pages/NIP19Page.tsx` already:
- Decodes `params.nip19` with `nip19.decode`.
- Branches on `decoded.type` with a section for each supported identifier.
- Redirects invalid / unsupported identifiers to the 404 page.
- Provides a responsive container wrapper.
To turn it into a real router, replace each placeholder section with a concrete component:
| `decoded.type` | Typical view |
|-----------------------|---------------------------------------------------------------|
| `npub` / `nprofile` | Profile page: header from kind 0, feed of the user's events |
| `note` | Single kind:1 text note with thread + replies |
| `nevent` | Generic event renderer; branch on `kind` for specialized UIs |
| `naddr` | Addressable-event view (article, product, community, etc.) |
Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch.
## Linking to NIP-19 Routes
When building links elsewhere in the app:
```tsx
import { nip19 } from 'nostr-tools';
import { Link } from 'react-router-dom';
// To a profile
<Link to={`/${nip19.npubEncode(pubkey)}`}>Profile</Link>
// To an addressable event (article, product, …)
<Link to={`/${nip19.naddrEncode({ kind, pubkey, identifier, relays })}`}>
Open
</Link>
// To a specific event of any kind, with relay hints
<Link to={`/${nip19.neventEncode({ id, relays, author, kind })}`}>Open</Link>
```
Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays.
## Security Recap
- Decode **before** querying.
- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary.
- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`.
+190
View File
@@ -0,0 +1,190 @@
---
name: nip85-stats
description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig.
---
# NIP-85 Trusted Assertion Stats
[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider.
This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers.
## Kinds Used
| Kind | Subject | `d` tag value |
| ----- | ---------------------------- | -------------------------- |
| 30382 | User | user pubkey (hex) |
| 30383 | Event (regular, kind 1 etc.) | event id (hex) |
| 30384 | Addressable event | `<kind>:<pubkey>:<d-tag>` |
The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it.
## Files Provided by This Skill
| Skill file | Copy to |
|---|---|
| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` |
## Setup Instructions
### 1. Copy the Hooks File
Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template.
### 2. Add `nip85StatsPubkey` to `AppConfig`
In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface:
```typescript
export interface AppConfig {
// ...existing fields...
/** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */
nip85StatsPubkey: string;
}
```
### 3. Update the Zod Schema in `AppProvider.tsx`
In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`:
```typescript
const AppConfigSchema = z.object({
// ...existing fields...
nip85StatsPubkey: z.string().refine(
(val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val),
{ message: 'Must be empty or a 64-character hex pubkey' },
),
}) satisfies z.ZodType<AppConfig>;
```
### 4. Set the Default in `App.tsx`
Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default:
```typescript
const defaultConfig: AppConfig = {
// ...existing fields...
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
};
```
Set to `''` to ship with stats disabled.
### 5. Update `TestApp.tsx`
In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider:
```typescript
const defaultConfig: AppConfig = {
// ...existing fields...
nip85StatsPubkey: '',
};
```
## Usage
### User stats (kind 30382)
```tsx
import { useNip85UserStats } from '@/hooks/useNip85Stats';
function FollowerCount({ pubkey }: { pubkey: string }) {
const { data: stats } = useNip85UserStats(pubkey);
if (!stats) return null; // no provider configured or no assertion yet
return <span>{stats.followers.toLocaleString()} followers</span>;
}
```
### Event stats (kind 30383)
```tsx
import { useNip85EventStats } from '@/hooks/useNip85Stats';
function NoteStats({ eventId }: { eventId: string }) {
const { data: stats } = useNip85EventStats(eventId);
if (!stats) return null;
return (
<div className="flex gap-3 text-sm text-muted-foreground">
<span>{stats.reactionCount} reactions</span>
<span>{stats.repostCount} reposts</span>
<span>{stats.commentCount} comments</span>
<span>{stats.zapAmount} sats</span>
</div>
);
}
```
### Addressable event stats (kind 30384)
The `addr` argument is the full NIP-01 event address `<kind>:<pubkey>:<d-tag>`:
```tsx
import { useNip85AddrStats } from '@/hooks/useNip85Stats';
function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) {
const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`);
if (!stats) return null;
return <span>{stats.reactionCount} reactions</span>;
}
```
## Behavior Notes
- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth.
- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI.
- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically.
- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics.
- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts.
## Extending the Stats
The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`:
```typescript
export interface Nip85UserStats {
followers: number;
postCount: number;
rank: number; // new
zapAmtReceived: number; // new
}
// inside useNip85UserStats queryFn
return {
followers: getIntTag(tags, 'followers'),
postCount: getIntTag(tags, 'post_cnt'),
rank: getIntTag(tags, 'rank'),
zapAmtReceived: getIntTag(tags, 'zap_amt_recd'),
};
```
See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md).
## Exposing a Provider Picker (Optional)
If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value:
```tsx
import { useAppContext } from '@/hooks/useAppContext';
function StatsProviderInput() {
const { config, updateConfig } = useAppContext();
return (
<input
value={config.nip85StatsPubkey}
onChange={(e) => {
const v = e.target.value.trim().toLowerCase();
if (v === '' || /^[0-9a-f]{64}$/.test(v)) {
updateConfig(() => ({ nip85StatsPubkey: v }));
}
}}
placeholder="64-char hex pubkey (blank to disable)"
/>
);
}
```
## Related NIPs
- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill)
- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`<kind>:<pubkey>:<d-tag>`)
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate)
@@ -0,0 +1,156 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */
export interface Nip85EventStats {
commentCount: number;
repostCount: number;
reactionCount: number;
zapCount: number;
/** Zap amount in sats. */
zapAmount: number;
}
/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */
export interface Nip85UserStats {
followers: number;
postCount: number;
}
/**
* Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing
* or unparseable, which mirrors the semantics of "no data" in NIP-85.
*/
function getIntTag(tags: string[][], tagName: string): number {
const tag = tags.find(([name]) => name === tagName);
if (!tag?.[1]) return 0;
const n = parseInt(tag[1], 10);
return Number.isFinite(n) ? n : 0;
}
/**
* Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey.
* Returns `null` when no stats pubkey is configured or the provider has no
* assertion for this event.
*/
export function useNip85EventStats(eventId: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
return useQuery<Nip85EventStats | null>({
queryKey: ['nip85-event-stats', eventId, statsPubkey],
queryFn: async ({ signal }) => {
if (!eventId || !statsPubkey) return null;
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
try {
const events = await nostr.query(
[{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }],
{ signal: combined },
);
if (events.length === 0) return null;
const { tags } = events[0];
return {
commentCount: getIntTag(tags, 'comment_cnt'),
repostCount: getIntTag(tags, 'repost_cnt'),
reactionCount: getIntTag(tags, 'reaction_cnt'),
zapCount: getIntTag(tags, 'zap_cnt'),
zapAmount: getIntTag(tags, 'zap_amount'),
};
} catch {
return null;
}
},
enabled: !!eventId && !!statsPubkey,
staleTime: 30 * 1000,
retry: false,
});
}
/**
* Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey.
* Returns `null` when no stats pubkey is configured or the provider has no
* assertion for this pubkey.
*/
export function useNip85UserStats(pubkey: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
return useQuery<Nip85UserStats | null>({
queryKey: ['nip85-user-stats', pubkey, statsPubkey],
queryFn: async ({ signal }) => {
if (!pubkey || !statsPubkey) return null;
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
try {
const events = await nostr.query(
[{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }],
{ signal: combined },
);
if (events.length === 0) return null;
const { tags } = events[0];
return {
followers: getIntTag(tags, 'followers'),
postCount: getIntTag(tags, 'post_cnt'),
};
} catch {
return null;
}
},
enabled: !!pubkey && !!statsPubkey,
staleTime: 60 * 1000,
retry: false,
});
}
/**
* Fetches NIP-85 addressable event stats (kind 30384) from the configured
* stats pubkey. The `addr` argument is the full NIP-01 event address string,
* e.g. `30023:<pubkey>:<d-tag>`.
*/
export function useNip85AddrStats(addr: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const statsPubkey = config.nip85StatsPubkey;
return useQuery<Nip85EventStats | null>({
queryKey: ['nip85-addr-stats', addr, statsPubkey],
queryFn: async ({ signal }) => {
if (!addr || !statsPubkey) return null;
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
try {
const events = await nostr.query(
[{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }],
{ signal: combined },
);
if (events.length === 0) return null;
const { tags } = events[0];
return {
commentCount: getIntTag(tags, 'comment_cnt'),
repostCount: getIntTag(tags, 'repost_cnt'),
reactionCount: getIntTag(tags, 'reaction_cnt'),
zapCount: getIntTag(tags, 'zap_cnt'),
zapAmount: getIntTag(tags, 'zap_amount'),
};
} catch {
return null;
}
},
enabled: !!addr && !!statsPubkey,
staleTime: 30 * 1000,
retry: false,
});
}
@@ -1,478 +0,0 @@
---
name: nostr-direct-messages
description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17).
---
# Direct Messaging on Nostr
This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage.
**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application.
## Setup Instructions
### 1. Add DMProvider to Your App
First, add the `DMProvider` to your app's provider tree in `src/App.tsx`:
```tsx
// Add these imports at the top of src/App.tsx
import { DMProvider, type DMConfig } from '@/components/DMProvider';
import { PROTOCOL_MODE } from '@/lib/dmConstants';
// Add this configuration before your App component
const dmConfig: DMConfig = {
// Enable or disable DMs entirely
enabled: true, // Set to true to enable messaging functionality
// Choose one protocol mode:
// PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only
// PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only
// PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17)
protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps
};
// Then wrap your app components with DMProvider:
export function App() {
return (
<UnheadProvider head={head}>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<NostrSync />
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</DMProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
</UnheadProvider>
);
}
```
### 2. Configure DM Settings
The `DMConfig` object supports the following options:
- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed.
- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support:
- `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only
- `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended)
- `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility)
**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain.
## Quick Start
### 1. Send Messages
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) {
const { sendMessage } = useDMContext();
const [content, setContent] = useState('');
const handleSend = async () => {
await sendMessage({
recipientPubkey,
content,
protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping
});
setContent('');
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
);
}
```
### 2. Display Conversations
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
function ConversationList({ onSelectConversation }: { onSelectConversation: (pubkey: string) => void }) {
const { conversations, isLoading } = useDMContext();
if (isLoading) {
return <div>Loading conversations...</div>;
}
return (
<div className="space-y-2">
{conversations.map((conversation) => (
<ConversationItem
key={conversation.pubkey}
conversation={conversation}
onClick={() => onSelectConversation(conversation.pubkey)}
/>
))}
</div>
);
}
function ConversationItem({ conversation, onClick }: {
conversation: ConversationSummary;
onClick: () => void;
}) {
const author = useAuthor(conversation.pubkey);
const displayName = author.data?.metadata?.name || genUserName(conversation.pubkey);
const avatarUrl = author.data?.metadata?.picture;
return (
<button onClick={onClick} className="w-full p-3 hover:bg-accent rounded-lg">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={avatarUrl} />
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 text-left">
<div className="font-medium">{displayName}</div>
<div className="text-sm text-muted-foreground truncate">
{conversation.lastMessage?.decryptedContent || 'No messages yet'}
</div>
</div>
</div>
</button>
);
}
```
### 3. Display Messages in a Conversation
```tsx
import { useConversationMessages } from '@/hooks/useConversationMessages';
import { useCurrentUser } from '@/hooks/useCurrentUser';
function MessageThread({ conversationPubkey }: { conversationPubkey: string }) {
const { user } = useCurrentUser();
const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(conversationPubkey);
return (
<div className="flex flex-col space-y-2">
{hasMoreMessages && (
<button onClick={loadEarlierMessages} className="text-sm text-muted-foreground">
Load earlier messages
</button>
)}
{messages.map((message) => {
const isFromMe = message.pubkey === user?.pubkey;
return (
<div
key={message.id}
className={cn(
"flex",
isFromMe ? "justify-end" : "justify-start"
)}
>
<div className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isFromMe ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
{message.error ? (
<span className="text-red-500">🔒 {message.error}</span>
) : (
<p className="whitespace-pre-wrap break-words">
{message.decryptedContent}
</p>
)}
{message.isSending && (
<span className="text-xs opacity-50">Sending...</span>
)}
</div>
</div>
);
})}
</div>
);
}
```
## Using the Complete Messaging Interface
For a fully-featured messaging UI out of the box, use the `DMMessagingInterface` component:
```tsx
import { DMMessagingInterface } from "@/components/dm/DMMessagingInterface";
function MessagesPage() {
return (
<div className="container mx-auto p-4 h-screen">
<DMMessagingInterface />
</div>
);
}
```
The `DMMessagingInterface` component provides a complete messaging UI with:
- Conversation list with Active/Requests tabs
- Message thread view with pagination
- Compose area with file upload support
- Real-time message updates
- Mobile-responsive layout (shows one panel at a time on mobile)
It requires no props and works automatically when wrapped in `DMProvider`.
**For custom layouts**, see the "Building Custom Messaging UIs" section below for individual components (`DMConversationList`, `DMChatArea`, `DMStatusInfo`).
## Sending Files with Messages
```tsx
import { useDMContext } from '@/hooks/useDMContext';
import { useUploadFile } from '@/hooks/useUploadFile';
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
import type { FileAttachment } from '@/contexts/DMContext';
function ComposeWithFiles({ recipientPubkey }: { recipientPubkey: string }) {
const { sendMessage } = useDMContext();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const [content, setContent] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleSend = async () => {
let attachments: FileAttachment[] | undefined;
// Upload file if one is selected
if (selectedFile) {
const tags = await uploadFile(selectedFile);
attachments = [{
url: tags[0][1], // URL from first tag
mimeType: selectedFile.type,
size: selectedFile.size,
name: selectedFile.name,
tags: tags
}];
}
await sendMessage({
recipientPubkey,
content,
protocol: MESSAGE_PROTOCOL.NIP17,
attachments,
});
setContent('');
setSelectedFile(null);
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a message..."
/>
<input
type="file"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
/>
{selectedFile && <div>Selected: {selectedFile.name}</div>}
<button type="submit" disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Send'}
</button>
</form>
);
}
```
## Protocol Comparison
### NIP-04 (Legacy)
- **Encryption**: NIP-04 (simpler, older)
- **Metadata**: Sender and recipient visible to relays
- **Event Kind**: Kind 4
- **Use When**: Compatibility with older clients
### NIP-17 (Modern & Private)
- **Encryption**: NIP-44 (stronger)
- **Metadata**: Hidden via gift wrapping (NIP-59)
- **Event Kinds**: Kind 14 (text), Kind 15 (files)
- **Wrapped In**: Kind 1059 (Gift Wrap) with ephemeral keys
- **Use When**: Maximum privacy (recommended)
**Key Privacy Features of NIP-17:**
- Sender identity hidden (uses random ephemeral keys)
- Timestamps randomized (±2 days) to hide send time
- Dual gift wraps (recipient + sender) for message history
## Advanced Features
### Conversation Categorization
The system automatically categorizes conversations:
```tsx
const { conversations } = useDMContext();
// Filter by category
const knownConversations = conversations.filter(c => c.isKnown);
const requestConversations = conversations.filter(c => c.isRequest);
// isKnown = true if user has sent at least one message
// isRequest = true if only received messages, never replied
```
### Loading States
```tsx
const { isLoading, loadingPhase, scanProgress } = useDMContext();
// Check overall loading state
if (isLoading) {
console.log('Current phase:', loadingPhase);
// LOADING_PHASES.CACHE - Loading from local cache
// LOADING_PHASES.RELAYS - Querying relays
// LOADING_PHASES.SUBSCRIPTIONS - Setting up real-time updates
// LOADING_PHASES.READY - Fully loaded
}
// Display scan progress for large message histories
if (scanProgress.nip17) {
console.log(`NIP-17: ${scanProgress.nip17.current} messages - ${scanProgress.nip17.status}`);
}
```
### Clear Cache and Refresh
```tsx
import { useDMContext } from '@/hooks/useDMContext';
function SettingsButton() {
const { clearCacheAndRefetch } = useDMContext();
const handleClearCache = async () => {
await clearCacheAndRefetch();
// Clears IndexedDB cache and reloads all messages from relays
};
return (
<button onClick={handleClearCache}>
Clear Message Cache
</button>
);
}
```
## Architecture Notes
### Data Flow
1. **Cache First**: Messages load instantly from encrypted IndexedDB cache
2. **Background Sync**: New messages fetched from relays in parallel
3. **Real-time Updates**: WebSocket subscriptions for live messages
4. **Optimistic UI**: Sent messages appear immediately, confirmed on relay response
### Storage
- **IndexedDB**: All messages stored locally with NIP-44 encryption
- **Per-User Storage**: Separate encrypted store for each logged-in user
- **Automatic Sync**: Debounced writes (15s) + immediate on new messages
### Performance
- **Parallel Queries**: NIP-04 and NIP-17 messages fetched simultaneously
- **Batched Loading**: Messages loaded in batches (1000/batch, 20k limit)
- **Pagination**: Conversation messages paginated (25/page)
- **Deduplication**: Automatic filtering of duplicate messages by ID
### Security
- **NIP-44 Encryption**: Modern authenticated encryption for all NIP-17 messages
- **Local Encryption**: IndexedDB storage encrypted with user's NIP-44 key
- **Ephemeral Keys**: Random keys for NIP-17 gift wraps (sender anonymity)
- **No Plaintext**: Decrypted content never persisted unencrypted
- **Domain Isolation**: IndexedDB databases are namespaced by hostname to prevent data conflicts
## Building Custom Messaging UIs
For advanced use cases, you can use the individual DM components to build custom layouts:
### Available Components
**`DMConversationList`** - Conversation sidebar with tabs
```tsx
import { DMConversationList } from '@/components/dm/DMConversationList';
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={(pubkey) => setSelectedPubkey(pubkey)}
onStatusClick={() => setShowStatus(true)} // optional
className="h-full"
/>
```
**`DMChatArea`** - Message thread and compose area
```tsx
import { DMChatArea } from '@/components/dm/DMChatArea';
<DMChatArea
pubkey={selectedPubkey}
onBack={() => setSelectedPubkey(null)} // optional, for mobile back button
className="h-full"
/>
```
**`DMStatusInfo`** - Debug/status panel
```tsx
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
```
### Custom Layout Example
```tsx
import { useState } from 'react';
import { DMConversationList } from '@/components/dm/DMConversationList';
import { DMChatArea } from '@/components/dm/DMChatArea';
function CustomMessagingLayout() {
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
return (
<div className="flex h-screen">
{/* Custom sidebar */}
<aside className="w-64 border-r">
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={setSelectedPubkey}
/>
</aside>
{/* Custom main area */}
<main className="flex-1">
{selectedPubkey ? (
<DMChatArea pubkey={selectedPubkey} />
) : (
<div className="flex items-center justify-center h-full">
<p>Select a conversation to start messaging</p>
</div>
)}
</main>
</div>
);
}
```
+81
View File
@@ -0,0 +1,81 @@
---
name: nostr-encryption
description: Encrypt and decrypt content for Nostr direct messages, gift wraps, or any feature that needs NIP-44 (or legacy NIP-04) ciphertext, using the logged-in user's signer.
---
# Nostr Encryption and Decryption
The logged-in user exposes a `signer` object that matches the NIP-07 signer interface. The signer handles all cryptographic operations internally — including ECDH, conversation-key derivation, and AEAD — so your code never touches a private key.
**Always use the signer interface for encryption. Never ask the user for their private key, and never derive a shared secret yourself.**
## NIP-44 (preferred)
NIP-44 is the modern, authenticated encryption scheme used for DMs (NIP-17), gift wraps (NIP-59), and most new encrypted payloads.
```ts
import { useCurrentUser } from "@/hooks/useCurrentUser";
function useEncryptedNote() {
const { user } = useCurrentUser();
if (!user) throw new Error("Must be logged in");
// Guard: older signers may not support NIP-44 yet.
if (!user.signer.nip44) {
throw new Error(
"Please upgrade your signer extension to a version that supports NIP-44 encryption",
);
}
// Encrypt a message to a recipient (use your own pubkey to encrypt to self).
const ciphertext = await user.signer.nip44.encrypt(
recipientPubkey,
"hello world",
);
// Decrypt a message from a sender (use the *other party's* pubkey).
const plaintext = await user.signer.nip44.decrypt(senderPubkey, ciphertext);
return plaintext;
}
```
### Key points
- `encrypt(peerPubkey, plaintext)``peerPubkey` is the **other party's** hex public key. For self-encryption (notes, backups), pass `user.pubkey`.
- `decrypt(peerPubkey, ciphertext)``peerPubkey` is the author of the ciphertext you're decrypting (for an incoming DM, this is the sender's pubkey).
- Both methods are async and may throw if the signer rejects the request or the ciphertext is malformed. Wrap calls in `try/catch`.
- The signer handles conversation-key caching; repeated calls for the same peer are cheap.
## NIP-04 (legacy)
NIP-04 is only needed when interacting with older clients that haven't adopted NIP-44. The API mirrors NIP-44:
```ts
if (!user.signer.nip04) {
throw new Error("Signer does not support NIP-04");
}
const ciphertext = await user.signer.nip04.encrypt(peerPubkey, plaintext);
const plaintext = await user.signer.nip04.decrypt(peerPubkey, ciphertext);
```
Prefer NIP-44 for anything new. Only fall back to NIP-04 when a spec or peer explicitly requires it.
## Patterns
### Encrypt-to-self (drafts, private notes)
```ts
const ciphertext = await user.signer.nip44.encrypt(user.pubkey, draft);
createEvent({ kind: 30078, content: ciphertext, tags: [["d", "my-draft"]] });
```
### Decrypt an incoming DM (NIP-17 / NIP-59)
For gift-wrapped DMs, you'll typically decrypt the outer wrap, then the inner seal, then read the rumor's content. Each decryption uses the *sender* of that specific layer as the peer pubkey.
### Guarding the UI
Always check `user.signer.nip44` (or `nip04`) before calling encryption methods. Remote signers and older browser extensions may not implement every interface, and catching the missing-capability case lets you show a useful message ("Please upgrade your signer") instead of an unhandled promise rejection.
+115
View File
@@ -0,0 +1,115 @@
---
name: nostr-kinds
description: Decide whether to reuse an existing NIP or mint a new kind, design tag structures that relays can index, choose what goes in content vs. tags, and register a new kind in Ditto's many UI touchpoints (feed cards, detail pages, embedded previews, kind-label maps).
---
# Nostr Kinds — Design and Registration
Use this skill when introducing a new kind to Ditto, extending an existing NIP with new tags, or deciding whether an existing NIP covers a feature. It covers the decision framework, schema rules, and — critically — the full list of places a new kind must be registered in Ditto's UI.
## Choosing Between Existing NIPs and Custom Kinds
1. **Thorough NIP review first.** Browse the NIP index, then read candidate NIPs in detail. The goal is to find the closest existing solution.
2. **Prefer extending existing NIPs** over creating custom kinds, even at the cost of minor schema compromises. Custom kinds fragment the ecosystem.
3. **When an existing NIP is close but not perfect**, use its kind as the base and add domain-specific tags. Document the extension in `NIP.md`.
4. **Only mint a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case requires different storage characteristics (regular vs. replaceable vs. addressable).
5. **If a tool to generate a new kind number is available, you MUST call it.** Never pick an arbitrary number.
6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description of the event's purpose.
**Example decision:**
```
Need: Equipment marketplace for farmers
Options:
1. NIP-15 (Marketplace) — too structured for peer-to-peer sales
2. NIP-99 (Classifieds) — good fit, extensible with farming tags
3. Custom kind — perfect fit, no interoperability
Decision: NIP-99 + farming-specific tags.
```
## Kind Ranges
An event's kind number determines storage semantics:
- **Regular** (1000 ≤ kind < 10000) — stored permanently by relays. Notes, articles, etc.
- **Replaceable** (10000 ≤ kind < 20000) — only the latest event per `pubkey+kind` is kept. Profile metadata, contact lists, mute lists.
- **Addressable** (30000 ≤ kind < 40000) — identified by `pubkey+kind+d-tag`; only the latest per combo is kept. Long-form content, products, definitions.
Kinds below 1000 are "legacy"; storage is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
## Tag Design Principles
- **Kind = schema, tags = semantics.** Don't mint a new kind just to represent a different category of the same data.
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
- **Filter at the relay**, not in JavaScript:
```ts
// ❌ Fetch everything, filter locally
const events = await nostr.query([{ kinds: [30402] }]);
const filtered = events.filter((e) => hasTag(e, 'product_type', 'electronics'));
// ✅ Filter at the relay
const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
```
- **For Ditto-specific niches** (community apps, regional variants), tag events with a `t` value and query on it. Don't do this for generic platforms — it would silo content.
## Content vs. Tags
- **`content`** — large freeform text or existing industry-standard JSON (GeoJSON, FHIR, Tiled maps). Kind 0 is the one exception where structured JSON goes in content.
- **Tags** — queryable metadata, structured data, anything you might filter on.
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
- **If you need to filter by a field, it must be a tag** — relays don't index content.
```json
// ✅ Queryable
{ "kind": 30402, "content": "",
"tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] }
// ❌ Structured data buried in content
{ "kind": 30402, "content": "{\"title\":\"Camera\",\"price\":250}", "tags": [["d", "product-123"]] }
```
## `NIP.md`
`NIP.md` documents Ditto's custom kinds and any extensions to existing NIPs. Whenever you mint a new kind or change a custom schema, **create or update `NIP.md`** with the tag list, content format, and intended usage. If a kind you add is effectively the same shape as an existing NIP, note the NIP reference rather than duplicating the spec.
## Registering a New Kind in the Ditto UI
When adding support for a new kind, the kind must be registered in **multiple locations** or it will render incorrectly in certain views (blank content in quote posts, "Kind 12345" as a label, missing action headers, etc.).
### Checklist
1. **Content card component** (`src/components/`) — create `<MyKindCard>` that renders the event's tags/content appropriately.
2. **Feed rendering** (`src/components/NoteCard.tsx`):
- Add `const isMyKind = event.kind === XXXX;`.
- Include it in the appropriate group flag (e.g. `isDevKind`) or the `isTextNote` exclusion list.
- Add the content dispatch: `isMyKind ? <MyKindCard event={event} /> : …`.
- Add an entry to `KIND_HEADER_MAP` for the action header (e.g. "deployed an nsite").
- Import the new component and any new icons (e.g. `Globe` from `lucide-react`).
3. **Detail page** (`src/pages/PostDetailPage.tsx`):
- Mirror the `isMyKind` detection and group/exclusion flags from `NoteCard`.
- Add the content dispatch for the detail view.
- Add an entry in `shellTitleForKind()` for the loading-state title.
- Import the new component.
4. **Feed registration** (`src/lib/extraKinds.ts`):
- Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry.
5. **Kind-label registries** — independent maps that resolve kind → human-readable string/icon. All must be updated:
- `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` — used for "Commenting on an nsite" text and inline icons.
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` — used in addressable event preview headers.
- The icon fallback in `AddressableEventPreview` in the same file.
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) — small preview cards shown inside quote posts, reply-context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal preview (author + title/content + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags (e.g. kind 20 photos via `imeta` tags) may need attachment-indicator logic added to `EmbeddedNoteCard`.
> Do not confuse these with the `compact` prop on `NoteCard` — that just hides action buttons on the full `NoteCard`. `EmbeddedNote`/`EmbeddedNaddr` are entirely different components.
7. **Reply composer** (`src/components/ReplyComposeModal.tsx`) — `EmbeddedPost` delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components, so no per-kind registration is needed here.
### Why so many places?
These are genuinely different UI contexts (feed cards, detail pages, embedded previews, reply previews, comment-context labels) with different rendering requirements. Several of them maintain independent kind-to-label maps that could theoretically be unified. **When in doubt, grep the codebase for an existing kind number like `30617`** — you'll find every registration point you need to mirror.
+115
View File
@@ -0,0 +1,115 @@
---
name: nostr-publishing
description: Publish Nostr events with useNostrPublish. Covers the basic publishing pattern, safely mutating replaceable and addressable events (read-modify-write via fetchFreshEvent + prev), published_at preservation, and d-tag collision prevention for new addressable content.
---
# Publishing Nostr Events
Use this skill when a feature needs to publish events — notes, reactions, list updates, profile edits, addressable content, etc. Covers the `useNostrPublish` hook, the correct read-modify-write pattern for replaceable/addressable lists, and d-tag collision prevention.
## The `useNostrPublish` Hook
`useNostrPublish` publishes an event through the app's connection pool and auto-adds a `client` tag. Always guard calls with `useCurrentUser` — publishing requires a signer.
```tsx
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
export function PostForm() {
const { user } = useCurrentUser();
const { mutate: createEvent } = useNostrPublish();
if (!user) return <span>You must be logged in to post.</span>;
return (
<button onClick={() => createEvent({ kind: 1, content: 'hello' })}>
Post
</button>
);
}
```
Prefer `mutateAsync` over `mutate` when the caller needs to `await` the published event (e.g. to navigate to the new event's page, or to chain another publish).
## Mutating Replaceable and Addressable Events (CRITICAL)
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a **read-modify-write** cycle: fetch the current event, modify its tags, publish a new version. **Never read from the TanStack Query cache before mutating** — the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template — the **previous version** of the event being replaced. The hook uses it to manage the `published_at` tag (NIP-24) automatically:
- **First publish (no `prev`)** — `published_at` is set equal to `created_at`.
- **Update (`prev` provided)** — `published_at` is preserved from the old event.
- **Old event lacks `published_at`** — nothing is fabricated.
- **Caller already set `published_at` in tags** — left alone.
**Convention**: name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
```typescript
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ...
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
## D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
### When to check for collisions
- **Must check** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.).
- **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with an embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
### Implementation pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
```typescript
// Before publishing a new addressable event:
const slug = slugify(title, { lower: true, strict: true });
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'Change the slug or edit the existing item.',
variant: 'destructive',
});
return;
}
// Safe to publish
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
```
**Skip the check in edit mode** — when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
+117
View File
@@ -0,0 +1,117 @@
---
name: nostr-queries
description: Query Nostr events efficiently with useNostr + TanStack Query. Covers the standard useQuery pattern, combining related kinds into a single request to avoid rate limiting, and validating events with required tags or strict schemas.
---
# Querying Nostr Events
Use this skill when building a hook that fetches Nostr events. Covers the standard `useNostr` + `useQuery` pattern, efficient query design (combining kinds to avoid relay round-trips), and event validation for kinds with required tags.
## The Standard Pattern
Combine `useNostr` with TanStack Query in a custom hook. Pass the abort signal from `c.signal` into `nostr.query` so cancelled queries free relay resources:
```typescript
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
function usePosts() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['posts'],
queryFn: async (c) => {
const events = await nostr.query(
[{ kinds: [1], limit: 20 }],
{ signal: c.signal },
);
return events;
},
});
}
```
Transform events into a domain model inside the `queryFn` if needed — callers should rarely see raw `NostrEvent`s. Multiple calls to `nostr.query()` inside one `queryFn` are fine for compound queries that can't be expressed as a single filter.
## Efficient Query Design
**Always minimize the number of separate round-trips** to relays. Each query consumes relay capacity and may count against rate limits.
**✅ Efficient — single query with multiple kinds:**
```typescript
// Query repost variants in one request
const events = await nostr.query([{
kinds: [1, 6, 16],
'#e': [eventId],
limit: 150,
}]);
// Separate by kind in JavaScript
const notes = events.filter((e) => e.kind === 1);
const reposts = events.filter((e) => e.kind === 6);
const genericReposts = events.filter((e) => e.kind === 16);
```
**❌ Inefficient — three separate round-trips:**
```typescript
const [notes, reposts, genericReposts] = await Promise.all([
nostr.query([{ kinds: [1], '#e': [eventId] }]),
nostr.query([{ kinds: [6], '#e': [eventId] }]),
nostr.query([{ kinds: [16], '#e': [eventId] }]),
]);
```
### Optimization rules
1. **Combine kinds** into one filter: `kinds: [1, 6, 16]`.
2. **Use multiple filter objects** in a single `nostr.query()` call when different tag filters are needed simultaneously.
3. **Raise the `limit`** when combining kinds so you still receive enough of each type.
4. **Split by kind in JavaScript**, not by making separate requests.
5. **Respect relay capacity** — heavy parallel queries can trigger rate limits even when each individually would be fine.
## Event Validation
For kinds with required tags or strict schemas (most custom kinds, anything beyond kind 1), filter query results through a validator before returning them. Loose kinds (kind 1 text notes) rarely need validation — all tags are optional and `content` is freeform.
```typescript
import type { NostrEvent } from '@nostrify/nostrify';
// Example validator for NIP-52 calendar events
function validateCalendarEvent(event: NostrEvent): boolean {
if (![31922, 31923].includes(event.kind)) return false;
const d = event.tags.find(([n]) => n === 'd')?.[1];
const title = event.tags.find(([n]) => n === 'title')?.[1];
const start = event.tags.find(([n]) => n === 'start')?.[1];
if (!d || !title || !start) return false;
// Date-based events require YYYY-MM-DD
if (event.kind === 31922 && !/^\d{4}-\d{2}-\d{2}$/.test(start)) return false;
// Time-based events require a unix timestamp
if (event.kind === 31923) {
const ts = parseInt(start);
if (isNaN(ts) || ts <= 0) return false;
}
return true;
}
function useCalendarEvents() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['calendar-events'],
queryFn: async (c) => {
const events = await nostr.query(
[{ kinds: [31922, 31923], limit: 20 }],
{ signal: c.signal },
);
return events.filter(validateCalendarEvent);
},
});
}
```
Validation is a correctness layer, not a security layer. For trust-sensitive queries (admin actions, addressable events, moderator approvals), also constrain `authors` — see the `nostr-security` skill.
+92
View File
@@ -0,0 +1,92 @@
---
name: nostr-relay-pools
description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
---
# Targeted Nostr Relay Connections
By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
## Single Relay: `nostr.relay(url)`
```ts
import { useNostr } from '@nostrify/react';
function useSpecificRelay() {
const { nostr } = useNostr();
// Connect to a specific relay
const relay = nostr.relay('wss://relay.damus.io');
// Query from this relay only
const events = await relay.query([{ kinds: [1], limit: 15 }]);
// Publish to this relay only
await relay.event({ kind: 1, content: 'Hello from a specific relay!' });
}
```
**Good fits:**
- Testing a relay's behavior in isolation
- Debugging connectivity or rate-limiting issues
- Querying content that only lives on a specialized relay (paid relays, private relays, niche communities)
- Health checks / admin tooling
## Relay Group: `nostr.group(urls)`
```ts
import { useNostr } from '@nostrify/react';
function useRelayGroup() {
const { nostr } = useNostr();
// Create a group of specific relays
const relayGroup = nostr.group([
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
]);
// Query from all relays in the group (deduplicated)
const events = await relayGroup.query([{ kinds: [1], limit: 15 }]);
// Publish to all relays in the group
await relayGroup.event({ kind: 1, content: 'Hello from a relay group!' });
}
```
**Good fits:**
- Publishing to a curated set of trusted relays for a specific feature
- Community-scoped queries (e.g. a set of relays known to host a particular topic)
- Geographic/region-targeted delivery
- Load-balancing reads across a known-good subset
## API Consistency
Both the `relay` object and the `group` object expose the **same interface** as the top-level `nostr` object:
- `.query(filters, opts?)` — request events matching filters
- `.req(filters, opts?)` — open a streaming subscription
- `.event(event)` — publish a signed event
- All other Nostrify methods
This means you can drop them into any existing hook or helper that expects a `nostr`-shaped object.
## Choosing Between Pool, Group, and Single Relay
| Scenario | Use |
|----------------------------------------------------|---------------------|
| Default app queries, best reach for publishing | `nostr` (pool) |
| Trusted subset, community-specific publishing | `nostr.group([…])` |
| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
## Tips
- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
+140
View File
@@ -0,0 +1,140 @@
---
name: nostr-security
description: Threat model and defenses for Ditto as a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture.
---
# Nostr Security
## Threat model
**Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt.
**Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses.
## Defense-in-depth
**Content Security Policy.** `index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources.
**Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (DOMPurify, already installed) at the parse layer.
**Sanitize URLs and CSS values** — see §1 and §2.
## 1. URL sanitization
Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers.
**Use the shipped helper at `src/lib/sanitizeUrl.ts`:**
```ts
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// Single URL — returns the normalised href, or undefined if not valid https
const url = sanitizeUrl(getTag(event.tags, 'url'));
if (url) {
// safe to use in any context
}
// Array of URLs — filter out invalid entries
const links = getAllTags(event.tags, 'r')
.map(([, v]) => sanitizeUrl(v))
.filter((v): v is string => !!v);
```
`sanitizeUrl` returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
**Sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
**When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. `NoteContent`'s tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs (relay configs, internal routes), and strings rendered as plain text that never land in an attribute, CSS value, or network request.
## 2. CSS injection
Event data interpolated into CSS (a `<style>` element, `style=""`, or an injected stylesheet) is a CSS injection vector. A `"`, `)`, `}`, or `;` in the value can break out of the string context and inject rules — overlay phishing, hide UI, exfiltrate via `background-image: url()` requests.
Common surfaces: `background-image: url("${url}")`, `font-family: "${family}"`, `@font-face { src: url("${url}") }`.
**Mitigation:**
- **URLs in `url()`** — use `sanitizeUrl()`. The `URL` constructor percent-encodes `"`, `)`, `\` and rejects non-`https:`. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
- **Non-URL strings** (font-family, animation names) — use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, and periods:
```ts
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { sanitizeCssString } from '@/lib/fontLoader';
// ❌ UNSAFE
style.textContent = `body { background-image: url("${rawUrl}"); font-family: "${rawFamily}"; }`;
// ✅ SAFE — validate URLs, allowlist identifiers
const bgUrl = sanitizeUrl(rawUrl);
const family = sanitizeCssString(rawFamily ?? '');
if (bgUrl && family) {
style.textContent = `body { background-image: url("${bgUrl}"); font-family: "${family}"; }`;
}
```
If you can't justify the exact characters you're allowing, the policy is wrong.
## 3. Author filtering for trust-sensitive queries
Even with perfect XSS defenses, an attacker can publish forged events your UI will trust unless queries constrain `authors`. Relays are dumb pipes — any matching event comes back.
**Filter by `authors` when:**
- Querying admin/moderator/owner events — use a hardcoded trusted-pubkey list (e.g. `ADMIN_PUBKEYS` from `src/lib/admins`).
- Querying addressable events (kinds 3000039999) — the `d` tag alone is not a trust boundary; the `(kind, pubkey, d)` triple is.
- Querying user-owned replaceable events (profile metadata, relay lists, mute lists) — `authors: [userPubkey]`.
**Do NOT filter by `authors`** for public UGC (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
```ts
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
// ✅ Only trust the admin list
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]);
```
**Routes for addressable/replaceable events must include the author** — otherwise the route handler can't construct a secure filter:
```tsx
// ❌ Any pubkey can squat the slug
<Route path="/article/:slug" element={<Article />} />
// ✅ Filter can include authors
<Route path="/article/:npub/:slug" element={<Article />} />
```
### NIP-72 community moderation
Kind 4550 approvals are only trustworthy if signed by a moderator from the community definition (kind 34550). Two-step query:
```ts
// 1. Fetch community definition — author-filter by the owner.
const [community] = await nostr.query([{
kinds: [34550], authors: [communityOwnerPubkey], '#d': [communityId], limit: 1,
}]);
if (!community) return [];
// 2. Extract moderator pubkeys from `p` tags with role "moderator".
const moderators = community.tags
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
.map(([, pubkey]) => pubkey);
// 3. Query approvals — only from moderators.
const approvals = await nostr.query([{
kinds: [4550],
authors: moderators,
'#a': [`34550:${communityOwnerPubkey}:${communityId}`],
limit: 100,
}]);
```
Without step 3's `authors` filter, anyone can publish a kind 4550 "approval".
## Pre-merge checklist
- [ ] No `dangerouslySetInnerHTML` / `innerHTML` / `document.write` with untrusted data.
- [ ] CSP unchanged or narrowed; no new `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcards.
- [ ] Every event-sourced URL passes `sanitizeUrl()` before reaching `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS.
- [ ] Every event-sourced string in CSS passes `sanitizeUrl()` (URLs) or `sanitizeCssString()` (identifiers).
- [ ] Every trust-sensitive query includes `authors`.
- [ ] Routes for addressable/replaceable events carry the author in the URL.
+87
View File
@@ -0,0 +1,87 @@
---
name: testing
description: Write Vitest unit tests for React components and hooks using the project's `TestApp` wrapper, jsdom environment, and pre-mocked browser APIs (localStorage, matchMedia, scrollTo, IntersectionObserver, ResizeObserver). Also covers the project policy on when to create new test files.
---
# Testing
Load this skill when the user asks you to write a test, diagnose a bug with a test, or add coverage for a component/hook. Running the existing test script is a standing requirement (see `AGENTS.md`*Validating Your Changes*) and doesn't require this skill.
## Policy: when to create new test files
**Do not create new test files unless one of these applies:**
1. The user explicitly asks for tests.
2. The user describes a specific bug and asks for tests to diagnose it.
3. The user says a problem persists after you tried to fix it.
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. The request must come from the user.
If none of the above apply, stop — don't create a test file. Keep running the existing test script as usual.
## Test setup
The project uses **Vitest + jsdom** with **React Testing Library** and **jest-dom** matchers. Global setup lives in `src/test/setup.ts` and mocks these browser APIs that jsdom doesn't provide (or that Node's built-ins conflict with):
- `localStorage` — a Map-backed mock, because Node 22's built-in `localStorage` lacks the Web Storage API surface jsdom expects
- `window.matchMedia`
- `window.scrollTo`
- `IntersectionObserver`
- `ResizeObserver`
If your component needs another browser API, extend `src/test/setup.ts` rather than mocking per-file.
## Writing a component test
Wrap rendered components in `TestApp` (`src/test/TestApp.tsx`) so all context providers — `UnheadProvider`, `AppProvider`, `QueryClientProvider`, `NostrLoginProvider`, `NostrProvider`, `BrowserRouter`, etc. — are available. Without it, hooks like `useQuery`, `useNostr`, `useAppContext`, or `useNavigate` will throw.
```tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TestApp } from '@/test/TestApp';
import { MyComponent } from './MyComponent';
describe('MyComponent', () => {
it('renders correctly', () => {
render(<TestApp><MyComponent /></TestApp>);
expect(screen.getByText('Expected text')).toBeInTheDocument();
});
});
```
## Writing a hook test
Use `renderHook` from `@testing-library/react` and pass `TestApp` as the `wrapper`:
```tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { TestApp } from '@/test/TestApp';
import { useMyHook } from './useMyHook';
describe('useMyHook', () => {
it('returns expected data', async () => {
const { result } = renderHook(() => useMyHook(), { wrapper: TestApp });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toBeDefined();
});
});
```
Files placed next to the code under test with the `.test.ts` / `.test.tsx` suffix are picked up automatically. For reference, see `src/test/ErrorBoundary.test.tsx`.
## Running tests
The `npm test` script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. Always run it after changes — a passing test file alone doesn't mean your task is done.
For fast iteration, run just Vitest:
```bash
npx vitest run
```
Or in watch mode while editing:
```bash
npx vitest
```
+127
View File
@@ -0,0 +1,127 @@
---
name: theming
description: Customize Ditto's visual design — install Google Fonts via @fontsource, change the color scheme, configure light/dark themes, and apply consistent component styling patterns with Tailwind and CSS variables.
---
# Theming, Fonts, and Color Schemes
Use this skill when the user wants to change fonts, colors, light/dark appearance, or general visual styling. Ditto ships with a light/dark theme system built on CSS custom properties and Tailwind v3, plus a `useTheme` hook for runtime switching.
## Adding Fonts
Any Google Font can be installed via the `@fontsource` / `@fontsource-variable` packages.
1. **Install the font package.** Prefer the variable version when available.
```bash
npm install @fontsource-variable/inter
```
Package naming:
- `@fontsource-variable/<font-name>` — variable fonts (preferred; one file, all weights)
- `@fontsource/<font-name>` — static fonts
2. **Import the font once** in `src/main.tsx`:
```ts
import '@fontsource-variable/inter';
```
3. **Register the family** in `tailwind.config.ts`:
```ts
export default {
theme: {
extend: {
fontFamily: {
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
},
},
},
};
```
### Suggested families by use case
- **Modern / Clean:** Inter Variable, Outfit Variable, Manrope
- **Professional / Corporate:** Roboto, Open Sans, Source Sans Pro
- **Creative / Artistic:** Poppins, Nunito, Comfortaa
- **Monospace / Code:** JetBrains Mono, Fira Code, Source Code Pro
For expressive hierarchies, pair a sans body font with a display/serif heading font (e.g. Inter + Playfair Display) and expose the second family as `fontFamily.serif` or `fontFamily.display` in Tailwind.
### Runtime font loading from Nostr events
Ditto also supports loading fonts referenced from Nostr events (theme events, letter stationery, etc.) through `src/lib/fontLoader.ts`. That path is separate from the build-time `@fontsource` approach — it constructs `@font-face` rules at runtime from sanitized URLs. Never feed event data through the `@fontsource` path; always go through `fontLoader` so the URL and family name are passed through `sanitizeUrl()` and `sanitizeCssString()` (see the `nostr-security` skill).
## Color Schemes
Colors are defined as CSS custom properties in `src/index.css` under two selectors:
- `:root` — light-mode values
- `.dark` — dark-mode overrides
When the user requests a new color scheme:
1. **Update both `:root` and `.dark`** in `src/index.css`. Each variable is an HSL triplet (no `hsl()` wrapper), e.g. `--primary: 222 47% 11%;`.
2. **Keep contrast ratios ≥ 4.5:1** for body text and interactive elements. Test both modes.
3. **Prefer extending Tailwind's palette** (`tailwind.config.ts`) over hard-coding hex values in components — this keeps the theme consistent and dark-mode-friendly.
4. **Apply colors through semantic tokens** (`bg-primary`, `text-muted-foreground`, `border-input`) rather than raw palette names when possible, so future theme changes propagate.
The shadcn/ui components consume these semantic tokens, so changing the variables automatically restyles the entire component library.
## Light/Dark Theme Switching
Ditto includes:
- **`useTheme` hook** (`src/hooks/useTheme.ts`) — read and set the current theme programmatically.
- **CSS custom properties** in `src/index.css` — one set in `:root`, dark overrides in `.dark`.
- **Automatic persistence** via the `AppContext` config (`config.theme`), saved to local storage.
To add a theme toggle:
```tsx
import { useTheme } from '@/hooks/useTheme';
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
```
## Component Styling Patterns
- **Class merging:** use the `cn()` utility (`@/lib/utils`) to combine conditional classes and override defaults without class-order bugs.
- **Variants:** follow shadcn/ui's `class-variance-authority` pattern for component variants (`variant`, `size`). Copy an existing `ui/` component as a template.
- **Responsive design:** lean on Tailwind breakpoints (`sm:`, `md:`, `lg:`) rather than JS media queries. Use `useIsMobile` only when layout must change based on JS-measured viewport.
- **Interactive states:** always define `hover:`, `focus-visible:`, and `disabled:` states for clickable elements. Focus rings should use `ring-ring` / `ring-offset-background` so they pick up theme colors.
- **Spacing:** an 8px grid (Tailwind's default 4-based scale) keeps visual rhythm consistent. Common paddings: `p-4`, `p-6`; gaps: `gap-2`, `gap-4`.
- **Depth:** soft shadows (`shadow-sm`, `shadow-md`), subtle gradients, and `rounded-lg` / `rounded-xl` corners match Ditto's aesthetic. Avoid heavy drop shadows.
### Negative z-index gotcha
When placing decorative elements behind content with `-z-10` (e.g. blurred background gradients), **add `isolate` to the parent container**. Without `isolate`, the negative z-index escapes the local stacking context and the element disappears behind the page's background color.
```tsx
<section className="relative isolate">
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-primary/20 to-transparent" />
{/* content */}
</section>
```
## Design Quality Checklist
Before finishing a visual change, verify:
- [ ] Both light and dark modes look correct — no hard-coded colors, all text readable.
- [ ] Contrast ratios meet WCAG AA (≥ 4.5:1 for body, ≥ 3:1 for large text).
- [ ] Interactive elements have visible `hover`, `focus-visible`, and `disabled` states.
- [ ] Layout is responsive down to ~360px width without horizontal scroll.
- [ ] Animations respect `prefers-reduced-motion` (Tailwind: `motion-safe:` / `motion-reduce:`).
- [ ] Spacing is consistent — no one-off `p-[13px]` style values.
+185 -1483
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
# Bitcoin PSBT Signing for Nostr Signers
This document specifies how Nostr signers (NIP-07 browser extensions and NIP-46 remote signers) can support signing Bitcoin Partially Signed Bitcoin Transactions (PSBTs).
## Motivation
Nostr and Bitcoin Taproot (BIP-341) share identical cryptographic primitives: secp256k1 with 32-byte x-only public keys and BIP-340 Schnorr signatures. This means a Nostr private key can directly sign Bitcoin Taproot transactions without any key conversion. To enable this, Nostr signers need a method to sign PSBTs.
## `signPsbt` Method
### NIP-07 (Browser Extensions)
Extensions that support Bitcoin signing MUST expose a `signPsbt` method on the `window.nostr` object:
```typescript
window.nostr.signPsbt(psbtHex: string): Promise<string>
```
**Parameters:**
- `psbtHex` — Hex-encoded PSBT (BIP-174/BIP-370).
**Returns:**
- A hex-encoded PSBT with Taproot key-path signatures (`tapKeySig`) added to matching inputs.
### NIP-46 (Remote Signers)
Remote signers that support Bitcoin signing MUST handle the `sign_psbt` RPC method:
```
method: "sign_psbt"
params: ["<hex-encoded PSBT>"]
result: "<hex-encoded signed PSBT>"
```
The method follows the same NIP-46 request/response pattern as `sign_event`. If the signer does not support this method, it MUST return an error.
## Signer Behavior
When a signer receives a PSBT to sign, it MUST:
1. Decode the PSBT from hex.
2. For each input, check if `tapInternalKey` is present.
3. Compare the input's `tapInternalKey` against the signer's own 32-byte x-only public key.
4. For each matching input:
a. Compute the BIP-341 tweak: `t = taggedHash("TapTweak", tapInternalKey)`.
b. Tweak the private key: apply `t` to the secret key with y-parity correction (negate the key if the corresponding public key has an odd y-coordinate, then add the tweak scalar modulo the curve order).
c. Compute the BIP-341 sighash for the input.
d. Produce a BIP-340 Schnorr signature over the sighash using the tweaked key.
e. Set `tapKeySig` on the input.
5. Return the PSBT with signatures added. The signer MUST NOT finalize or extract the transaction.
Inputs whose `tapInternalKey` does not match the signer's key MUST be left unchanged.
## Security Considerations
- Signers SHOULD display a confirmation dialog showing the transaction outputs, amounts, and fees before signing.
- Signers SHOULD reject PSBTs that do not contain any inputs matching the signer's key.
- The PSBT format (BIP-174) carries all information needed for the signer to verify what is being signed, including input amounts (`witnessUtxo`) and output destinations.
## Capability Detection
### NIP-07
Clients SHOULD check for the presence of `signPsbt` before calling it:
```typescript
if (typeof window.nostr?.signPsbt === 'function') {
const signedHex = await window.nostr.signPsbt(unsignedPsbtHex);
}
```
### NIP-46
Clients SHOULD handle errors gracefully when the remote signer does not support `sign_psbt`. If the signer returns an error for an unknown method, the client should inform the user that their signer does not support Bitcoin signing.
## References
- [BIP-174: Partially Signed Bitcoin Transaction Format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)
- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)
- [BIP-341: Taproot (SegWit v1 Spending Rules)](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)
- [BIP-370: PSBT Version 2](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki)
- [NIP-07: `window.nostr` capability for web browsers](https://github.com/nostr-protocol/nips/blob/master/07.md)
- [NIP-46: Nostr Remote Signing](https://github.com/nostr-protocol/nips/blob/master/46.md)
+123
View File
@@ -1,5 +1,128 @@
# Changelog
## [2.12.1] - 2026-05-01
### Changed
- The right widget sidebar now appears on iPad-landscape (1024px) viewports, and both sidebars scale fluidly with the window instead of snapping at fixed widths
- Hashtags with internal hyphens like `#bitcoin-conference` and `#70-706` now render as a single tag instead of being cut off at the dash
### Fixed
- Comments on country, book, and Bitcoin transaction/address pages now load correctly instead of showing an empty thread
## [2.12.0] - 2026-04-30
### Added
- Bitcoin wallet -- a new Wallet tab in the sidebar shows your balance in USD with BTC underneath, a transaction history that collapses when empty, and a 3-step send flow with a two-tap confirmation for amounts over $100
- Bitcoin zaps -- send on-chain Bitcoin directly to anyone on Nostr as a native alternative to Lightning, with an automatic QR-code fallback when your signer does not support Bitcoin
- Detail pages for Bitcoin transactions and addresses, with the block explorer URL configurable per deployment
- Evolve ceremony -- Blobbis now transform from baby to adult through an immersive full-screen animation with energy particles, a flash-to-reveal, and a typewriter reveal text
- Birdex life lists -- a compact species tile strip in feeds and a full responsive grid on the post-detail page, so visitors can browse an author's whole collection
- Bird-song recordings play inline on Wikipedia species pages, with an emerald play button next to the title and a credit link in the footer
### Changed
- Display names now use a consistent `name` then `display_name` fallback everywhere, so the same user renders the same way across the whole UI
- Hatching ceremony reveal background is now tinted by the baby Blobbi's color instead of a hardcoded blue, with a vignette overlay so the blobbi pops against same-hue backgrounds
- Bird Detection cards prefer the authoritative scientific name tag published by Birdstar, so cards stay labeled even when the post's alt text is generic
### Fixed
- "Discover people to follow" now lands on a populated Global tab instead of another empty Follows view
- Blobbi daily bounty progress is no longer wiped by profile writes, and persists reliably across page refreshes and app restarts
- Blobbi profile content (name, avatar, custom fields) is preserved across every profile update instead of being silently dropped by some write paths
- Blobbi hatch and evolve mission progress no longer resets from concurrent writes racing against each other
## [2.11.2] - 2026-04-28
### Fixed
- Unsupported event kinds no longer leak opaque identifiers or raw post content into previews and hover cards -- when an author-written description is missing, the card shows the "not supported" tombstone instead of an internal lookup string
## [2.11.1] - 2026-04-28
### Fixed
- Unsupported event kinds now show a clean fallback card with the author's description (or a "not supported" tombstone) instead of rendering raw JSON or empty content through the text-note parser -- applies in feeds, post detail, embedded quotes, reply context, compose previews, notifications, and inline nostr: mentions
## [2.11.0] - 2026-04-28
### Added
- Birdstar bird detections and hand-drawn constellations now render inline in feeds -- species cards with Wikipedia summaries, and gnomonic star maps backed by the Hipparcos catalog, with a "View on Birdstar" deep-link and a Discuss button that routes species comments to the shared Wikidata identifier
- Magic: The Gathering cards render from Gatherer URLs -- card art with a 3D mouse tilt and specular glare, face toggle for double-faced cards, lightbox, and hover-card previews across comments, detail pages, and external content
- Touch support for the Magic card 3D tilt -- press and drag to rock the card on mobile
- Wikidata entity URLs on the external content page render the entity's English Wikipedia article, falling back to a generic link preview when no article exists
- Webxdc embeds now render as a tilted Game Boy-style cartridge tinted by the app icon's dominant color, with the launch icon centered on the label
- Segmented Blobbi stat rings -- babies show 4 bars and adults show 10, so progress is visible at a glance instead of as a continuous sweep, and the ring gaps are now symmetrical
- Sleep is now restorative for Blobbis -- energy regenerates faster, hunger, happiness, and hygiene decay at a fraction of their awake rates, and health stops degrading entirely while asleep
- Blobbi rooms and carousel focus are remembered per-Blobbi across refreshes and Blobbi switches
- Action and fridge previews show segment impact alongside raw stat changes, so it's clear how much a bar will fill
- Profile recovery now shows follow-list snapshots as avatar stacks with the newest follows first, making different historical versions distinguishable at a glance
- Google Play and App Store listings are declared in the web manifest so browsers can surface the native apps
### Changed
- The "Follow Packs" feed toggle is now called "People Lists" and its description explains that it also covers follow lists and people sets
- Blobbi care thresholds are aligned to the new segment model -- attention and urgent states fire on clear bar boundaries instead of arbitrary numbers, and the care badge triggers when any stat is urgent or multiple stats need attention
- Awake decay rates rebalanced so growing up feels like increased resilience -- babies hit their first "okay" stat around 2.7 hours and adults around 5-6 hours
- Item effects rebalanced around the segment model -- basic items restore roughly one baby bar, medium items offer meaningful upgrades, and premium items deliver strong multi-stat effects
### Fixed
- Profile feeds now filter out deprecated follow sets, unlisted decks, hidden treasures, and empty emoji packs before rendering, matching the main feed's behavior
- Onboarding no longer clobbers a returning user's feed preferences with a hardcoded preset when their encrypted settings fetch comes back empty
## [2.10.5] - 2026-04-25
### Added
- Blobbi eye tracking -- your companion's eyes follow content on your feed and post detail pages, including touch support on mobile
- Blobbi overstimulation reaction -- tap your Blobbi too many times and it zooms in with a shockwave, blocking interactions until it calms down
- Shake-to-dizzy reaction -- shake your phone to make your Blobbi go woozy with a nausea fill effect
- Blobbi route-transition reactions -- your companion glances at where you tapped before looking at new content when navigating
- Guided care flow -- low-status indicators glow to get your attention, and tapping any stat icon walks you to the right room to help
- Drag-to-clean shovel -- swipe poop away instead of tapping a button, with poops now visible across all rooms
- Pandi color customizer with tinted-white body and dark-tinted patches
- Vines now appear in feeds -- short videos render inline with volume control
- Infinite scroll pagination on the search page
- Right-to-left text support expanded to articles, compose box, and letters
### Changed
- Blobbi colors are now generated from the seed instead of fixed palettes, giving every Blobbi a unique look
- Adult Blobbi type is now derived from the seed for fully deterministic visual identity
### Fixed
- Content warnings were not applied on the video and vine feed pages -- videos with sensitive tags played without blur or filtering
- Compose textarea lost its expanded height when toggling the markdown preview on and off
- Crysti Blobbi was missing sparkle animations, had a broken pink facet path, and mismatched sleeping opacities
- Adopting another Blobbi could create a duplicate egg due to a race condition in the hatching ceremony
## [2.10.4] - 2026-04-23
### Added
- Right-to-left text support for Arabic, Hebrew, and other RTL languages across posts, bios, and direct messages
- Blobbis now close their eyes when falling asleep and open them when waking up with smooth one-shot animations
### Changed
- Eye color and secondary color now apply consistently across all adult Blobbi forms
- Blobbi mission progress is now tracked per-Blobbi instead of per-account, so evolving multiple Blobbis no longer conflicts
### Fixed
- Swiping to dismiss a full-screen image could leave the controls flickering or locked in place
- Sleeping Blobbis appeared with open eyes in the Blobbies tab grid
- Catti reaction mouths rendered off-center instead of aligned with the face
- Catti whiskers disappeared when showing a reaction mouth
- Pandi eye tracking could lock onto ear patches instead of actual eyes
- Adult Blobbi eyebrows floated away from the eyes on certain body shapes
- Some adult Blobbi body shapes were not detected correctly for visual effects
- Poll and spoiler menu in the compose modal was clipped off-screen on mobile
- Duplicate Blobbis could appear when the legacy-to-new-format migration ran more than once
## [2.10.3] - 2026-04-20
### Added
- Music discovery page with a curated Discover tab, artist profiles, playlist detail pages with full playback, genre browsing, and hot/top/new sorting
- Swipe-to-dismiss gesture on full-screen image lightboxes -- flick up or down to close instead of reaching for the X button
- Autoplay videos setting in Content preferences (off by default) that plays videos muted in feeds and profiles
- Replies are now delivered to tagged users' inbox relays so they're more likely to see your response
### Fixed
- Status bar text could appear unreadable on light themes
- Envelope cards in the Letters inbox were hard to tap on mobile
- Wall compose box kept showing the previous post's text after submitting from the modal
## [2.10.2] - 2026-04-18
### Fixed
+111
View File
@@ -6,6 +6,7 @@
| Kind | Name | Description |
|-------|----------------------|-------------------------------------------------------|
| 8333 | Onchain Zap | Attestation that an on-chain BTC tx paid a target |
| 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) |
@@ -16,6 +17,8 @@ These event kinds were created by community contributors and are supported by Di
| Kind | Name | Description | Spec |
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| 2473 | Bird Detection | Bird-by-ear observation log (species heard in the wild) | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
| 12473 | Birdex | Author's cumulative life list of confirmed bird species | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
@@ -29,6 +32,102 @@ These event kinds were created by community contributors and are supported by Di
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
| 36787 | Music Track | Addressable event for a music audio file with metadata | See [Music Tracks & Playlists](#music-tracks--playlists) below |
| 34139 | Music Playlist | Ordered list of music track references (also used for albums) | See [Music Tracks & Playlists](#music-tracks--playlists) below |
| 30621 | Custom Constellation | User-drawn star figure with Hipparcos-numbered edges | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
---
## Kind 8333: Onchain Zap
### Summary
Regular event kind that records a **Bitcoin on-chain payment** ("onchain zap") sent in appreciation of a Nostr event or profile. Functions as the on-chain analogue of NIP-57 zap receipts (kind 9735), but without the LNURL round-trip: the event is self-attested by the sender and references a real Bitcoin transaction that clients can verify directly on-chain.
The kind number mirrors the convention of NIP-57: kind **9735** is the Lightning P2P port (per BOLT spec), and kind **8333** is the Bitcoin mainnet P2P port — a natural semantic pairing for Lightning vs. on-chain settlement.
Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) address (both use 32-byte x-only secp256k1 keys, per BIP-340/BIP-341), an on-chain zap is simply a Bitcoin transaction whose output pays the recipient's derived Taproot address. The kind 8333 event links that transaction to the Nostr event or profile being zapped.
### Event Structure
```json
{
"kind": 8333,
"pubkey": "<sender-pubkey>",
"content": "Great post!",
"tags": [
["e", "<target-event-id>", "<relay-hint>"],
["p", "<target-pubkey>"],
["i", "bitcoin:tx:<txid>"],
["amount", "<sats>"],
["alt", "On-chain zap: 25000 sats"]
]
}
```
### Content
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
### Tags
| Tag | Required | Description |
|----------|----------|----------------------------------------------------------------------------------------------|
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address — *not* the total tx value. |
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 3000039999. |
| `alt` | Yes | NIP-31 human-readable fallback. |
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
### Publishing Flow
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
3. Sender signs and publishes a kind 8333 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
4. The event is published **after** broadcast; the txid is already final at that point.
### Client Behavior
**Querying onchain zaps for an event:**
```json
{ "kinds": [8333], "#e": ["<target-event-id>"], "limit": 100 }
```
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
**Verification (REQUIRED before trusting amounts):**
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
1. Extract the txid from the `i` tag.
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
3. Derive the recipient's expected Taproot address from the `p` tag pubkey.
4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to the recipient.
5. If the verified amount is 0, the event SHOULD be discarded.
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) equals the recipient pubkey from the `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals.
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per (txid, target) pair is canonical — when multiple events reference the same `txid` for the same target, the earliest is preferred.
**Network scope:** This specification applies to Bitcoin **mainnet** only. Testnet, signet, and other networks are out of scope; addresses and txids on those networks MUST NOT be used in kind 8333 events.
### Comparison with NIP-57 (Lightning Zaps)
| Aspect | NIP-57 (kind 9735) | This spec (kind 8333) |
|--------|---------------------|------------------------|
| Settlement | Lightning Network | Bitcoin L1 |
| Invoice / payment | LNURL + BOLT-11 invoice | Raw Bitcoin tx |
| Event issuer | Recipient's LNURL provider | Sender |
| Availability | Requires `lud06`/`lud16` on recipient profile | Always available (every Nostr pubkey has a derived Taproot addr) |
| Verification | Recipient zap-provider pubkey + bolt11 amount | On-chain tx verified against derived recipient address |
| Finality | Instant | Confirms in ~10 min (mempool first) |
| Fees | Sub-satoshi typical | Significant at low amounts |
The two zap kinds are complementary. Clients SHOULD sum verified amounts from both kinds when displaying total zap stats for a post or profile.
---
@@ -329,6 +428,18 @@ The following specifications are maintained by their respective authors. Ditto i
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
### Birdstar (Kinds 2473, 12473, 30621)
**Author:** Alex Gleason
**Spec:** https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md
**App:** https://birdstar.app
Birdstar merges Birdsong Spotter (a bird-by-ear checklist) and Starpoint (an interactive sky map with community constellations) into a single client.
- **Kind 2473 — Bird Detection.** A regular event representing a single identified bird observation. The species is identified by a NIP-73 `i`/`k` pair pointing at the species' Wikidata entity URI (e.g. `https://www.wikidata.org/entity/Q26825` for the American Robin). The `content` field holds an optional freeform human note about the detection. Required tags: NIP-31 `alt`, NIP-73 `i` (Wikidata URL) + `k` (`web`). Ditto renders detections as a species card with the Wikipedia thumbnail, common/scientific name, and article summary.
- **Kind 12473 — Birdex.** A replaceable event (one per author) indexing every distinct species the author has ever confirmed via kind 2473. Each species is a positional `i`/`n` pair — the Wikidata entity URI followed immediately by the scientific binomial name — emitted in chronological order of first detection. Ditto renders a Birdex as a tiled grid of species, each tile showing the Wikipedia thumbnail with the common name overlaid. In feeds, only the most recent few tiles are shown with a "+N" capstone mirroring how kind 3 follow lists preview members; the post-detail page shows every species.
- **Kind 30621 — Custom Constellation.** An addressable event (`d` tag) representing a single user-drawn star figure. Each `edge` tag (`["edge", from, to]`) references two Hipparcos catalog numbers as decimal strings — e.g. `["edge", "32349", "37279"]` for Sirius → Procyon. Required tags: `d`, `title`, `alt`, and at least one valid `edge`. The `content` field is a freeform description. Ditto renders constellations as a stylized SVG star-map (gnomonically projected onto a tangent plane at the figure's centroid, with stars sized by magnitude) using a bundled Hipparcos catalog that is code-split so the data only loads when a constellation is actually viewed.
### Geocaching (Kinds 37516, 7516)
**Author:** Chad Curtis
-1
View File
@@ -15,7 +15,6 @@ Made by [Soapbox](https://soapbox.pub).
- **Theming** -- 9 built-in theme presets, 19 CSS token properties for full customization, and the ability to publish and share themes as Nostr events
- **Infinite Content Types** -- Text notes, articles, short-form videos (Divines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
- **Lightning Payments** -- Zap posts and profiles with sats via Nostr Wallet Connect (NWC) or WebLN
- **Private Messaging** -- End-to-end encrypted DMs (NIP-04 and NIP-17)
- **Comments** -- Comment on anything: posts, URLs, profiles, hashtags, books, and more (NIP-22)
- **Self-Hosting** -- Builds to static HTML/JS/CSS. Deploy anywhere -- GitHub Pages, Netlify, Vercel, a VPS, or a Raspberry Pi
- **Mobile** -- Android native app via Capacitor, responsive design for all screen sizes
+213
View File
@@ -0,0 +1,213 @@
# Nostr-to-Bitcoin Wallet
This document explains how the application derives a Bitcoin Taproot address from a Nostr public key, enabling every Nostr identity to function as a Bitcoin wallet.
## Why This Works
Nostr and Bitcoin Taproot (BIP-341) share the exact same cryptographic primitives:
| Property | Nostr | Bitcoin Taproot |
|---|---|---|
| Curve | secp256k1 | secp256k1 |
| Signature scheme | Schnorr (BIP-340) | Schnorr (BIP-340) |
| Public key format | 32-byte x-only | 32-byte x-only |
Because the key formats are byte-for-byte identical, a Nostr public key can be used **directly** as a Taproot internal key with no mathematical conversion, hashing, or derivation.
## Derivation Algorithm
### Step 1 -- Parse the Public Key
A Nostr pubkey is a 64-character hex string representing 32 bytes. Convert it to a byte buffer:
```
pubkey (hex): e7a2e3b5f1c8d4a6... (64 hex chars = 32 bytes)
pubkeyBuffer: <Buffer e7 a2 e3 b5 f1 c8 d4 a6 ...>
```
### Step 2 -- Compute the Taproot Output Key
Bitcoin Taproot (BIP-341) defines a "tweaking" process for the internal key:
```
t = taggedHash("TapTweak", internalPubkey)
Q = P + t*G (where P = internal key, G = generator point)
```
When there is no script tree (key-path-only spend), only the internal key participates in the tweak. The result `Q` is the **output key** that appears on-chain.
This step is handled internally by `bitcoinjs-lib`'s `payments.p2tr()`.
### Step 3 -- Encode as a bech32m Address
The 32-byte output key `Q` is encoded with:
- Witness version: **1** (Taproot)
- Encoding: **bech32m** (BIP-350)
- Human-readable prefix: `bc` (mainnet)
The resulting address always starts with `bc1p`.
### Implementation
```typescript
import * as bitcoin from 'bitcoinjs-lib';
function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex');
const { address } = bitcoin.payments.p2tr({
internalPubkey: pubkeyBuffer,
network: bitcoin.networks.bitcoin,
});
return address; // "bc1p..."
}
```
### Example
```
Nostr pubkey (hex): 82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2
Bitcoin address: bc1pw0qkazw9twl4snwxal6v90djv3c8cph4s0w7rvtyp3k95rll3cqqhv4cn8
```
## Dependencies
| Package | Role |
|---|---|
| `bitcoinjs-lib` | P2TR address generation, PSBT construction |
| `@bitcoinerlab/secp256k1` | secp256k1 ECC operations (Schnorr, key tweaking) |
| `buffer` | Node.js Buffer polyfill for the browser |
The ECC library must be initialized once at startup:
```typescript
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
bitcoin.initEccLib(ecc);
```
## Balance & Transaction APIs
All Bitcoin data is fetched from the public [mempool.space](https://mempool.space) Esplora-compatible API:
| Endpoint | Purpose |
|---|---|
| `GET https://mempool.space/api/address/{address}` | Balance stats (funded/spent sums, tx counts) |
| `GET https://mempool.space/api/address/{address}/txs` | Transaction history for an address |
| `GET https://mempool.space/api/tx/{txid}` | Full transaction detail (inputs, outputs, fee, block) |
The wallet page polls balance and transaction data every 30 seconds. BTC/USD price is fetched from CoinGecko every 60 seconds.
## NIP-73 Integration
Transaction and address detail pages use [NIP-73](https://github.com/nostr-protocol/nips/blob/master/73.md) external content identifiers, enabling Nostr comments and reactions on Bitcoin transactions and addresses:
- **Transaction pages**: `/i/bitcoin:tx:{txid}` -- renders a mempool.space-style transaction view with inputs, outputs, fee, block info, and USD values
- **Address pages**: `/i/bitcoin:address:{address}` -- renders balance, recent transactions, and total received/sent
These pages are part of the existing `/i/*` external content system, which also supports URLs, ISBNs, country codes, and other NIP-73 identifier types.
## Sending Bitcoin
The wallet supports sending Bitcoin transactions directly from the app. Because Nostr and Bitcoin Taproot share the same private key, the Nostr key can sign Bitcoin transactions without any key conversion.
### Supported Signer Types
Sending works with all three login types:
| Login type | Signing method | How it works |
|---|---|---|
| **nsec** (secret key) | Local signing | The app applies the BIP-341 TapTweak and signs the PSBT directly using the private key. |
| **NIP-07 extension** | `window.nostr.signPsbt(hex)` | The unsigned PSBT hex is passed to the extension, which handles tweaking and signing internally. |
| **NIP-46 bunker** | `sign_psbt` RPC | The unsigned PSBT hex is sent to the remote signer over the NIP-46 relay channel. |
If the signer does not support PSBT signing (e.g. an extension without `signPsbt`), the send dialog displays an explanation and the user cannot proceed.
### Architecture
The send flow uses a three-step PSBT pipeline:
1. **`buildUnsignedPsbt()`** -- Constructs the PSBT with all inputs and outputs but no signatures. Only needs the sender's public key.
2. **`signer.signPsbt()`** -- Signs the PSBT. Dispatched through the signer interface (local, extension, or bunker).
3. **`finalizePsbt()`** -- Finalizes all inputs and extracts the raw transaction hex for broadcast.
The signer classes (`NSecSignerBtc`, `NBrowserSignerBtc`, `NConnectSignerBtc`) extend Nostrify's base signer classes with a `signPsbt` method. The `signerWithNudge` wrapper forwards `signPsbt` to the underlying signer, providing the same nudge toast UX for remote signers.
### Transaction Construction
The send flow constructs a standard Taproot (P2TR) key-path spend:
1. **Fetch UTXOs** -- All unspent outputs for the sender's address are retrieved from `mempool.space/api/address/{address}/utxo`.
2. **Fetch fee rates** -- Recommended fee rates (sat/vB) for four confirmation targets are retrieved from `mempool.space/api/fee-estimates`:
| Speed | Block target | Typical wait |
|---|---|---|
| Fastest | 1 block | ~10 minutes |
| Half hour | 3 blocks | ~30 minutes |
| One hour | 6 blocks | ~1 hour |
| Economy | 144 blocks | ~1 day |
3. **Resolve recipient** -- The recipient can be an `npub1...` (converted to its Taproot address via `npubToBitcoinAddress`) or a raw Bitcoin address (validated via `bitcoin.address.toOutputScript`).
4. **Build unsigned PSBT** -- All UTXOs are consumed as inputs (no coin selection). Each input includes:
- `witnessUtxo`: the P2TR output script and value
- `tapInternalKey`: the 32-byte x-only public key
5. **Estimate fee** -- The formula is `ceil((numInputs * 57.5 + numOutputs * 43 + 10.5) * feeRate)`. The output count is determined dynamically: if the change would be below the 546-sat dust limit, it is dropped (donated as extra miner fee) and the estimate uses 1 output instead of 2.
6. **Add outputs** -- The recipient output is always added. A change output (back to the sender's own Taproot address) is added only if the change exceeds the dust limit.
7. **Sign** -- The unsigned PSBT is passed to the signer's `signPsbt` method. For local nsec signing, the private key is tweaked for Taproot key-path spending (BIP-341):
```
tweak = taggedHash("TapTweak", internalPubkey)
tweakedKey = privateKey + tweak (mod n, with y-parity correction)
```
For extension and bunker signers, the tweaking is handled by the external signer.
8. **Finalize and broadcast** -- The signed PSBT is finalized, the raw transaction hex is extracted, and POSTed to `mempool.space/api/tx`, which returns the txid on success.
### User Flow
The send dialog has three steps:
1. **Form** -- Enter recipient, amount, and fee speed. Shows available balance, USD conversion, and a "Send Max" button that correctly subtracts the estimated fee.
2. **Confirm** -- Review recipient address, amount (BTC + USD), fee breakdown, and total debit before committing.
3. **Success** -- Shows the transaction ID with a link to the in-app NIP-73 detail page (`/i/bitcoin:tx:{txid}`).
### Additional API Endpoints
| Endpoint | Purpose |
|---|---|
| `GET https://mempool.space/api/address/{address}/utxo` | Unspent transaction outputs |
| `GET https://mempool.space/api/fee-estimates` | Recommended fee rates by block target |
| `POST https://mempool.space/api/tx` | Broadcast signed transaction (raw hex body) |
### Dependencies (sending-specific)
| Package | Role |
|---|---|
| `ecpair` | Key pair creation and Taproot key tweaking |
| `tiny-secp256k1` | Low-level ECC operations (peer dep of ecpair) |
These are in addition to the base dependencies listed above.
## Security Considerations
- The same private key (nsec in Nostr) controls both the Nostr identity and the Bitcoin funds at the derived address.
- Extension and bunker signers handle the private key internally -- the app never sees the raw key for those login types. Only the unsigned PSBT (which contains no secret material) is sent to the signer.
- This is a **single-key** Taproot address with no HD derivation (no BIP-32/BIP-44 path). Every Nostr keypair maps to exactly one Bitcoin address.
- Users should ensure they have secure backups of their Nostr private key before receiving Bitcoin at the derived address.
- Extensions and bunkers that support `signPsbt` / `sign_psbt` should display a confirmation dialog showing transaction outputs and amounts before signing.
## References
- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)
- [BIP-341: Taproot (SegWit v1)](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)
- [BIP-350: Bech32m](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki)
- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) (defines secp256k1 x-only keys for Nostr)
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.10.2"
versionName "2.12.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -327,7 +327,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.10.2;
MARKETING_VERSION = 2.12.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -351,7 +351,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.10.2;
MARKETING_VERSION = 2.12.1;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+214 -2
View File
@@ -1,13 +1,14 @@
{
"name": "ditto",
"version": "2.10.1",
"version": "2.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.10.1",
"version": "2.12.0",
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
@@ -95,14 +96,17 @@
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"bitcoinjs-lib": "^7.0.1",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"ecpair": "^3.0.1",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -129,6 +133,7 @@
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"tiny-secp256k1": "^2.2.4",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
@@ -336,6 +341,42 @@
"node": ">=6.9.0"
}
},
"node_modules/@bitcoinerlab/secp256k1": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.7.0"
}
},
"node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@capacitor/android": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.1.0.tgz",
@@ -7544,6 +7585,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/base-x": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -7564,6 +7611,12 @@
],
"license": "MIT"
},
"node_modules/bech32": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
"license": "MIT"
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
@@ -7586,6 +7639,37 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bip174": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz",
"integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.9",
"varuint-bitcoin": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/bitcoinjs-lib": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz",
"integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bech32": "^2.0.0",
"bip174": "^3.0.0",
"bs58check": "^4.0.0",
"uint8array-tools": "^0.0.9",
"valibot": "^1.2.0",
"varuint-bitcoin": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
@@ -7661,6 +7745,25 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bs58": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
"license": "MIT",
"dependencies": {
"base-x": "^5.0.0"
}
},
"node_modules/bs58check": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bs58": "^6.0.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -8106,6 +8209,12 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz",
"integrity": "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==",
"license": "BSD-3-Clause"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -8118,6 +8227,15 @@
"node": ">=12"
}
},
"node_modules/d3-celestial": {
"version": "0.7.35",
"resolved": "https://registry.npmjs.org/d3-celestial/-/d3-celestial-0.7.35.tgz",
"integrity": "sha512-cURxIl0E+FGWnYj6gTDt80SjuiM9lklcGykj/skVy7glDg5nj/QxTUoPPArU+bpEQ+1fLy5hi920OvJ/TgliRw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3": "^3.5.17"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -8448,6 +8566,29 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/ecpair": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz",
"integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.8",
"valibot": "^1.2.0",
"wif": "^5.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ecpair/node_modules/uint8array-tools": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.149",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
@@ -13534,6 +13675,27 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tiny-secp256k1": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.4.tgz",
"integrity": "sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "0.0.7"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tiny-secp256k1/node_modules/uint8array-tools": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz",
"integrity": "sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -13793,6 +13955,15 @@
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
"node_modules/uint8array-tools": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz",
"integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -14130,6 +14301,38 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/valibot": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz",
"integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==",
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/varuint-bitcoin": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.8"
}
},
"node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
@@ -15723,6 +15926,15 @@
"node": ">=8"
}
},
"node_modules/wif": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
"license": "MIT",
"dependencies": {
"bs58check": "^4.0.0"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+6 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.10.2",
"version": "2.12.1",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -15,6 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
@@ -102,14 +103,17 @@
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"bitcoinjs-lib": "^7.0.1",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
"ecpair": "^3.0.1",
"embla-carousel-react": "^8.3.0",
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
@@ -136,6 +140,7 @@
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"tiny-secp256k1": "^2.2.4",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
+123
View File
@@ -1,5 +1,128 @@
# Changelog
## [2.12.1] - 2026-05-01
### Changed
- The right widget sidebar now appears on iPad-landscape (1024px) viewports, and both sidebars scale fluidly with the window instead of snapping at fixed widths
- Hashtags with internal hyphens like `#bitcoin-conference` and `#70-706` now render as a single tag instead of being cut off at the dash
### Fixed
- Comments on country, book, and Bitcoin transaction/address pages now load correctly instead of showing an empty thread
## [2.12.0] - 2026-04-30
### Added
- Bitcoin wallet -- a new Wallet tab in the sidebar shows your balance in USD with BTC underneath, a transaction history that collapses when empty, and a 3-step send flow with a two-tap confirmation for amounts over $100
- Bitcoin zaps -- send on-chain Bitcoin directly to anyone on Nostr as a native alternative to Lightning, with an automatic QR-code fallback when your signer does not support Bitcoin
- Detail pages for Bitcoin transactions and addresses, with the block explorer URL configurable per deployment
- Evolve ceremony -- Blobbis now transform from baby to adult through an immersive full-screen animation with energy particles, a flash-to-reveal, and a typewriter reveal text
- Birdex life lists -- a compact species tile strip in feeds and a full responsive grid on the post-detail page, so visitors can browse an author's whole collection
- Bird-song recordings play inline on Wikipedia species pages, with an emerald play button next to the title and a credit link in the footer
### Changed
- Display names now use a consistent `name` then `display_name` fallback everywhere, so the same user renders the same way across the whole UI
- Hatching ceremony reveal background is now tinted by the baby Blobbi's color instead of a hardcoded blue, with a vignette overlay so the blobbi pops against same-hue backgrounds
- Bird Detection cards prefer the authoritative scientific name tag published by Birdstar, so cards stay labeled even when the post's alt text is generic
### Fixed
- "Discover people to follow" now lands on a populated Global tab instead of another empty Follows view
- Blobbi daily bounty progress is no longer wiped by profile writes, and persists reliably across page refreshes and app restarts
- Blobbi profile content (name, avatar, custom fields) is preserved across every profile update instead of being silently dropped by some write paths
- Blobbi hatch and evolve mission progress no longer resets from concurrent writes racing against each other
## [2.11.2] - 2026-04-28
### Fixed
- Unsupported event kinds no longer leak opaque identifiers or raw post content into previews and hover cards -- when an author-written description is missing, the card shows the "not supported" tombstone instead of an internal lookup string
## [2.11.1] - 2026-04-28
### Fixed
- Unsupported event kinds now show a clean fallback card with the author's description (or a "not supported" tombstone) instead of rendering raw JSON or empty content through the text-note parser -- applies in feeds, post detail, embedded quotes, reply context, compose previews, notifications, and inline nostr: mentions
## [2.11.0] - 2026-04-28
### Added
- Birdstar bird detections and hand-drawn constellations now render inline in feeds -- species cards with Wikipedia summaries, and gnomonic star maps backed by the Hipparcos catalog, with a "View on Birdstar" deep-link and a Discuss button that routes species comments to the shared Wikidata identifier
- Magic: The Gathering cards render from Gatherer URLs -- card art with a 3D mouse tilt and specular glare, face toggle for double-faced cards, lightbox, and hover-card previews across comments, detail pages, and external content
- Touch support for the Magic card 3D tilt -- press and drag to rock the card on mobile
- Wikidata entity URLs on the external content page render the entity's English Wikipedia article, falling back to a generic link preview when no article exists
- Webxdc embeds now render as a tilted Game Boy-style cartridge tinted by the app icon's dominant color, with the launch icon centered on the label
- Segmented Blobbi stat rings -- babies show 4 bars and adults show 10, so progress is visible at a glance instead of as a continuous sweep, and the ring gaps are now symmetrical
- Sleep is now restorative for Blobbis -- energy regenerates faster, hunger, happiness, and hygiene decay at a fraction of their awake rates, and health stops degrading entirely while asleep
- Blobbi rooms and carousel focus are remembered per-Blobbi across refreshes and Blobbi switches
- Action and fridge previews show segment impact alongside raw stat changes, so it's clear how much a bar will fill
- Profile recovery now shows follow-list snapshots as avatar stacks with the newest follows first, making different historical versions distinguishable at a glance
- Google Play and App Store listings are declared in the web manifest so browsers can surface the native apps
### Changed
- The "Follow Packs" feed toggle is now called "People Lists" and its description explains that it also covers follow lists and people sets
- Blobbi care thresholds are aligned to the new segment model -- attention and urgent states fire on clear bar boundaries instead of arbitrary numbers, and the care badge triggers when any stat is urgent or multiple stats need attention
- Awake decay rates rebalanced so growing up feels like increased resilience -- babies hit their first "okay" stat around 2.7 hours and adults around 5-6 hours
- Item effects rebalanced around the segment model -- basic items restore roughly one baby bar, medium items offer meaningful upgrades, and premium items deliver strong multi-stat effects
### Fixed
- Profile feeds now filter out deprecated follow sets, unlisted decks, hidden treasures, and empty emoji packs before rendering, matching the main feed's behavior
- Onboarding no longer clobbers a returning user's feed preferences with a hardcoded preset when their encrypted settings fetch comes back empty
## [2.10.5] - 2026-04-25
### Added
- Blobbi eye tracking -- your companion's eyes follow content on your feed and post detail pages, including touch support on mobile
- Blobbi overstimulation reaction -- tap your Blobbi too many times and it zooms in with a shockwave, blocking interactions until it calms down
- Shake-to-dizzy reaction -- shake your phone to make your Blobbi go woozy with a nausea fill effect
- Blobbi route-transition reactions -- your companion glances at where you tapped before looking at new content when navigating
- Guided care flow -- low-status indicators glow to get your attention, and tapping any stat icon walks you to the right room to help
- Drag-to-clean shovel -- swipe poop away instead of tapping a button, with poops now visible across all rooms
- Pandi color customizer with tinted-white body and dark-tinted patches
- Vines now appear in feeds -- short videos render inline with volume control
- Infinite scroll pagination on the search page
- Right-to-left text support expanded to articles, compose box, and letters
### Changed
- Blobbi colors are now generated from the seed instead of fixed palettes, giving every Blobbi a unique look
- Adult Blobbi type is now derived from the seed for fully deterministic visual identity
### Fixed
- Content warnings were not applied on the video and vine feed pages -- videos with sensitive tags played without blur or filtering
- Compose textarea lost its expanded height when toggling the markdown preview on and off
- Crysti Blobbi was missing sparkle animations, had a broken pink facet path, and mismatched sleeping opacities
- Adopting another Blobbi could create a duplicate egg due to a race condition in the hatching ceremony
## [2.10.4] - 2026-04-23
### Added
- Right-to-left text support for Arabic, Hebrew, and other RTL languages across posts, bios, and direct messages
- Blobbis now close their eyes when falling asleep and open them when waking up with smooth one-shot animations
### Changed
- Eye color and secondary color now apply consistently across all adult Blobbi forms
- Blobbi mission progress is now tracked per-Blobbi instead of per-account, so evolving multiple Blobbis no longer conflicts
### Fixed
- Swiping to dismiss a full-screen image could leave the controls flickering or locked in place
- Sleeping Blobbis appeared with open eyes in the Blobbies tab grid
- Catti reaction mouths rendered off-center instead of aligned with the face
- Catti whiskers disappeared when showing a reaction mouth
- Pandi eye tracking could lock onto ear patches instead of actual eyes
- Adult Blobbi eyebrows floated away from the eyes on certain body shapes
- Some adult Blobbi body shapes were not detected correctly for visual effects
- Poll and spoiler menu in the compose modal was clipped off-screen on mobile
- Duplicate Blobbis could appear when the legacy-to-new-format migration ran more than once
## [2.10.3] - 2026-04-20
### Added
- Music discovery page with a curated Discover tab, artist profiles, playlist detail pages with full playback, genre browsing, and hot/top/new sorting
- Swipe-to-dismiss gesture on full-screen image lightboxes -- flick up or down to close instead of reaching for the X button
- Autoplay videos setting in Content preferences (off by default) that plays videos muted in feeds and profiles
- Replies are now delivered to tagged users' inbox relays so they're more likely to see your response
### Fixed
- Status bar text could appear unreadable on light themes
- Envelope cards in the Letters inbox were hard to tap on mobile
- Wall compose box kept showing the previous post's text after submitting from the modal
## [2.10.2] - 2026-04-18
### Fixed
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

+14 -1
View File
@@ -87,5 +87,18 @@
"type": "image/png",
"form_factor": "narrow"
}
]
],
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=pub.ditto.app",
"id": "pub.ditto.app"
},
{
"platform": "itunes",
"url": "https://apps.apple.com/us/app/ditto-fun-social-media/id6761851821",
"id": "6761851821"
}
],
"prefer_related_applications": false
}
+8 -25
View File
@@ -1,14 +1,11 @@
// NOTE: This file should normally not be modified unless you are adding a new provider.
// To add new routes, edit the AppRouter.tsx file.
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
import { NostrLoginProvider } from "@nostrify/react/login";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InferSeoMetaPlugin } from "@unhead/addons";
import { createHead, UnheadProvider } from "@unhead/react/client";
import { useEffect } from "react";
import { AppProvider } from "@/components/AppProvider";
import { DMProvider, type DMConfig } from "@/components/DMProvider";
import { InitialSyncGate } from "@/components/InitialSyncGate";
import { NativeNotifications } from "@/components/NativeNotifications";
import NostrProvider from "@/components/NostrProvider";
@@ -21,17 +18,11 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
const dmConfig: DMConfig = {
enabled: false,
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
const head = createHead({
plugins: [InferSeoMetaPlugin()],
});
@@ -76,13 +67,13 @@ const hardcodedConfig: AppConfig = {
showTreasureGeocaches: true,
showTreasureFoundLogs: true,
showColors: true,
showPacks: true,
showPeopleLists: true,
feedIncludeVines: true,
feedIncludePolls: true,
feedIncludeTreasureGeocaches: true,
feedIncludeTreasureFoundLogs: true,
feedIncludeColors: true,
feedIncludePacks: true,
feedIncludePeopleLists: true,
showDecks: true,
feedIncludeDecks: true,
showWebxdc: true,
@@ -121,6 +112,10 @@ const hardcodedConfig: AppConfig = {
feedIncludeBadgeAwards: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
showBirdstar: true,
feedIncludeBirdDetections: true,
feedIncludeBirdex: true,
feedIncludeConstellations: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
@@ -151,6 +146,7 @@ const hardcodedConfig: AppConfig = {
plausibleDomain: import.meta.env.VITE_PLAUSIBLE_DOMAIN || "",
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
autoplayVideos: false,
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
@@ -190,17 +186,6 @@ const defaultConfig: AppConfig = {
export function App() {
useNsecPasteGuard();
useEffect(() => {
// Initialize system bars for mobile apps.
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
// setOverlaysWebView / setBackgroundColor no longer work. The new
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
if (Capacitor.isNativePlatform()) {
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
// SystemBars may not be available on all platforms
});
}
}, []);
return (
<UnheadProvider head={head}>
@@ -214,7 +199,6 @@ export function App() {
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<InitialSyncGate>
@@ -222,8 +206,7 @@ export function App() {
</InitialSyncGate>
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
</NWCProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
+2
View File
@@ -73,6 +73,7 @@ const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default:
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 WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
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 })));
@@ -255,6 +256,7 @@ export function AppRouter() {
}
/>
<Route path="/themes" element={<ThemesPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/blobbi" element={<BlobbiPage />} />
@@ -1,317 +0,0 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import {
filterInventoryByAction,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
canUseAction,
getStageRestrictionMessage,
ACTION_METADATA,
type InventoryAction,
type ResolvedInventoryItem,
type EggStatPreview,
} from '../lib/blobbi-action-utils';
interface BlobbiActionInventoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
}
export function BlobbiActionInventoryModal({
open,
onOpenChange,
action,
companion,
profile: _profile,
onUseItem,
onOpenShop: _onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
const availableItems = useMemo(() => {
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
const stageMessage = getStageRestrictionMessage(companion, action);
const isEmpty = availableItems.length === 0;
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
onUseItem(item.itemId);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
{actionMeta.icon}
</div>
<div className="min-w-0">
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
{actionMeta.description}
</p>
</div>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
{/* Stage Restriction Message */}
{!canUse && stageMessage && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
<span className="text-3xl">🥚</span>
</div>
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{stageMessage}
</p>
</div>
)}
{/* Empty State */}
{canUse && isEmpty && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for this action at your Blobbi's current stage.
</p>
</div>
)}
{/* Item List */}
{canUse && !isEmpty && (
<div className="grid gap-3">
{availableItems.map((item) => (
<BlobbiInventoryUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── Inventory Use Row ────────────────────────────────────────────────────────
interface BlobbiInventoryUseRowProps {
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
onUse: () => void;
isUsing: boolean;
disabled: boolean;
}
function BlobbiInventoryUseRow({
item,
companion,
action,
onUse,
isUsing,
disabled,
}: BlobbiInventoryUseRowProps) {
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
item.effect
),
};
}
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
};
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
{/* Top row on mobile: Icon + Info + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className="relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl">
{item.icon}
</div>
</div>
{/* Item Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
</div>
{/* Use Button */}
<Button
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
</div>
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
);
}
@@ -1,201 +0,0 @@
// src/blobbi/actions/components/BlobbiActionsModal.tsx
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
interface BlobbiActionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
onRest: () => void;
onInventoryAction: (action: InventoryAction) => void;
onDirectAction: (action: DirectAction) => void;
actionInProgress: string | null;
isPublishing: boolean;
}
export function BlobbiActionsModal({
open,
onOpenChange,
companion,
onRest,
onInventoryAction,
onDirectAction,
actionInProgress,
isPublishing,
}: BlobbiActionsModalProps) {
const isSleeping = companion.state === 'sleeping';
const isDisabled = isPublishing || actionInProgress !== null;
const isEgg = companion.stage === 'egg';
const handleAction = (action: () => void) => {
action();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle>Blobbi Actions</DialogTitle>
<p className="text-sm text-muted-foreground">{companion.name}</p>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="grid gap-3">
{/* Feed Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('feed'))}
disabled={isDisabled}
>
<Utensils className="size-5 text-orange-500" />
<div className="text-left">
<p className="font-medium">Feed</p>
<p className="text-xs text-muted-foreground">
Give your Blobbi something to eat
</p>
</div>
</Button>
)}
{/* Play Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('play'))}
disabled={isDisabled}
>
<Gamepad2 className="size-5 text-yellow-500" />
<div className="text-left">
<p className="font-medium">Play</p>
<p className="text-xs text-muted-foreground">
Play with toys to make your Blobbi happy
</p>
</div>
</Button>
)}
{/* Clean Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('clean'))}
disabled={isDisabled}
>
<SparklesIcon className="size-5 text-blue-500" />
<div className="text-left">
<p className="font-medium">Clean</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg clean and fresh'
: 'Keep your Blobbi clean and fresh'}
</p>
</div>
</Button>
{/* Medicine Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('medicine'))}
disabled={isDisabled}
>
<Pill className="size-5 text-green-500" />
<div className="text-left">
<p className="font-medium">Medicine</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg healthy'
: 'Heal your Blobbi'}
</p>
</div>
</Button>
{/* Play Music Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('play_music'))}
disabled={isDisabled}
>
<Music className="size-5 text-pink-500" />
<div className="text-left">
<p className="font-medium">Play Music</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Play soothing music for your egg'
: 'Play music for your Blobbi'}
</p>
</div>
</Button>
{/* Sing Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('sing'))}
disabled={isDisabled}
>
<Mic className="size-5 text-purple-500" />
<div className="text-left">
<p className="font-medium">Sing</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Sing a lullaby to your egg'
: 'Sing to your Blobbi'}
</p>
</div>
</Button>
{/* Sleep/Wake Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(onRest)}
disabled={isDisabled}
>
{actionInProgress === 'rest' ? (
<Loader2 className="size-5 animate-spin" />
) : isSleeping ? (
<Sun className="size-5 text-amber-500" />
) : (
<Moon className="size-5 text-violet-500" />
)}
<div className="text-left">
<p className="font-medium">{isSleeping ? 'Wake Up' : 'Sleep'}</p>
<p className="text-xs text-muted-foreground">
{isSleeping ? 'Wake your Blobbi up' : 'Put your Blobbi to sleep'}
</p>
</div>
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,531 +0,0 @@
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
/**
* Missions modal for Blobbi — card-grid quest board.
*
* Layout:
* 1. Sticky header with title, subtitle, legend help button, close
* 2. Current Focus section (hatch / evolve) — collapsible, default open
* 3. Daily Bounties section — collapsible, default open
* 4. Settings row — low emphasis toggle (not collapsible)
*
* Both main sections use lightweight Radix Collapsible wrappers.
* Collapsed headers still show summary info (progress / coins).
*/
import {
Loader2,
XCircle,
AlertTriangle,
X,
Eye,
Scroll,
Compass,
HelpCircle,
ChevronDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useState } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
import { TasksPanel } from './TasksPanel';
import { DailyMissionsPanel } from './DailyMissionsPanel';
import { useDailyMissions } from '../hooks/useDailyMissions';
import { useRerollMission } from '../hooks/useRerollMission';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
hatchTasks: HatchTasksResult;
evolveTasks: EvolveTasksResult;
onHatch: () => void;
isHatching: boolean;
onEvolve: () => void;
isEvolving: boolean;
onStopIncubation: () => Promise<void>;
isStoppingIncubation: boolean;
onStopEvolution: () => Promise<void>;
isStoppingEvolution: boolean;
availableStages?: ('egg' | 'baby' | 'adult')[];
showMissionCard?: boolean;
onToggleMissionCard?: (visible: boolean) => void;
}
// ─── Section Chevron ─────────────────────────────────────────────────────────
function SectionChevron({ open }: { open: boolean }) {
return (
<ChevronDown
className={cn(
'size-4 text-muted-foreground/60 transition-transform duration-200',
open && 'rotate-180',
)}
/>
);
}
// ─── Mission Type Legend ──────────────────────────────────────────────────────
function MissionTypeLegend() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="rounded-full p-1.5 opacity-50 hover:opacity-100 hover:bg-muted transition-all"
aria-label="Mission types legend"
>
<HelpCircle className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56 p-3">
<p className="text-xs font-semibold mb-2">Mission Types</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
<Scroll className="size-3 text-amber-500" />
</div>
<div>
<p className="text-xs font-medium">Daily Bounty</p>
<p className="text-[10px] text-muted-foreground">Resets every day</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-sky-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🥚</span>
</div>
<div>
<p className="text-xs font-medium">Hatch Task</p>
<p className="text-[10px] text-muted-foreground">Egg progression</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🐣</span>
</div>
<div>
<p className="text-xs font-medium">Evolve Task</p>
<p className="text-[10px] text-muted-foreground">Baby progression</p>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
// ─── Daily Missions Section ───────────────────────────────────────────────────
interface DailyMissionsSectionProps {
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({
availableStages,
disabled,
defaultOpen = true,
}: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayXp,
allComplete,
bonusUnlocked,
bonusXp,
noMissionsAvailable,
rerollsRemaining,
} = useDailyMissions({ availableStages });
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const completedCount = missions.filter((m) => m.complete).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<Scroll className="size-4 text-amber-500 dark:text-amber-400 shrink-0" />
<h3 className="font-semibold text-sm">Daily Bounties</h3>
</div>
<div className="flex items-center gap-2">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="tabular-nums">
{completedCount} / {missions.length}
</span>
{allComplete && (
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
</span>
)}
</div>
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
<DailyMissionsPanel
missions={missions}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayXp={todayXp}
disabled={disabled || isRerolling}
bonusUnlocked={bonusUnlocked}
bonusXp={bonusXp}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
interface StopConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companionName: string;
processType: 'incubation' | 'evolution';
onConfirm: () => Promise<void>;
isPending: boolean;
}
function StopConfirmationDialog({
open,
onOpenChange,
companionName,
processType,
onConfirm,
isPending,
}: StopConfirmationDialogProps) {
const handleConfirm = async () => {
await onConfirm();
onOpenChange(false);
};
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Stop {label}?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
<strong>{companionName}</strong>?
</p>
<p>
This will interrupt the {processType} process and clear all task progress.
You can restart {processType} later, but you'll need to complete the tasks again.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending}
className="bg-destructive hover:bg-destructive/90"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
`Stop ${label}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
interface CurrentFocusSectionProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
onComplete: () => void;
isCompleting: boolean;
onStop: () => Promise<void>;
isStopping: boolean;
defaultOpen?: boolean;
}
function CurrentFocusSection({
companion,
tasks,
processType,
onComplete,
isCompleting,
onStop,
isStopping,
defaultOpen = true,
}: CurrentFocusSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const isIncubation = processType === 'incubation';
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
const completeEmoji = isIncubation ? '🐣' : '';
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
const badgeLabel = isIncubation ? 'Hatch' : 'Evolve';
const category = isIncubation ? ('hatch' as const) : ('evolve' as const);
const completedCount = tasks.tasks.filter((t) => t.completed).length;
const totalTasks = tasks.tasks.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-xs font-semibold px-2 py-0.5',
isIncubation
? 'bg-sky-500/15 text-sky-600 dark:text-sky-400'
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
)}
>
{badgeLabel}
</Badge>
<span className="text-sm font-semibold">{title}</span>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'text-xs font-medium tabular-nums',
tasks.allCompleted
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)}
>
{completedCount} / {totalTasks}
</span>
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
{/* Task card grid */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onComplete={onComplete}
isCompleting={isCompleting}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
category={category}
/>
{/* Stop process — low emphasis */}
<div className="mt-3 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8 px-3"
>
{isStopping ? (
<>
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-3.5 mr-1.5" />
{stopLabel}
</>
)}
</Button>
</div>
</div>
</CollapsibleContent>
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
companionName={companion.name}
processType={processType}
onConfirm={onStop}
isPending={isStopping}
/>
</Collapsible>
);
}
// ─── Empty Focus State ────────────────────────────────────────────────────────
function EmptyFocusState() {
return (
<div className="py-6 text-center">
<Compass className="size-5 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No active progression right now</p>
</div>
);
}
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function BlobbiMissionsModal({
open,
onOpenChange,
companion,
hatchTasks,
evolveTasks,
onHatch,
isHatching,
onEvolve,
isEvolving,
onStopIncubation,
isStoppingIncubation,
onStopEvolution,
isStoppingEvolution,
availableStages,
showMissionCard,
onToggleMissionCard,
}: BlobbiMissionsModalProps) {
const isIncubating = companion.progressionState === 'incubating';
const isEvolvingState = companion.progressionState === 'evolving';
const isEgg = companion.stage === 'egg';
const isBaby = companion.stage === 'baby';
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
{/* ── Sticky Header ── */}
<div className="sticky top-0 z-10 bg-background px-4 sm:px-5 pt-4 pb-3 border-b border-border/60">
<div className="flex items-center justify-between">
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight">Missions</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Quests & bounties for {companion.name}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<MissionTypeLegend />
<DialogClose className="rounded-full p-1.5 opacity-60 hover:opacity-100 hover:bg-muted transition-all">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
</div>
{/* ── Scrollable Content ── */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-5 py-4 space-y-5">
{/* 1. Current Focus */}
{hasActiveProcess ? (
<>
{isIncubating && isEgg ? (
<CurrentFocusSection
companion={companion}
tasks={hatchTasks}
processType="incubation"
onComplete={onHatch}
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
/>
) : isEvolvingState && isBaby ? (
<CurrentFocusSection
companion={companion}
tasks={evolveTasks}
processType="evolution"
onComplete={onEvolve}
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
/>
) : null}
</>
) : (
<EmptyFocusState />
)}
{/* Divider */}
<div className="h-px bg-border/60" />
{/* 2. Daily Bounties */}
<DailyMissionsSection
availableStages={availableStages}
disabled={isProcessBusy}
/>
{/* 3. Settings */}
{onToggleMissionCard !== undefined && showMissionCard !== undefined && (
<>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between py-1">
<Label
htmlFor="mission-card-toggle"
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer"
>
<Eye className="size-3.5" />
Show mission card on main page
</Label>
<Switch
id="mission-card-toggle"
checked={showMissionCard}
onCheckedChange={onToggleMissionCard}
/>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,284 +0,0 @@
/**
* DailyMissionsPanel — card-grid layout for daily bounties.
*
* Each mission is a compact card in a 2-col grid.
* Tapping a card expands it to show progress and reroll.
* Only one card expanded at a time.
* Completion is implicit (derived from progress vs target).
*/
import { useState } from 'react';
import {
Check,
Sparkles,
Gift,
Egg,
Trophy,
RefreshCw,
Heart,
Utensils,
Droplets,
Moon,
Camera,
Mic,
Music,
Pill,
CircleDot,
Zap,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMissionAction } from '../lib/daily-missions';
import type { DailyMissionView } from '../hooks/useDailyMissions';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
missions: DailyMissionView[];
onRerollMission?: (missionId: string) => void;
todayXp: number;
disabled?: boolean;
bonusUnlocked?: boolean;
bonusXp?: number;
noMissionsAvailable?: boolean;
rerollsRemaining?: number;
isRerolling?: boolean;
}
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
const cls = 'size-5';
switch (action) {
case 'interact':
return <Heart className={cls} />;
case 'feed':
return <Utensils className={cls} />;
case 'clean':
return <Droplets className={cls} />;
case 'sleep':
return <Moon className={cls} />;
case 'take_photo':
return <Camera className={cls} />;
case 'sing':
return <Mic className={cls} />;
case 'play_music':
return <Music className={cls} />;
case 'medicine':
return <Pill className={cls} />;
default:
return <CircleDot className={cls} />;
}
}
// ─── Bonus Card ───────────────────────────────────────────────────────────────
interface BonusCardProps {
isUnlocked: boolean;
xp: number;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusCard({ isUnlocked, xp, isExpanded, onToggle }: BonusCardProps) {
return (
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isUnlocked}
progress={isUnlocked ? 1 : 0}
isExpanded={isExpanded}
onToggle={onToggle}
>
<MissionDescription>
{isUnlocked
? 'Bonus XP for completing all daily missions!'
: 'Complete all missions to unlock this bonus'}
</MissionDescription>
<div className="flex items-center gap-1 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
+{formatCompactNumber(xp)} XP
</div>
</ExpandableMissionCard>
);
}
// ─── Empty / Done States ──────────────────────────────────────────────────────
function NoMissionsState() {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Egg className="size-5 text-muted-foreground/50" />
<div>
<p className="text-sm font-medium">Hatch your Blobbi first</p>
<p className="text-xs text-muted-foreground mt-0.5">
Daily missions unlock after hatching
</p>
</div>
</div>
);
}
function AllCompleteState({ todayXp }: { todayXp: number }) {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-5 text-primary/60" />
<div>
<p className="text-sm font-medium">All done for today</p>
<p className="text-xs text-muted-foreground mt-0.5">
Earned{' '}
<span className="font-medium text-violet-600 dark:text-violet-400">
{formatCompactNumber(todayXp)} XP
</span>{' '}
come back tomorrow!
</p>
</div>
</div>
);
}
// ─── Reroll Counter ───────────────────────────────────────────────────────────
function RerollCounter({ remaining }: { remaining: number }) {
const text =
remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
return (
<div className="flex items-center justify-end gap-1 text-[11px] text-muted-foreground col-span-full">
<RefreshCw className="size-2.5" />
<span>{text}</span>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function DailyMissionsPanel({
missions,
onRerollMission,
todayXp,
disabled,
bonusUnlocked = false,
bonusXp = 50,
noMissionsAvailable = false,
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
if (noMissionsAvailable) return <NoMissionsState />;
const allComplete = missions.every((m) => m.complete);
if (allComplete && bonusUnlocked) return <AllCompleteState todayXp={todayXp} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{/* Reroll counter */}
{onRerollMission && <RerollCounter remaining={rerollsRemaining} />}
{/* Regular mission cards */}
{missions.map((mission) => {
const progressFrac = mission.target > 0 ? mission.progress / mission.target : 0;
const showReroll = onRerollMission && !mission.complete && canReroll;
return (
<ExpandableMissionCard
key={mission.id}
id={mission.id}
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.complete}
progress={Math.min(progressFrac, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
{/* Description */}
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.complete && (
<MissionProgress
current={mission.progress}
required={mission.target}
completed={mission.complete}
/>
)}
{/* XP + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
{formatCompactNumber(mission.xp)} XP
</span>
{showReroll && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onRerollMission(mission.id);
}}
disabled={disabled || isRerolling}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
>
<RefreshCw className={cn('size-3', isRerolling && 'animate-spin')} />
</button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{mission.complete && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
</span>
)}
</div>
{/* Complete indicator */}
{mission.complete && (
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<Gift className="size-3.5" />
+{formatCompactNumber(mission.xp)} XP earned
</div>
)}
</ExpandableMissionCard>
);
})}
{/* Bonus card */}
<BonusCard
isUnlocked={bonusUnlocked}
xp={bonusXp}
isExpanded={expandedId === 'bonus'}
onToggle={handleToggle}
/>
</div>
);
}
@@ -1,211 +0,0 @@
// src/blobbi/actions/components/HatchTasksPanel.tsx
/**
* UI component for displaying hatch task progress.
* Shows a list of tasks with progress indicators and action buttons.
*/
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { HatchTask } from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HatchTasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when all tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching?: boolean;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
}
function TaskRow({ task }: TaskRowProps) {
const navigate = useNavigate();
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
}
};
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,
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}
/>
))}
{/* 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>
);
}
-601
View File
@@ -1,601 +0,0 @@
// src/blobbi/actions/components/SingModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isLoading: boolean;
}
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
/**
* Ordered list of MIME types to try for audio recording.
* The first supported type will be used.
*/
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
/**
* Get the first supported MIME type for MediaRecorder.
* Returns undefined if no explicit MIME type is supported (let browser decide).
*/
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
// No explicit MIME type supported, let browser use default
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function SingModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: SingModalProps) {
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [error, setError] = useState<string | null>(null);
const [playbackError, setPlaybackError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Stop audio playback
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl]);
const resetRecording = useCallback(() => {
cleanup();
setRecordingState('idle');
setError(null);
setPlaybackError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
currentPlaybackUrlRef.current = null;
actualMimeTypeRef.current = undefined;
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open, cleanup, resetRecording]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
setCurrentLyrics(lyrics);
setShowLyrics(true);
}, []);
// Check if browser supports media recording
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setError(null);
setPlaybackError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get the first supported MIME type using our helper
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder with or without explicit MIME type
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
// Let browser choose default MIME type
mediaRecorder = new MediaRecorder(stream);
}
// Store the actual MIME type being used (may differ from what we requested)
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
// Create blob from chunks using the actual MIME type used by the recorder
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setError('Recording failed. Please try again.');
setRecordingState('error');
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setRecordingState('recording');
setRecordingDuration(0);
// Start timer
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('Microphone access was denied. Please allow microphone access and try again.');
} else if (err.name === 'NotFoundError') {
setError('No microphone found. Please connect a microphone and try again.');
} else {
setError(`Failed to access microphone: ${err.message}`);
}
} else {
setError('Failed to access microphone. Please try again.');
}
setRecordingState('error');
}
}, []);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
}, []);
// Track the current audio URL to detect changes
const currentPlaybackUrlRef = useRef<string | null>(null);
// Play/pause preview
const togglePlayback = useCallback(() => {
if (!audioUrl) return;
// Clear previous playback error when attempting to play
setPlaybackError(null);
if (recordingState === 'playing') {
if (audioRef.current) {
audioRef.current.pause();
}
setRecordingState('recorded');
} else {
// Check if we need to create a new Audio instance (URL changed or first time)
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the recorded audio URL
audioRef.current = new Audio(audioUrl);
currentPlaybackUrlRef.current = audioUrl;
audioRef.current.onended = () => setRecordingState('recorded');
// Handle playback errors with user-visible message
audioRef.current.onerror = () => {
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
setRecordingState('recorded');
};
}
audioRef.current?.play()
.then(() => {
setRecordingState('playing');
})
.catch((err) => {
console.error('Failed to play recording:', err);
// Provide user-friendly error message
if (err.name === 'NotSupportedError') {
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
} else if (err.name === 'NotAllowedError') {
setPlaybackError('Playback was blocked. Try interacting with the page first.');
} else {
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
}
setRecordingState('recorded');
});
}
}, [audioUrl, recordingState]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
}
onConfirm();
}, [onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen) {
cleanup();
}
onOpenChange(isOpen);
}, [onOpenChange, cleanup]);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
<Mic className="size-5 text-purple-500" />
</div>
<div>
<DialogTitle className="text-xl">Sing</DialogTitle>
<p className="text-sm text-muted-foreground">
Record yourself singing for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 px-6 py-8">
<div className="flex flex-col items-center justify-center gap-6">
{/* Recording Visualization */}
<div className={cn(
"relative size-40 rounded-full flex items-center justify-center transition-all",
recordingState === 'recording' && "animate-pulse",
recordingState === 'recording'
? "bg-red-500/10 ring-4 ring-red-500/30"
: hasRecording
? "bg-purple-500/10 ring-4 ring-purple-500/30"
: "bg-muted"
)}>
{/* Animated rings for recording */}
{recordingState === 'recording' && (
<>
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
</>
)}
{/* Icon */}
<div className={cn(
"relative size-20 rounded-full flex items-center justify-center",
recordingState === 'recording'
? "bg-red-500 text-white"
: hasRecording
? "bg-purple-500 text-white"
: "bg-muted-foreground/20"
)}>
{recordingState === 'requesting' ? (
<Loader2 className="size-8 animate-spin" />
) : recordingState === 'recording' ? (
<Mic className="size-8" />
) : hasRecording ? (
recordingState === 'playing' ? (
<Pause className="size-8" />
) : (
<Play className="size-8 ml-1" />
)
) : (
<MicOff className="size-8 text-muted-foreground" />
)}
</div>
</div>
{/* Duration / Status */}
<div className="text-center">
{recordingState === 'idle' && (
<p className="text-muted-foreground">Tap the button below to start recording</p>
)}
{recordingState === 'requesting' && (
<p className="text-muted-foreground">Requesting microphone access...</p>
)}
{recordingState === 'recording' && (
<>
<p className="text-3xl font-mono font-bold text-red-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
</>
)}
{hasRecording && (
<>
<p className="text-3xl font-mono font-bold text-purple-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
</p>
</>
)}
{recordingState === 'error' && (
<p className="text-destructive">Recording failed</p>
)}
</div>
{/* Error Message */}
{error && (
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
)}
{/* Playback Error Message (non-fatal, recording still works) */}
{playbackError && (
<div className="w-full p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{playbackError}</p>
</div>
</div>
)}
{/* Lyrics Helper */}
<div className="w-full">
{!currentLyrics ? (
<Button
variant="outline"
size="sm"
onClick={handleRandomLyrics}
className="w-full gap-2"
>
<Sparkles className="size-4" />
Need lyrics? Get random lyrics
</Button>
) : (
<div className="rounded-lg border bg-card/60">
<button
type="button"
onClick={() => setShowLyrics(!showLyrics)}
className="w-full flex items-center justify-between p-3 text-left"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-purple-500" />
<span className="font-medium text-sm">{currentLyrics.title}</span>
</div>
{showLyrics ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</button>
{showLyrics && (
<div className="px-3 pb-3 pt-0">
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
{currentLyrics.lines.join('\n')}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRandomLyrics}
className="w-full mt-2 gap-2 text-muted-foreground"
>
<RotateCcw className="size-3" />
Get different lyrics
</Button>
</div>
)}
</div>
)}
</div>
{/* Recording Controls */}
<div className="flex items-center gap-3">
{recordingState === 'idle' || recordingState === 'error' ? (
<Button
size="lg"
onClick={startRecording}
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-5 mr-2" />
Start Recording
</Button>
) : recordingState === 'recording' ? (
<Button
size="lg"
variant="destructive"
onClick={stopRecording}
className="rounded-full px-8"
>
<Square className="size-5 mr-2" />
Stop
</Button>
) : hasRecording ? (
<>
<Button
size="lg"
variant="outline"
onClick={togglePlayback}
className="rounded-full"
>
{recordingState === 'playing' ? (
<>
<Pause className="size-5 mr-2" />
Pause
</>
) : (
<>
<Play className="size-5 mr-2" />
Preview
</>
)}
</Button>
<Button
size="lg"
variant="ghost"
onClick={resetRecording}
className="rounded-full"
>
<RotateCcw className="size-5 mr-2" />
Re-record
</Button>
</>
) : null}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!hasRecording || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Singing...
</>
) : (
<>
<Mic className="size-4 mr-2" />
Sing for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,125 +0,0 @@
// src/blobbi/actions/components/StartEvolutionDialog.tsx
/**
* Dialog for confirming start of evolution.
*
* Evolution is simpler than incubation:
* - Only baby Blobbis can evolve
* - Shows restart confirmation if already evolving
* - Otherwise shows normal start confirmation
*/
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartEvolutionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start evolving */
companion: BlobbiCompanion | null;
/** Called when confirmed */
onConfirm: () => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartEvolutionDialog({
open,
onOpenChange,
companion,
onConfirm,
isPending,
}: StartEvolutionDialogProps) {
// Check if the current Blobbi is already evolving
const isAlreadyEvolving = companion?.progressionState === 'evolving';
// Determine title and description based on state
const getDialogContent = () => {
if (isAlreadyEvolving) {
return {
title: 'Restart Evolution?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Evolution',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Evolution',
icon: <Sparkles className="size-5 text-primary" />,
description: (
<>
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
Complete all the tasks to evolve your baby Blobbi into an adult!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Evolution',
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,180 +0,0 @@
// src/blobbi/actions/components/StartIncubationDialog.tsx
/**
* Dialog for confirming start of incubation.
*
* Determines the mode and passes it explicitly to the confirm callback:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating)
* - 'switch': Stop another Blobbi first, then start this one
*
* The mode is determined by UI state, NOT auto-detected by the hook.
* This makes the flow explicit and predictable.
*/
import { useMemo } from 'react';
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartIncubationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start incubating */
companion: BlobbiCompanion | null;
/** All companions in the collection (to check for other incubating Blobbis) */
companions?: BlobbiCompanion[];
/** Called with explicit mode and optional stopOtherD when confirmed */
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartIncubationDialog({
open,
onOpenChange,
companion,
companions = [],
onConfirm,
isPending,
}: StartIncubationDialogProps) {
// Check if the current Blobbi is already in a task state
const isAlreadyInTaskState = companion?.progressionState === 'incubating' || companion?.progressionState === '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.progressionState === '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?.progressionState}. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Incubation',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
if (mode === 'switch') {
return {
title: 'Switch Incubation?',
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
description: (
<>
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
Only one Blobbi can incubate at a time.
<br /><br />
Starting incubation for <strong>{companion?.name}</strong> will{' '}
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
reset their task progress.
<br /><br />
Do you want to switch?
</>
),
buttonText: 'Switch & Start',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Incubation',
icon: null,
description: (
<>
Starting incubation begins your Blobbi's hatching journey.
Complete all the tasks to hatch your egg into a baby Blobbi!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Incubation',
buttonClass: undefined,
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -1,187 +0,0 @@
// src/blobbi/actions/components/TasksPanel.tsx
/**
* Card-grid presentation for hatch / evolve tasks.
*
* Each task is a compact card in a 2-column grid.
* Tapping a card expands it inline (full row) to reveal details.
* Only one card is expanded at a time.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Palette,
Droplets,
Heart,
UserPen,
Activity,
Loader2,
HelpCircle,
} from 'lucide-react';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import type { HatchTask } from '../hooks/useHatchTasks';
import type { MissionCategory } from './ExpandableMissionCard';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
MissionAction,
DynamicHint,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface TasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
onComplete: () => void;
isCompleting?: boolean;
completeLabel: string;
completingLabel: string;
completeEmoji: string;
/** Mission category for styling the cards */
category?: MissionCategory;
}
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
/** Map task ids to lucide icons. Falls back to a generic icon. */
function TaskIcon({ taskId }: { taskId: string }) {
const iconClass = 'size-5';
switch (taskId) {
case 'create_themes':
return <Palette className={iconClass} />;
case 'color_moments':
return <Droplets className={iconClass} />;
case 'interactions':
return <Heart className={iconClass} />;
case 'edit_profile':
return <UserPen className={iconClass} />;
case 'maintain_stats':
return <Activity className={iconClass} />;
default:
return <HelpCircle className={iconClass} />;
}
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function TasksPanel({
tasks,
allCompleted,
isLoading,
onComplete,
isCompleting = false,
completeLabel,
completingLabel,
completeEmoji,
category = 'hatch',
}: TasksPanelProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const navigate = useNavigate();
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
{/* Card grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{tasks.map((task) => {
const isDynamic = task.type === 'dynamic';
const progress =
task.required > 0 ? task.current / task.required : task.completed ? 1 : 0;
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
}
};
return (
<ExpandableMissionCard
key={task.id}
id={task.id}
category={category}
icon={<TaskIcon taskId={task.id} />}
title={task.name}
completed={task.completed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === task.id}
onToggle={handleToggle}
>
{/* Expanded content */}
<MissionDescription>{task.description}</MissionDescription>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !isDynamic && (
<MissionProgress
current={task.current}
required={task.required}
completed={task.completed}
/>
)}
{/* Dynamic stat hint */}
{isDynamic && !task.completed && (
<DynamicHint current={task.current} required={task.required} />
)}
{/* Action link */}
{task.action && task.actionLabel && !task.completed && (
<MissionAction
label={task.actionLabel}
type={task.action}
onClick={handleAction}
/>
)}
</ExpandableMissionCard>
);
})}
</div>
{/* CTA button when all tasks are done */}
{allCompleted && (
<Button
onClick={onComplete}
disabled={isCompleting}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white shadow-sm"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-lg">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
)}
</div>
);
}
@@ -132,6 +132,7 @@ export function useBlobbiCareActivity({
kind: KIND_BLOBBI_STATE,
content: freshCompanion.event.content,
tags: updatedTags,
prev: freshCompanion.event,
});
// Update local cache (optimistic — no invalidation needed)
@@ -18,8 +18,9 @@ import {
DIRECT_ACTION_METADATA,
type DirectAction,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '../lib/daily-mission-tracker';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally, readEvolutionFromStorage } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
@@ -150,9 +151,20 @@ export function useBlobbiDirectAction({
const progressionState = canonical.companion.progressionState;
const updatedTags = canonical.allTags;
if (progressionState === 'incubating' || progressionState === 'evolving') {
trackEvolutionMissionTally('interactions', 1, user.pubkey);
trackEvolutionMissionTally('interactions', 1, user.pubkey, canonical.companion.d);
}
// ─── Build content with latest evolution state ───
// Read the updated evolution from session store so the publish carries
// the latest progress, instead of relying on the debounce hook.
let content = canonical.content;
if (progressionState === 'incubating' || progressionState === 'evolving') {
const evo = readEvolutionFromStorage(user.pubkey, canonical.companion.d);
if (evo && evo.length > 0) {
content = serializeEvolutionContent(canonical.content, evo);
}
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
@@ -172,8 +184,9 @@ export function useBlobbiDirectAction({
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
content,
tags: blobbiTags,
prev: canonical.companion.event,
});
updateCompanionEvent(blobbiEvent);
+38 -33
View File
@@ -27,10 +27,11 @@ import {
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { createHatchMissions, createEvolveMissions } from '../lib/evolution-missions';
import {
ensureSessionStore,
writeMissionsToStorage,
writeEvolutionToStorage,
clearEvolutionFromStorage,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -206,15 +207,21 @@ export function useStartIncubation({
last_decay_at: nowStr,
});
// Clear evolution from the other Blobbi's content
const otherContent = serializeEvolutionContent(otherEvent.content, []);
// Publish the stop event for the other Blobbi
const stopEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: otherEvent.content,
content: otherContent,
tags: otherNewTags,
});
// Update the cache for the stopped Blobbi
updateCompanionEvent(stopEvent);
// Clear evolution session store for the stopped Blobbi
clearEvolutionFromStorage(user.pubkey, stopOtherD);
}
}
@@ -261,22 +268,22 @@ export function useStartIncubation({
last_decay_at: nowStr,
});
// ─── Build evolution content for 31124 ───
const hatchMissions = createHatchMissions();
const content = serializeEvolutionContent(canonical.content, hatchMissions);
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
content,
tags: newTags,
});
updateCompanionEvent(event);
// ─── Populate evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: createHatchMissions() },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
// ─── Populate evolution missions in session store (per-Blobbi) ───
writeEvolutionToStorage(hatchMissions, user.pubkey, canonical.companion.d);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } }));
return {
name: canonical.companion.name,
@@ -424,22 +431,21 @@ export function useStopIncubation({
last_decay_at: nowStr,
});
// ─── Clear evolution from 31124 content ───
const content = serializeEvolutionContent(canonical.content, []);
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
content,
tags: newTags,
});
updateCompanionEvent(event);
// ─── Clear evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: [] },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
clearEvolutionFromStorage(user.pubkey, canonical.companion.d);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } }));
return {
name: canonical.companion.name,
@@ -557,22 +563,22 @@ export function useStartEvolution({
last_decay_at: nowStr,
});
// ─── Build evolution content for 31124 ───
const evolveMissions = createEvolveMissions();
const content = serializeEvolutionContent(canonical.content, evolveMissions);
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
content,
tags: newTags,
});
updateCompanionEvent(event);
// ─── Populate evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: createEvolveMissions() },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
// ─── Populate evolution missions in session store (per-Blobbi) ───
writeEvolutionToStorage(evolveMissions, user.pubkey, canonical.companion.d);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } }));
return {
name: canonical.companion.name,
@@ -705,22 +711,21 @@ export function useStopEvolution({
last_decay_at: nowStr,
});
// ─── Clear evolution from 31124 content ───
const content = serializeEvolutionContent(canonical.content, []);
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
content,
tags: newTags,
});
updateCompanionEvent(event);
// ─── Clear evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: [] },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
clearEvolutionFromStorage(user.pubkey, canonical.companion.d);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } }));
return {
name: canonical.companion.name,
@@ -27,19 +27,22 @@ import {
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { createEvolveMissions } from '../lib/evolution-missions';
import { writeEvolutionToStorage, clearEvolutionFromStorage } from '../lib/daily-mission-tracker';
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"
* Now stores JSON with an optional evolution array.
* Falls back to a descriptive JSON content when no evolution is active.
*/
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
function generateBlobbiContent(_name: string, _stage: BlobbiStage): string {
// Return empty JSON — evolution will be populated separately when needed.
// The old plain-text format ("Luna is an egg Blobbi.") is no longer used.
return JSON.stringify({});
}
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -209,9 +212,13 @@ export function useBlobbiHatch({
progression_started_at: nowStr,
});
// ─── Generate New Content for Baby Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
// ─── Write evolution missions into 31124 content ───
// Baby auto-starts evolution, so seed the missions immediately.
const evolveMissions = createEvolveMissions();
const newContent = serializeEvolutionContent(
generateBlobbiContent(canonical.companion.name, 'baby'),
evolveMissions,
);
// ─── Publish Event ───
const event = await publishEvent({
@@ -222,6 +229,12 @@ export function useBlobbiHatch({
updateCompanionEvent(event);
// ─── Seed evolution session store for immediate tally tracking ───
if (user?.pubkey) {
writeEvolutionToStorage(evolveMissions, user.pubkey, canonical.companion.d);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } }));
}
return {
previousStage: 'egg',
newStage: 'baby',
@@ -360,9 +373,11 @@ export function useBlobbiEvolve({
progression_state: 'none',
});
// ─── Generate New Content for Adult Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
// ─── Clear evolution from 31124 content (progression complete) ───
const newContent = serializeEvolutionContent(
generateBlobbiContent(canonical.companion.name, 'adult'),
[],
);
// ─── Publish Event ───
const event = await publishEvent({
@@ -373,6 +388,11 @@ export function useBlobbiEvolve({
updateCompanionEvent(event);
// ─── Clear evolution session store ───
if (user?.pubkey) {
clearEvolutionFromStorage(user.pubkey, canonical.companion.d);
}
return {
previousStage: 'baby',
newStage: 'adult',
@@ -380,12 +400,6 @@ export function useBlobbiEvolve({
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',
@@ -24,8 +24,8 @@ import {
type InventoryAction,
ACTION_METADATA,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { trackEvolutionMissionTally, readEvolutionFromStorage, trackInventoryDailyActions } from '../lib/daily-mission-tracker';
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
@@ -244,9 +244,18 @@ export function useBlobbiUseInventoryItem({
const progressionState = canonical.companion.progressionState;
const updatedTags = canonical.allTags;
if (progressionState === 'incubating' || progressionState === 'evolving') {
trackEvolutionMissionTally('interactions', 1, user?.pubkey);
trackEvolutionMissionTally('interactions', 1, user?.pubkey, canonical.companion.d);
}
// ─── Build content with latest evolution state ───
let content = canonical.content;
if (progressionState === 'incubating' || progressionState === 'evolving') {
const evo = readEvolutionFromStorage(user?.pubkey, canonical.companion.d);
if (evo && evo.length > 0) {
content = serializeEvolutionContent(canonical.content, evo);
}
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
@@ -265,8 +274,9 @@ export function useBlobbiUseInventoryItem({
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
content,
tags: blobbiTags,
prev: canonical.companion.event,
});
updateCompanionEvent(blobbiEvent);
@@ -293,11 +303,7 @@ export function useBlobbiUseInventoryItem({
});
// 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);
trackInventoryDailyActions(action, user?.pubkey);
},
onError: (error: Error) => {
toast({
+65 -38
View File
@@ -1,7 +1,7 @@
/**
* useDailyMissions - Hook for reading daily mission state
*
* Provides reactive access to the current day's missions.
* Provides reactive access to the current day's daily missions.
* Progress tracking is done via the tracker module (non-React).
* Completion is implicit (derived from count/events vs target).
* XP is awarded automatically when missions complete.
@@ -10,6 +10,9 @@
* switch, hydrates from kind 11125 content JSON if the session store
* is empty. Completed missions are persisted by `useAwardDailyXp`;
* intermediate progress resets on page refresh.
*
* NOTE: Evolution missions are NOT managed here. They live on kind 31124
* (per-Blobbi) and are handled by the evolution session store.
*/
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
@@ -34,9 +37,8 @@ import {
} from '../lib/daily-missions';
import {
readMissionsFromStorage,
writeMissionsToStorage,
hydrateFromPersisted,
readDailyFromStorage,
writeDailyToStorage,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -66,7 +68,7 @@ export interface UseDailyMissionsOptions {
/**
* Raw content string from the kind 11125 profile event.
* Pass `profile.content` here. The hook parses it to extract
* persisted missions and hydrates the session store on first load.
* persisted daily missions and hydrates the session store on first load.
*/
profileContent?: string;
}
@@ -84,8 +86,14 @@ export interface UseDailyMissionsResult {
bonusUnlocked: boolean;
/** Bonus XP amount */
bonusXp: number;
/** Whether user has no eligible missions */
/**
* Whether the account has no hatched (baby/adult) Blobbi at all.
* True only when availableStages contains neither 'baby' nor 'adult'.
* NOT a loading indicator — use `isLoading` for that.
*/
noMissionsAvailable: boolean;
/** Whether the hook is still hydrating and missions aren't ready yet */
isLoading: boolean;
/** Rerolls remaining today */
rerollsRemaining: number;
/** Max rerolls per day */
@@ -107,38 +115,54 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
// Track whether we've hydrated for this pubkey
const hydratedRef = useRef<string | null>(null);
// Hydrate session store from kind 11125 content on mount / account switch
// Hydrate session store from kind 11125 content on mount / account switch.
// If profileContent hasn't loaded yet, we still mark as hydrated so the hook
// can generate fresh missions rather than blocking. If profileContent arrives
// later with persisted progress for today, we merge it in.
const profileHydratedRef = useRef<string | null>(null);
useEffect(() => {
if (!pubkey || !profileContent) return;
if (hydratedRef.current === pubkey) return; // already hydrated this session
if (!pubkey) return;
// Check if session store already has data for this pubkey
const existing = readMissionsFromStorage(pubkey);
if (existing) {
hydratedRef.current = pubkey;
if (!profileContent) {
// No profile loaded yet — mark hydrated so we can generate fresh missions.
// When profileContent arrives, this effect re-runs and can merge.
if (hydratedRef.current !== pubkey) {
hydratedRef.current = pubkey;
setVersion((v) => v + 1);
}
return;
}
// Parse persisted missions from profile content
// Only attempt profile hydration once per pubkey per session
if (profileHydratedRef.current === pubkey) return;
profileHydratedRef.current = pubkey;
// Parse persisted daily missions from profile content
const parsed = parseProfileContent(profileContent);
if (parsed.missions && !needsDailyReset(parsed.missions)) {
// Daily missions are still current — hydrate the full object
hydrateFromPersisted(parsed.missions, pubkey);
} else if (parsed.missions?.evolution?.length) {
// Daily missions need a reset, but evolution missions survive across days.
// Seed the store with fresh dailies + persisted evolution so the raw memo
// picks them up instead of creating missions with evolution: [].
const fresh = createDailyMissionsContent(
getTodayDateString(),
parsed.missions.evolution,
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
// Daily missions are still current — hydrate or merge.
// Merge strategy: persisted missions from the relay represent accumulated
// progress from prior sessions. If they have ANY real progress, they are
// authoritative and should overwrite local (which at most has trivial
// progress from the brief window before profile loaded). If persisted has
// zero progress everywhere, local is equally valid — keep it to avoid
// swapping mission assignments the user has already seen.
const persistedHasProgress = parsed.missions.daily.some((m) => missionProgress(m) > 0);
if (persistedHasProgress) {
// Persisted carries real work — always prefer it
writeDailyToStorage(parsed.missions, pubkey);
} else {
// Persisted has zero progress — only hydrate if local is empty
const existing = readDailyFromStorage(pubkey);
if (!existing) {
writeDailyToStorage(parsed.missions, pubkey);
}
}
}
hydratedRef.current = pubkey;
setVersion((v) => v + 1);
}, [pubkey, profileContent]); // eslint-disable-line react-hooks/exhaustive-deps
}, [pubkey, profileContent]);
// Listen for tracker events
useEffect(() => {
@@ -152,11 +176,9 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
// Read and ensure current state.
// CRITICAL: Don't create a fresh store entry until hydration is complete.
// Creating one prematurely would overwrite persisted evolution missions
// because `hydrateFromPersisted` no-ops when the store already has data.
const hydrated = hydratedRef.current === pubkey;
const raw = useMemo((): MissionsContent | undefined => {
const stored = readMissionsFromStorage(pubkey);
const stored = readDailyFromStorage(pubkey);
if (!needsDailyReset(stored)) return stored;
@@ -164,14 +186,13 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
// hydration effect to seed persisted data before creating fresh missions.
if (!stored && !hydrated) return undefined;
// Reset for new day, preserve evolution missions
// Reset for new day
const fresh = createDailyMissionsContent(
getTodayDateString(),
stored?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
writeDailyToStorage(fresh, pubkey);
return fresh;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version, pubkey, stagesKey, hydrated]);
@@ -197,20 +218,25 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
const allComplete = raw ? areAllDailyComplete(raw) : false;
const todayXp = raw ? totalDailyXp(raw) : 0;
const bonusUnlocked = allComplete;
const noMissionsAvailable = missions.length === 0;
// noMissionsAvailable means the account genuinely has no hatched Blobbi.
// It does NOT reflect loading state — use `isLoading` for that.
const hasHatchedStage = availableStages
? availableStages.includes('baby') || availableStages.includes('adult')
: true; // default to true (assume hatched) when stages aren't known yet
const noMissionsAvailable = !hasHatchedStage;
const isLoading = !raw && hasHatchedStage;
const rerollsRemaining = raw?.rerolls ?? MAX_DAILY_REROLLS;
const forceReset = useCallback(() => {
const fresh = createDailyMissionsContent(
getTodayDateString(),
raw?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
writeDailyToStorage(fresh, pubkey);
setVersion((v) => v + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey, stagesKey, raw?.evolution]);
}, [pubkey, stagesKey]);
return {
missions,
@@ -220,6 +246,7 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
bonusUnlocked,
bonusXp: DAILY_BONUS_XP,
noMissionsAvailable,
isLoading,
rerollsRemaining,
maxRerolls: MAX_DAILY_REROLLS,
forceReset,
+59 -43
View File
@@ -3,7 +3,7 @@
/**
* Hook to compute evolve task progress.
*
* Progress is stored in `MissionsContent.evolution[]` on kind 11125.
* Progress is stored in the kind 31124 Blobbi event content JSON (per-Blobbi).
* - Interactions: TallyMission tracked via `trackEvolutionMissionTally`
* - Event-based tasks: EventMission, backfilled from retroactive Nostr queries
* - Dynamic task (maintain_stats): computed from current companion stats, NEVER stored
@@ -16,9 +16,14 @@ import type { NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import type { Mission } from '@/blobbi/core/lib/missions';
import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions';
import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker';
import {
trackEvolutionMissionEvent,
readEvolutionFromStorage,
writeEvolutionToStorage,
hydrateEvolutionFromPersisted,
} from '../lib/daily-mission-tracker';
import {
EVOLVE_MISSIONS,
EVOLVE_REQUIRED_INTERACTIONS,
@@ -80,45 +85,64 @@ export interface EvolveTasksResult {
* Hook to compute evolve task progress from evolution missions + Nostr event backfill.
*
* @param companion - The Blobbi companion (must be in evolving state)
* @param missions - Current MissionsContent from the session store
*/
export function useEvolveTasks(
companion: BlobbiCompanion | null,
missions: MissionsContent | undefined,
): EvolveTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const companionD = companion?.d;
const isEvolving = companion?.progressionState === 'evolving';
const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]);
// Read evolution from session store or companion (31124 content)
const evolution = useMemo((): Mission[] => {
if (!pubkey || !companionD) return [];
const fromStore = readEvolutionFromStorage(pubkey, companionD);
if (fromStore && fromStore.length > 0) return fromStore;
return companion?.evolution ?? [];
}, [pubkey, companionD, companion?.evolution]);
// ─── Hydrate evolution store from companion on mount ───
const hydratedRef = useRef<string | null>(null);
useEffect(() => {
if (!isEvolving || !pubkey || !companionD) return;
const hydrateKey = `${pubkey}:${companionD}`;
if (hydratedRef.current === hydrateKey) return;
hydratedRef.current = hydrateKey;
const companionEvolution = companion?.evolution ?? [];
if (companionEvolution.length > 0) {
hydrateEvolutionFromPersisted(companionEvolution, pubkey, companionD);
}
}, [isEvolving, pubkey, companionD, companion?.evolution]);
// ─── Ensure evolution missions exist and match current definitions ───
// Safety net: if the companion is evolving but evolution[] is empty
// (e.g. persist didn't fire, hydration lost them), re-populate from
// the static definitions so tally tracking works immediately.
// Also handles schema migrations: if persisted missions don't match
// the current EVOLVE_MISSIONS (e.g. a mission was added or removed),
// rebuild from definitions while preserving progress for surviving missions.
const ensuredRef = useRef(false);
// Scoped by pubkey:d so switching Blobbis re-runs the check.
const ensuredRef = useRef<string | null>(null);
useEffect(() => {
if (!isEvolving || !pubkey || ensuredRef.current) return;
const ensureKey = `${pubkey}:${companionD}`;
if (!isEvolving || !pubkey || !companionD || ensuredRef.current === ensureKey) return;
const store = ensureSessionStore(pubkey);
if (store.evolution.length === 0) {
writeMissionsToStorage({ ...store, evolution: createEvolveMissions() }, pubkey);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
} else if (!evolutionMatchesDefinitions(store.evolution, EVOLVE_MISSIONS)) {
const migrated = migrateEvolutionMissions(store.evolution, EVOLVE_MISSIONS);
writeMissionsToStorage({ ...store, evolution: migrated }, pubkey);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
const fromStore = readEvolutionFromStorage(pubkey, companionD);
const current = fromStore && fromStore.length > 0 ? fromStore : (companion?.evolution ?? []);
if (current.length === 0) {
const fresh = createEvolveMissions();
writeEvolutionToStorage(fresh, pubkey, companionD);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } }));
} else if (!evolutionMatchesDefinitions(current, EVOLVE_MISSIONS)) {
const migrated = migrateEvolutionMissions(current, EVOLVE_MISSIONS);
writeEvolutionToStorage(migrated, pubkey, companionD);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } }));
}
ensuredRef.current = true;
}, [isEvolving, pubkey, evolution]);
ensuredRef.current = ensureKey;
}, [isEvolving, pubkey, companionD, companion?.evolution]);
// ─── Retroactive Nostr Queries (discover event IDs to backfill) ───
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['evolve-tasks', pubkey],
queryKey: ['evolve-tasks', pubkey, companionD],
queryFn: async () => {
if (!pubkey) return null;
@@ -144,7 +168,6 @@ export function useEvolveTasks(
});
// ─── Compute event counts directly from Nostr query results ───
// These are the authoritative counts for event-based tasks.
const queryCounts: Record<string, number> = useMemo(() => {
if (!data) return {} as Record<string, number>;
return {
@@ -158,24 +181,23 @@ export function useEvolveTasks(
const lastBackfilledDataRef = useRef<typeof data>(null);
useEffect(() => {
if (!data || !pubkey || evolution.length === 0) return;
if (!data || !pubkey || !companionD || evolution.length === 0) return;
if (data === lastBackfilledDataRef.current) return;
lastBackfilledDataRef.current = data;
const current = readMissionsFromStorage(pubkey);
if (!current || current.evolution.length === 0) return;
const evo = current.evolution;
const current = readEvolutionFromStorage(pubkey, companionD);
if (!current || current.length === 0) return;
for (const event of data.themeEvents) {
const m = findEvolutionMission(evo, 'create_themes');
const m = findEvolutionMission(current, 'create_themes');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('create_themes', event.id, pubkey);
trackEvolutionMissionEvent('create_themes', event.id, pubkey, companionD);
}
}
for (const event of data.colorMomentEvents) {
const m = findEvolutionMission(evo, 'color_moments');
const m = findEvolutionMission(current, 'color_moments');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('color_moments', event.id, pubkey);
trackEvolutionMissionEvent('color_moments', event.id, pubkey, companionD);
}
}
const profileEditEvents = [
@@ -183,18 +205,14 @@ export function useEvolveTasks(
...(data.hasProfileMetadata ? [{ id: 'profile-metadata' }] : []),
];
for (const event of profileEditEvents) {
const m = findEvolutionMission(evo, 'edit_profile');
const m = findEvolutionMission(current, 'edit_profile');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('edit_profile', event.id, pubkey);
trackEvolutionMissionEvent('edit_profile', event.id, pubkey, companionD);
}
}
}, [data, pubkey, evolution]);
}, [data, pubkey, companionD, evolution]);
// ─── Build task view models ───
// For event-based tasks, use the MAX of the Nostr query count and the
// evolution mission progress. The query is authoritative but the mission
// store may have progress from a previous session that hasn't been
// re-queried yet.
const tasks: HatchTask[] = EVOLVE_MISSIONS.map((def) => {
const mission = findEvolutionMission(evolution, def.id);
const missionCount = mission ? missionProgress(mission) : 0;
@@ -261,5 +279,3 @@ export function useEvolveTasks(
refetch,
};
}
+64 -39
View File
@@ -3,13 +3,13 @@
/**
* Hook to compute hatch task progress.
*
* Progress is stored in `MissionsContent.evolution[]` on kind 11125.
* Progress is stored in the kind 31124 Blobbi event content JSON (per-Blobbi).
* - Interactions: TallyMission tracked via `trackEvolutionMissionTally`
* - Event-based tasks: EventMission, backfilled from retroactive Nostr queries
*
* The Nostr queries discover event IDs that satisfy event-based tasks and
* feed them into the evolution tracker. The evolution array is the source of
* truth for completion state.
* feed them into the evolution tracker. The evolution array (from companion
* or session store) is the source of truth for completion state.
*/
import { useEffect, useRef, useMemo } from 'react';
@@ -19,9 +19,14 @@ import type { NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import type { Mission } from '@/blobbi/core/lib/missions';
import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions';
import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker';
import {
trackEvolutionMissionEvent,
readEvolutionFromStorage,
writeEvolutionToStorage,
hydrateEvolutionFromPersisted,
} from '../lib/daily-mission-tracker';
import {
HATCH_MISSIONS,
HATCH_REQUIRED_INTERACTIONS,
@@ -99,45 +104,71 @@ export interface HatchTasksResult {
* Hook to compute hatch task progress from evolution missions + Nostr event backfill.
*
* @param companion - The Blobbi companion (must be incubating)
* @param missions - Current MissionsContent from the session store
*/
export function useHatchTasks(
companion: BlobbiCompanion | null,
missions: MissionsContent | undefined,
): HatchTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const companionD = companion?.d;
const isIncubating = companion?.progressionState === 'incubating';
const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]);
// Read evolution from companion (31124 content) or session store
const evolution = useMemo((): Mission[] => {
if (!pubkey || !companionD) return [];
// Session store takes priority (has latest in-session progress)
const fromStore = readEvolutionFromStorage(pubkey, companionD);
if (fromStore && fromStore.length > 0) return fromStore;
// Fall back to companion's persisted evolution from 31124 content
return companion?.evolution ?? [];
}, [pubkey, companionD, companion?.evolution]);
// ─── Hydrate evolution store from companion on mount ───
// If the companion has persisted evolution data but the session store is empty,
// seed the session store so tally tracking works immediately.
const hydratedRef = useRef<string | null>(null);
useEffect(() => {
if (!isIncubating || !pubkey || !companionD) return;
const hydrateKey = `${pubkey}:${companionD}`;
if (hydratedRef.current === hydrateKey) return;
hydratedRef.current = hydrateKey;
const companionEvolution = companion?.evolution ?? [];
if (companionEvolution.length > 0) {
hydrateEvolutionFromPersisted(companionEvolution, pubkey, companionD);
}
}, [isIncubating, pubkey, companionD, companion?.evolution]);
// ─── Ensure evolution missions exist and match current definitions ───
// Safety net: if the companion is incubating but evolution[] is empty
// (e.g. persist didn't fire, hydration lost them), re-populate from
// (e.g. persist didn't fire, old content format), re-populate from
// the static definitions so tally tracking works immediately.
// Also handles schema migrations: if persisted missions don't match
// the current HATCH_MISSIONS (e.g. a mission was added or removed),
// rebuild from definitions while preserving progress for surviving missions.
const ensuredRef = useRef(false);
// Scoped by pubkey:d so switching Blobbis re-runs the check.
const ensuredRef = useRef<string | null>(null);
useEffect(() => {
if (!isIncubating || !pubkey || ensuredRef.current) return;
const ensureKey = `${pubkey}:${companionD}`;
if (!isIncubating || !pubkey || !companionD || ensuredRef.current === ensureKey) return;
const store = ensureSessionStore(pubkey);
if (store.evolution.length === 0) {
writeMissionsToStorage({ ...store, evolution: createHatchMissions() }, pubkey);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
} else if (!evolutionMatchesDefinitions(store.evolution, HATCH_MISSIONS)) {
const migrated = migrateEvolutionMissions(store.evolution, HATCH_MISSIONS);
writeMissionsToStorage({ ...store, evolution: migrated }, pubkey);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
const fromStore = readEvolutionFromStorage(pubkey, companionD);
const current = fromStore && fromStore.length > 0 ? fromStore : (companion?.evolution ?? []);
if (current.length === 0) {
const fresh = createHatchMissions();
writeEvolutionToStorage(fresh, pubkey, companionD);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } }));
} else if (!evolutionMatchesDefinitions(current, HATCH_MISSIONS)) {
const migrated = migrateEvolutionMissions(current, HATCH_MISSIONS);
writeEvolutionToStorage(migrated, pubkey, companionD);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } }));
}
ensuredRef.current = true;
}, [isIncubating, pubkey, evolution]);
ensuredRef.current = ensureKey;
}, [isIncubating, pubkey, companionD, companion?.evolution]);
// ─── Retroactive Nostr Queries (discover event IDs to backfill) ───
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['hatch-tasks', pubkey],
queryKey: ['hatch-tasks', pubkey, companionD],
queryFn: async () => {
if (!pubkey) return null;
@@ -159,7 +190,6 @@ export function useHatchTasks(
});
// ─── Compute event counts directly from Nostr query results ───
// These are the authoritative counts for event-based tasks.
const queryCounts: Record<string, number> = useMemo(() => {
if (!data) return {} as Record<string, number>;
return {
@@ -172,33 +202,28 @@ export function useHatchTasks(
const lastBackfilledDataRef = useRef<typeof data>(null);
useEffect(() => {
if (!data || !pubkey || evolution.length === 0) return;
if (!data || !pubkey || !companionD || evolution.length === 0) return;
if (data === lastBackfilledDataRef.current) return;
lastBackfilledDataRef.current = data;
const current = readMissionsFromStorage(pubkey);
if (!current || current.evolution.length === 0) return;
const evo = current.evolution;
const current = readEvolutionFromStorage(pubkey, companionD);
if (!current || current.length === 0) return;
for (const event of data.themeEvents) {
const m = findEvolutionMission(evo, 'create_theme');
const m = findEvolutionMission(current, 'create_theme');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('create_theme', event.id, pubkey);
trackEvolutionMissionEvent('create_theme', event.id, pubkey, companionD);
}
}
for (const event of data.colorMomentEvents) {
const m = findEvolutionMission(evo, 'color_moment');
const m = findEvolutionMission(current, 'color_moment');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('color_moment', event.id, pubkey);
trackEvolutionMissionEvent('color_moment', event.id, pubkey, companionD);
}
}
}, [data, pubkey, evolution]);
}, [data, pubkey, companionD, evolution]);
// ─── Build task view models ───
// For event-based tasks, use the MAX of the Nostr query count and the
// evolution mission progress. The query is authoritative but the mission
// store may have progress from a previous session that hasn't been
// re-queried yet.
const tasks: HatchTask[] = HATCH_MISSIONS.map((def) => {
const mission = findEvolutionMission(evolution, def.id);
const missionCount = mission ? missionProgress(mission) : 0;
@@ -0,0 +1,209 @@
/**
* usePersistDailyProgress - Debounced persistence for daily mission progress.
*
* Daily missions live in the per-user session store (keyed by pubkey).
* This hook listens for changes and debounce-publishes the updated state to the
* kind 11125 Blobbonaut profile content JSON so progress survives page refreshes.
*
* Design:
* - Listens to 'daily-missions-updated' CustomEvent (same event the tracker fires)
* - Only acts on non-evolution events (daily mission tally/event updates)
* - Debounces by PERSIST_DELAY_MS to batch rapid interactions
* - Flushes immediately on visibilitychange → hidden (tab close, navigation, lock)
* - Uses fetchFreshEvent to avoid stale-read overwrites
* - Writes ONLY to content.missions — does NOT modify XP/level tags
* - Skips publish if missions haven't changed from the persisted state
* - Skips if all missions are already complete (useAwardDailyXp handles that)
* - Pending/dirty flag ensures updates during in-flight publishes are not dropped
*/
import { useEffect, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { KIND_BLOBBONAUT_PROFILE } from '@/blobbi/core/lib/blobbi';
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
import { readDailyFromStorage } from '../lib/daily-mission-tracker';
import { areAllDailyComplete } from '../lib/daily-missions';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Delay before persisting daily progress (ms). */
const PERSIST_DELAY_MS = 3_000;
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* @param updateProfileEvent - Callback to update profile in query cache after persist
*/
export function usePersistDailyProgress(
updateProfileEvent?: (event: import('@nostrify/nostrify').NostrEvent) => void,
): void {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishingRef = useRef(false);
const pendingRef = useRef(false);
/** Tracks whether there is unsaved progress (timer was set but hasn't fired yet). */
const dirtyRef = useRef(false);
// Store latest values in refs so the persist function always reads fresh
// values without needing to be recreated (which would reset the timer).
const userRef = useRef(user);
const nostrRef = useRef(nostr);
const publishEventRef = useRef(publishEvent);
const updateProfileEventRef = useRef(updateProfileEvent);
userRef.current = user;
nostrRef.current = nostr;
publishEventRef.current = publishEvent;
updateProfileEventRef.current = updateProfileEvent;
// Stable persist function that reads from refs — never changes identity.
const persistRef = useRef(async () => {
const pubkey = userRef.current?.pubkey;
if (!pubkey) return;
// If already publishing, mark pending so we re-run after completion.
if (publishingRef.current) {
pendingRef.current = true;
return;
}
const missions = readDailyFromStorage(pubkey);
if (!missions || missions.daily.length === 0) return;
// Skip if all missions are complete — useAwardDailyXp is responsible for
// writing the final state together with XP/level tags. Persisting here
// would race with the XP-award write and could overwrite fresher tags.
if (areAllDailyComplete(missions)) {
dirtyRef.current = false;
return;
}
publishingRef.current = true;
try {
// Fetch the fresh profile event from relays
const prev = await fetchFreshEvent(nostrRef.current, {
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [pubkey],
});
// Safety: never publish a kind 11125 event without an existing profile.
if (!prev) {
if (import.meta.env.DEV) {
console.warn('[PersistDailyProgress] No existing profile event found, skipping persist');
}
return;
}
// Re-read missions after the async fetch. If missions became all-complete
// while we were waiting (e.g. user completed the last mission during the
// fetch), bail out — useAwardDailyXp owns the final write.
const freshMissions = readDailyFromStorage(pubkey);
if (!freshMissions || areAllDailyComplete(freshMissions)) return;
// Serialize only the missions field into content, preserving other keys
const content = serializeProfileContent(prev.content, { missions: freshMissions });
// Skip publish if content hasn't changed
if (content === prev.content) {
dirtyRef.current = false;
return;
}
const event = await publishEventRef.current({
kind: KIND_BLOBBONAUT_PROFILE,
content,
// Preserve existing tags exactly — do NOT modify XP/level
tags: prev.tags,
prev,
});
dirtyRef.current = false;
updateProfileEventRef.current?.(event);
} catch (err) {
console.warn('[PersistDailyProgress] Failed to persist:', err);
// Keep dirtyRef true so a subsequent flush or pending retry can try again
} finally {
publishingRef.current = false;
// If a persist was requested during this publish, re-schedule it.
if (pendingRef.current) {
pendingRef.current = false;
dirtyRef.current = true;
timerRef.current = setTimeout(() => {
persistRef.current().catch((err) => {
console.warn('[PersistDailyProgress] Pending persist error:', err);
});
}, PERSIST_DELAY_MS);
}
}
});
useEffect(() => {
// ─── Daily mission update handler (debounced) ───
const onMissionUpdate = (e: Event) => {
const detail = (e as CustomEvent).detail;
// Skip evolution updates — those are handled by usePersistEvolutionProgress
if (detail?.evolution) return;
dirtyRef.current = true;
// Clear any pending timer and restart the debounce
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
persistRef.current().catch((err) => {
console.warn('[PersistDailyProgress] Persist error:', err);
});
}, PERSIST_DELAY_MS);
};
// ─── Visibility change handler (flush on hide) ───
// When the page becomes hidden (tab close, navigate away, lock screen),
// flush any pending progress immediately rather than waiting for the
// debounce timer that would be cleared by unmount/page destruction.
const onVisibilityChange = () => {
if (document.visibilityState !== 'hidden') return;
if (!dirtyRef.current) return;
// Cancel the pending debounce timer — we're flushing now
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// Fire-and-forget: attempt to persist. If the page is being destroyed
// the WebSocket send may not complete, but for tab-switch / mobile-lock
// scenarios this reliably persists before the JS context is suspended.
persistRef.current().catch(() => {
// Best-effort — page may be closing
});
};
window.addEventListener('daily-missions-updated', onMissionUpdate);
document.addEventListener('visibilitychange', onVisibilityChange);
// Capture ref for cleanup (React lint rule: ref may change before cleanup runs)
const persist = persistRef.current;
return () => {
window.removeEventListener('daily-missions-updated', onMissionUpdate);
document.removeEventListener('visibilitychange', onVisibilityChange);
if (timerRef.current) clearTimeout(timerRef.current);
// Flush on unmount (SPA navigation away from /blobbi) if there's
// unsaved progress. Fire-and-forget — do not block navigation.
if (dirtyRef.current) {
persist().catch(() => {
// Best-effort — component is already gone
});
}
};
}, []);
}
@@ -1,10 +1,9 @@
/**
* usePersistEvolutionProgress - Debounced persistence for evolution mission progress.
*
* Evolution missions (hatch/evolve tasks) live in `MissionsContent.evolution[]`
* in the in-memory session store. This hook listens for changes and debounce-
* publishes the updated state to kind 11125 content JSON so progress survives
* page refreshes.
* Evolution missions live in the per-Blobbi session store (keyed by pubkey:d).
* This hook listens for changes and debounce-publishes the updated state to the
* kind 31124 Blobbi event content JSON so progress survives page refreshes.
*
* Design:
* - Listens to 'daily-missions-updated' CustomEvent (same event the tracker fires)
@@ -23,10 +22,10 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import {
KIND_BLOBBONAUT_PROFILE,
KIND_BLOBBI_STATE,
} from '@/blobbi/core/lib/blobbi';
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
import { readMissionsFromStorage } from '../lib/daily-mission-tracker';
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { readEvolutionFromStorage } from '../lib/daily-mission-tracker';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -38,10 +37,12 @@ const PERSIST_DELAY_MS = 5_000;
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* @param updateProfileEvent - Callback to update profile in query cache
* @param companionD - The d-tag of the active Blobbi (required for per-Blobbi storage)
* @param updateCompanionEvent - Callback to update companion in query cache
*/
export function usePersistEvolutionProgress(
updateProfileEvent: (event: NostrEvent) => void,
companionD: string | undefined,
updateCompanionEvent: (event: NostrEvent) => void,
): void {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -53,42 +54,56 @@ export function usePersistEvolutionProgress(
const persist = useCallback(async () => {
const pubkey = user?.pubkey;
if (!pubkey || publishingRef.current) return;
if (!pubkey || !companionD || publishingRef.current) return;
const missions = readMissionsFromStorage(pubkey);
if (!missions || missions.evolution.length === 0) return;
const evolution = readEvolutionFromStorage(pubkey, companionD);
if (!evolution || evolution.length === 0) return;
publishingRef.current = true;
try {
// Fetch the fresh Blobbi event from relays
const prev = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBONAUT_PROFILE],
kinds: [KIND_BLOBBI_STATE],
authors: [pubkey],
'#d': [companionD],
});
const content = serializeProfileContent(
prev?.content ?? '',
{ missions },
);
if (!prev) {
console.warn('[PersistEvolution] No Blobbi event found for d-tag:', companionD);
return;
}
const content = serializeEvolutionContent(prev.content, evolution);
// Skip publish if the content is already up-to-date.
// This avoids redundant replaceable-event publishes when the
// primary interaction write path already persisted the same data.
if (content === prev.content) return;
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
kind: KIND_BLOBBI_STATE,
content,
tags: prev?.tags ?? [],
prev: prev ?? undefined,
tags: prev.tags,
prev,
});
updateProfileEvent(event);
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', pubkey] });
updateCompanionEvent(event);
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
} finally {
publishingRef.current = false;
}
}, [user?.pubkey, nostr, publishEvent, updateProfileEvent, queryClient]);
}, [user?.pubkey, companionD, nostr, publishEvent, updateCompanionEvent, queryClient]);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.evolution) return;
// Only react to evolution updates for the active companion.
// detail.d is set by trackEvolutionMissionTally/Event; if absent
// (legacy caller), accept it to avoid silently dropping updates.
if (detail.d && detail.d !== companionD) return;
// Clear any pending timer and restart the debounce
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
@@ -103,5 +118,5 @@ export function usePersistEvolutionProgress(
window.removeEventListener('daily-missions-updated', handler);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [persist]);
}, [persist, companionD]);
}
+3 -14
View File
@@ -1,17 +1,9 @@
// 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 { StartIncubationDialog } from './components/StartIncubationDialog';
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
// Hooks
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
@@ -104,9 +96,8 @@ export {
type InventoryAction,
type DirectAction,
type BlobbiAction,
type ResolvedInventoryItem,
type EggStatPreview,
type ItemUsabilityResult,
type StatChangeWithSegments,
// Constants
ACTION_TO_ITEM_TYPE,
ACTION_METADATA,
@@ -123,16 +114,13 @@ export {
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
isActionVisibleForStage,
canUseInventoryItems,
getStageRestrictionMessage,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
previewStatChangesWithSegments,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
canUseItemForStage,
@@ -144,6 +132,7 @@ export { useDailyMissions } from './hooks/useDailyMissions';
export type { DailyMissionView, UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useAwardDailyXp, useClaimMissionReward } from './hooks/useClaimMissionReward';
export { usePersistEvolutionProgress } from './hooks/usePersistEvolutionProgress';
export { usePersistDailyProgress } from './hooks/usePersistDailyProgress';
export type { AwardDailyXpRequest, AwardDailyXpResult, ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export { useRerollMission } from './hooks/useRerollMission';
export type { RerollMissionRequest, RerollMissionResult } from './hooks/useRerollMission';
+65 -144
View File
@@ -1,8 +1,9 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStage, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getBlobbiStatDisplayState, type CareState } from '@/blobbi/core/lib/blobbi-segments';
// ─── Action Types ─────────────────────────────────────────────────────────────
@@ -272,78 +273,6 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved catalog item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
quantity: number;
name: string;
icon: string;
type: ShopItemCategory;
effect?: ItemEffect;
}
/**
* Options for filtering catalog items by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
stage?: 'egg' | 'baby' | 'adult';
}
/**
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
* - Shell Repair Kit only appears in medicine modal for eggs
* - For eggs: only items with egg-compatible effects are returned
* - medicine action: only items with health effect
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
_storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
const allItems = getLiveShopItems();
for (const shopItem of allItems) {
if (shopItem.type !== allowedType) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
// For eggs, filter items by egg-compatible effects
if (isEgg) {
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
continue; // Skip medicine without health effect
}
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
continue; // Skip hygiene items without hygiene or happiness effect
}
}
result.push({
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
effect: shopItem.effect,
});
}
return result;
}
/**
* Decrement item quantity in storage array.
* If quantity becomes 0, removes the item entirely.
@@ -461,88 +390,80 @@ export function getStageRestrictionMessage(companion: BlobbiCompanion, action?:
return null;
}
// ─── Stats Preview ────────────────────────────────────────────────────────────
// ─── Segment-aware stat preview ───────────────────────────────────────────────
/**
* Preview stats after applying an item's effects.
* Useful for showing the user what will happen before confirming.
* A single stat change enriched with segment (bar) impact.
*
* Pure and deterministic — depends only on the inputs.
*/
export function previewStatChanges(
export interface StatChangeWithSegments {
/** Which stat is affected. */
stat: keyof BlobbiStats;
/** Raw delta from the item effect (before clamping). */
delta: number;
/** Current stat value (clamped 1100). */
beforeValue: number;
/** Projected stat value after applying the delta (clamped 1100). */
afterValue: number;
/** Filled segments before applying the delta. */
beforeSegments: number;
/** Filled segments after applying the delta. */
afterSegments: number;
/** Change in filled segments (afterSegments beforeSegments). */
segmentDelta: number;
/** Maximum segments for the current stage. */
maxSegments: number;
/** Care state before applying the delta. */
beforeCareState: CareState;
/** Care state after applying the delta. */
afterCareState: CareState;
}
/**
* Preview stat changes with segment (bar) impact for each affected stat.
*
* Uses `getBlobbiStatDisplayState` to derive segment counts before and after
* the item effect, so the result exactly matches what the user sees in the
* stat rings.
*
* For eggs, `segmentDelta` is always 0 because eggs are visually protected
* (all bars shown as full regardless of the internal value).
*/
export function previewStatChangesWithSegments(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect | undefined
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
effects: ItemEffect | undefined,
stage: BlobbiStage,
): StatChangeWithSegments[] {
if (!effects) return [];
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
const changes: StatChangeWithSegments[] = [];
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 });
}
if (delta === undefined || delta === 0) continue;
const beforeValue = clampStat(currentStats[stat] ?? 0);
const afterValue = clampStat(beforeValue + delta);
const before = getBlobbiStatDisplayState({ stage, stat, value: beforeValue });
const after = getBlobbiStatDisplayState({ stage, stat, value: afterValue });
changes.push({
stat,
delta,
beforeValue,
afterValue,
beforeSegments: before.filled,
afterSegments: after.filled,
segmentDelta: after.filled - before.filled,
maxSegments: before.max,
beforeCareState: before.careState,
afterCareState: after.careState,
});
}
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;
}
+142 -53
View File
@@ -1,16 +1,18 @@
/**
* Daily Mission Tracker - Standalone progress tracking utility
*
* Provides a way to record daily mission progress from anywhere
* (hooks, event handlers, etc.) without requiring React context.
* Two separate in-memory stores:
* - dailyStore: pubkey-scoped, for daily missions (kind 11125)
* - evolutionStore: pubkey:d-scoped, for per-Blobbi evolution missions (kind 31124)
*
* Uses a pubkey-scoped in-memory Map. Kind 11125 content JSON is the
* persistent source of truth. Completed missions are persisted by
* `useAwardDailyXp`; intermediate progress resets on page refresh.
* Both cleared on page refresh. The persistent source of truth is:
* - Daily missions → kind 11125 content JSON
* - Evolution missions → kind 31124 content JSON
*
* Dispatches 'daily-missions-updated' CustomEvent so React hooks re-render.
*/
import type { Mission } from '@/blobbi/core/lib/missions';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import type { DailyMissionAction } from './daily-missions';
import {
@@ -19,31 +21,31 @@ import {
createDailyMissionsContent,
trackTally,
trackEvent,
trackEvolutionTally,
trackEvolutionEvent,
trackEvolutionTally as trackEvoTally,
trackEvolutionEvent as trackEvoEvent,
} from './daily-missions';
// ─── In-Memory Session Store ──────────────────────────────────────────────────
// ─── Daily Mission Session Store (per-user) ──────────────────────────────────
/**
* Pubkey-scoped session cache. Each logged-in user gets their own entry.
* Pubkey-scoped session cache for daily missions.
* Cleared on page refresh (intentional — kind 11125 is the persistent store).
*/
const sessionStore = new Map<string, MissionsContent>();
const dailyStore = new Map<string, MissionsContent>();
function key(pubkey: string | undefined): string {
function dailyKey(pubkey: string | undefined): string {
return pubkey ?? '';
}
function ensureCurrent(pubkey?: string): MissionsContent {
const current = sessionStore.get(key(pubkey));
function ensureDailyCurrent(pubkey?: string, availableStages?: import('./daily-missions').BlobbiStage[]): MissionsContent {
const current = dailyStore.get(dailyKey(pubkey));
if (!needsDailyReset(current)) return current!;
const fresh = createDailyMissionsContent(
getTodayDateString(),
current?.evolution ?? [],
pubkey,
availableStages,
);
sessionStore.set(key(pubkey), fresh);
dailyStore.set(dailyKey(pubkey), fresh);
return fresh;
}
@@ -51,7 +53,20 @@ function notify(detail?: Record<string, unknown>): void {
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail }));
}
// ─── Public API ───────────────────────────────────────────────────────────────
// ─── Evolution Mission Session Store (per-Blobbi) ────────────────────────────
/**
* Per-Blobbi session cache for evolution missions.
* Keyed by `pubkey:d` so each Blobbi has its own evolution progress.
* Cleared on page refresh — kind 31124 content is the persistent store.
*/
const evolutionStore = new Map<string, Mission[]>();
function evoKey(pubkey: string | undefined, d: string | undefined): string {
return `${pubkey ?? ''}:${d ?? ''}`;
}
// ─── Public API: Daily Missions ──────────────────────────────────────────────
/**
* Record a tally-based action (feed, clean, interact, etc.).
@@ -61,9 +76,9 @@ export function trackDailyMissionProgress(
count: number = 1,
pubkey?: string,
): void {
const current = ensureCurrent(pubkey);
const current = ensureDailyCurrent(pubkey);
const updated = trackTally(current, action, count);
sessionStore.set(key(pubkey), updated);
dailyStore.set(dailyKey(pubkey), updated);
notify({ action, count });
}
@@ -75,9 +90,9 @@ export function trackDailyMissionEvent(
eventId: string,
pubkey?: string,
): void {
const current = ensureCurrent(pubkey);
const current = ensureDailyCurrent(pubkey);
const updated = trackEvent(current, action, eventId);
sessionStore.set(key(pubkey), updated);
dailyStore.set(dailyKey(pubkey), updated);
notify({ action, eventId });
}
@@ -88,80 +103,154 @@ export function trackMultipleDailyMissionActions(
actions: DailyMissionAction[],
pubkey?: string,
): void {
let current = ensureCurrent(pubkey);
let current = ensureDailyCurrent(pubkey);
for (const action of actions) {
current = trackTally(current, action, 1);
}
sessionStore.set(key(pubkey), current);
dailyStore.set(dailyKey(pubkey), current);
notify({ actions });
}
// ─── Evolution Mission Tracking ───────────────────────────────────────────────
// ─── Public API: Evolution Missions (per-Blobbi) ─────────────────────────────
/**
* Increment tally for an evolution mission (e.g. interactions).
* No-ops if pubkey missing or session store empty.
* No-ops if the store is empty for this Blobbi.
*/
export function trackEvolutionMissionTally(
missionId: string,
count: number = 1,
pubkey?: string,
d?: string,
): void {
const current = sessionStore.get(key(pubkey));
if (!current) return;
const k = evoKey(pubkey, d);
const current = evolutionStore.get(k);
if (!current || current.length === 0) return;
const updated = trackEvolutionTally(current, missionId, count);
sessionStore.set(key(pubkey), updated);
notify({ evolution: true, missionId, count });
const updated = trackEvoTally(current, missionId, count);
evolutionStore.set(k, updated);
notify({ evolution: true, missionId, count, d });
}
/**
* Append a Nostr event ID to an evolution mission (e.g. create_theme).
* Deduplicates by event ID. No-ops if pubkey missing or session store empty.
* Deduplicates by event ID. No-ops if the store is empty for this Blobbi.
*/
export function trackEvolutionMissionEvent(
missionId: string,
eventId: string,
pubkey?: string,
d?: string,
): void {
const current = sessionStore.get(key(pubkey));
if (!current) return;
const k = evoKey(pubkey, d);
const current = evolutionStore.get(k);
if (!current || current.length === 0) return;
const updated = trackEvolutionEvent(current, missionId, eventId);
sessionStore.set(key(pubkey), updated);
notify({ evolution: true, missionId, eventId });
const updated = trackEvoEvent(current, missionId, eventId);
evolutionStore.set(k, updated);
notify({ evolution: true, missionId, eventId, d });
}
// ─── Storage Access ──────────────────────────────────────────────────────────
// ─── Storage Access: Daily ───────────────────────────────────────────────────
/** Read current session state for a pubkey. */
export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined {
return sessionStore.get(key(pubkey));
/** Read current daily session state for a pubkey. */
export function readDailyFromStorage(pubkey?: string): MissionsContent | undefined {
return dailyStore.get(dailyKey(pubkey));
}
/**
* Ensure the session store has an entry for the given pubkey.
* If the store is empty or needs a daily reset, a fresh entry is created.
* Ensure the daily store has an entry for the given pubkey.
* Returns the current (possibly newly-created) MissionsContent.
*
* Use this before writing evolution missions into the store, to avoid
* silent no-ops when the store hasn't been hydrated yet.
*/
export function ensureSessionStore(pubkey?: string): MissionsContent {
return ensureCurrent(pubkey);
export function ensureDailyStore(pubkey?: string): MissionsContent {
return ensureDailyCurrent(pubkey);
}
/** Write state to session store for a pubkey. */
export function writeMissionsToStorage(missions: MissionsContent, pubkey?: string): void {
sessionStore.set(key(pubkey), missions);
/** Write daily state to session store for a pubkey. */
export function writeDailyToStorage(missions: MissionsContent, pubkey?: string): void {
dailyStore.set(dailyKey(pubkey), missions);
}
/**
* Hydrate the session store from kind 11125 persisted data.
* Hydrate the daily session store from kind 11125 persisted data.
* Called once on mount / account switch when the session store is empty.
* No-op if the store already has data for this pubkey.
*/
export function hydrateFromPersisted(missions: MissionsContent, pubkey: string): void {
if (sessionStore.has(pubkey)) return;
sessionStore.set(pubkey, missions);
export function hydrateDailyFromPersisted(missions: MissionsContent, pubkey: string): void {
if (dailyStore.has(pubkey)) return;
dailyStore.set(pubkey, missions);
}
// ─── Storage Access: Evolution (per-Blobbi) ──────────────────────────────────
/** Read current evolution session state for a specific Blobbi. */
export function readEvolutionFromStorage(pubkey?: string, d?: string): Mission[] | undefined {
return evolutionStore.get(evoKey(pubkey, d));
}
/** Write evolution state for a specific Blobbi. */
export function writeEvolutionToStorage(evolution: Mission[], pubkey?: string, d?: string): void {
evolutionStore.set(evoKey(pubkey, d), evolution);
}
/**
* Hydrate the evolution session store from kind 31124 content.
* Called once when a companion with active progression is loaded.
* No-op if the store already has data for this Blobbi.
*/
export function hydrateEvolutionFromPersisted(evolution: Mission[], pubkey: string, d: string): void {
const k = evoKey(pubkey, d);
if (evolutionStore.has(k)) return;
evolutionStore.set(k, evolution);
}
/** Clear evolution store for a specific Blobbi (on stage transition / stop). */
export function clearEvolutionFromStorage(pubkey?: string, d?: string): void {
evolutionStore.delete(evoKey(pubkey, d));
}
// ─── Inventory Action → Daily Mission Mapping ────────────────────────────────
/**
* Track daily mission actions for a successful inventory item use.
*
* Every item use tracks 'interact'. Specific actions (feed, clean, medicine)
* also track their corresponding daily mission. This is the single source of
* truth for the mapping — both useBlobbiUseInventoryItem and useBlobbiItemUse
* call this instead of duplicating the logic.
*
* Accepts the wider InventoryAction type (string) so callers don't need casts.
* Only recognized daily-mission actions are forwarded.
*/
export function trackInventoryDailyActions(
action: string,
pubkey?: string,
): void {
const actions: DailyMissionAction[] = ['interact'];
if (action === 'feed') actions.push('feed');
if (action === 'clean') actions.push('clean');
if (action === 'medicine') actions.push('medicine');
trackMultipleDailyMissionActions(actions, pubkey);
}
// ─── Backward-compat aliases ─────────────────────────────────────────────────
/**
* @deprecated Use readDailyFromStorage. Kept for callers that haven't migrated.
*/
export const readMissionsFromStorage = readDailyFromStorage;
/**
* @deprecated Use writeDailyToStorage. Kept for callers that haven't migrated.
*/
export const writeMissionsToStorage = writeDailyToStorage;
/**
* @deprecated Use ensureDailyStore. Kept for callers that haven't migrated.
*/
export const ensureSessionStore = ensureDailyStore;
/**
* @deprecated Use hydrateDailyFromPersisted. Kept for callers that haven't migrated.
*/
export const hydrateFromPersisted = hydrateDailyFromPersisted;
+32 -23
View File
@@ -113,13 +113,13 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
{
id: 'take_photo_1', title: 'Snapshot',
description: 'Take a photo of your Blobbi',
action: 'take_photo', target: 1, tracking: 'event', xp: 25, weight: 4,
action: 'take_photo', target: 1, tracking: 'tally', xp: 25, weight: 4,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_2', title: 'Photo Album',
description: 'Take 2 photos of your Blobbi',
action: 'take_photo', target: 2, tracking: 'event', xp: 40, weight: 2,
action: 'take_photo', target: 2, tracking: 'tally', xp: 40, weight: 2,
requiredStages: ['baby', 'adult'],
},
@@ -267,10 +267,9 @@ export function createMission(def: DailyMissionDefinition): Mission {
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
}
/** Create a fresh MissionsContent for a new day, preserving evolution missions */
/** Create a fresh MissionsContent for a new day */
export function createDailyMissionsContent(
dateString: string,
existingEvolution: Mission[],
pubkey?: string,
availableStages?: BlobbiStage[],
): MissionsContent {
@@ -278,7 +277,6 @@ export function createDailyMissionsContent(
return {
date: dateString,
daily: defs.map(createMission),
evolution: existingEvolution,
rerolls: MAX_DAILY_REROLLS,
};
}
@@ -288,6 +286,10 @@ export function createDailyMissionsContent(
/**
* Increment tally for all daily missions matching the given action.
* Returns a new missions content (immutable).
*
* Also handles legacy EventMission entries whose pool definition has been
* changed to tally tracking — converts them in-flight so previously-generated
* missions still receive progress.
*/
export function trackTally(
missions: MissionsContent,
@@ -297,9 +299,19 @@ export function trackTally(
const updated = missions.daily.map((m) => {
const def = POOL_BY_ID.get(m.id);
if (!def || def.action !== action) return m;
if (!isTallyMission(m)) return m;
if (m.count >= m.target) return m; // already complete
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
// Normal tally mission
if (isTallyMission(m)) {
if (m.count >= m.target) return m; // already complete
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
}
// Legacy EventMission whose pool definition is now tally tracking —
// convert to TallyMission in-flight so it receives progress.
if (isEventMission(m) && def.tracking === 'tally') {
const currentCount = m.events.length;
if (currentCount >= m.target) return m; // already complete
return { id: m.id, target: m.target, count: Math.min(currentCount + incrementBy, m.target) } satisfies TallyMission;
}
return m;
});
return { ...missions, daily: updated };
}
@@ -324,39 +336,41 @@ export function trackEvent(
return { ...missions, daily: updated };
}
// ─── Evolution Mission Tracking (operates on Mission[] directly) ─────────────
/**
* Track progress for an evolution mission by tally.
* Increment tally for an evolution mission by ID.
* Returns a new array (immutable). Used by the evolution session store.
*/
export function trackEvolutionTally(
missions: MissionsContent,
evolution: Mission[],
missionId: string,
incrementBy: number = 1,
): MissionsContent {
const updated = missions.evolution.map((m) => {
): Mission[] {
return evolution.map((m) => {
if (m.id !== missionId) return m;
if (!isTallyMission(m)) return m;
if (m.count >= m.target) return m;
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
});
return { ...missions, evolution: updated };
}
/**
* Append an event ID to an evolution mission.
* Append a Nostr event ID to an evolution mission.
* Returns a new array (immutable). Used by the evolution session store.
*/
export function trackEvolutionEvent(
missions: MissionsContent,
evolution: Mission[],
missionId: string,
eventId: string,
): MissionsContent {
const updated = missions.evolution.map((m) => {
): Mission[] {
return evolution.map((m) => {
if (m.id !== missionId) return m;
if (!isEventMission(m)) return m;
if (m.events.length >= m.target) return m;
if (m.events.includes(eventId)) return m;
return { ...m, events: [...m.events, eventId] };
});
return { ...missions, evolution: updated };
}
// ─── Completion Queries ──────────────────────────────────────────────────────
@@ -366,11 +380,6 @@ export function areAllDailyComplete(missions: MissionsContent): boolean {
return missions.daily.length > 0 && missions.daily.every(isMissionComplete);
}
/** Whether all evolution missions are complete */
export function areAllEvolutionComplete(missions: MissionsContent): boolean {
return missions.evolution.length > 0 && missions.evolution.every(isMissionComplete);
}
/** Total XP available from today's daily missions (including bonus if all complete) */
export function totalDailyXp(missions: MissionsContent): number {
const base = missions.daily.reduce((sum, m) => {
@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest';
import { previewStatChangesWithSegments, type StatChangeWithSegments } from './blobbi-action-utils';
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Convenience: find the change entry for a specific stat. */
function find(changes: StatChangeWithSegments[], stat: string) {
return changes.find(c => c.stat === stat);
}
// ─── Baby (4 segments) ────────────────────────────────────────────────────────
describe('previewStatChangesWithSegments baby', () => {
it('baby 50 + 25 hunger → segmentDelta +1 (2/4 → 3/4)', () => {
const changes = previewStatChangesWithSegments(
{ hunger: 50 },
{ hunger: 25 },
'baby',
);
const h = find(changes, 'hunger')!;
expect(h).toBeDefined();
expect(h.delta).toBe(25);
expect(h.beforeValue).toBe(50);
expect(h.afterValue).toBe(75);
expect(h.beforeSegments).toBe(2);
expect(h.afterSegments).toBe(3);
expect(h.segmentDelta).toBe(1);
expect(h.maxSegments).toBe(4);
});
it('baby 80 + 25 hunger → segmentDelta 0 (already 4/4)', () => {
const changes = previewStatChangesWithSegments(
{ hunger: 80 },
{ hunger: 25 },
'baby',
);
const h = find(changes, 'hunger')!;
expect(h).toBeDefined();
expect(h.delta).toBe(25);
expect(h.beforeValue).toBe(80);
expect(h.afterValue).toBe(100); // clamped
expect(h.beforeSegments).toBe(4);
expect(h.afterSegments).toBe(4);
expect(h.segmentDelta).toBe(0);
});
it('baby 20 + 70 hunger → segmentDelta +3 (1/4 → 4/4)', () => {
const changes = previewStatChangesWithSegments(
{ hunger: 20 },
{ hunger: 70 },
'baby',
);
const h = find(changes, 'hunger')!;
expect(h).toBeDefined();
expect(h.beforeValue).toBe(20);
expect(h.afterValue).toBe(90);
expect(h.beforeSegments).toBe(1);
expect(h.afterSegments).toBe(4);
expect(h.segmentDelta).toBe(3);
});
it('negative side-effect crossing a boundary shows negative segmentDelta', () => {
// Baby hygiene 80 → 45 (effect -35)
// 80: ceil(0.8*4) = 4, 45: ceil(0.45*4) = ceil(1.8) = 2
const changes = previewStatChangesWithSegments(
{ hygiene: 80 },
{ hygiene: -35 },
'baby',
);
const h = find(changes, 'hygiene')!;
expect(h).toBeDefined();
expect(h.delta).toBe(-35);
expect(h.beforeSegments).toBe(4);
expect(h.afterSegments).toBe(2);
expect(h.segmentDelta).toBe(-2);
});
it('includes beforeCareState and afterCareState', () => {
// Baby: 30 → attention, 55 → okay
const changes = previewStatChangesWithSegments(
{ hunger: 30 },
{ hunger: 25 },
'baby',
);
const h = find(changes, 'hunger')!;
expect(h.beforeCareState).toBe('attention');
expect(h.afterCareState).toBe('okay');
});
});
// ─── Adult (10 segments) ──────────────────────────────────────────────────────
describe('previewStatChangesWithSegments adult', () => {
it('adult 45 + 25 hunger → segmentDelta +2 (5/10 → 7/10)', () => {
const changes = previewStatChangesWithSegments(
{ hunger: 45 },
{ hunger: 25 },
'adult',
);
const h = find(changes, 'hunger')!;
expect(h).toBeDefined();
expect(h.delta).toBe(25);
expect(h.beforeValue).toBe(45);
expect(h.afterValue).toBe(70);
expect(h.beforeSegments).toBe(5);
expect(h.afterSegments).toBe(7);
expect(h.segmentDelta).toBe(2);
expect(h.maxSegments).toBe(10);
});
it('adult care states transition correctly', () => {
// Adult: 25 → urgent, 55 → attention
const changes = previewStatChangesWithSegments(
{ health: 25 },
{ health: 30 },
'adult',
);
const h = find(changes, 'health')!;
expect(h.beforeCareState).toBe('urgent');
expect(h.afterCareState).toBe('attention');
});
});
// ─── Egg (protected) ──────────────────────────────────────────────────────────
describe('previewStatChangesWithSegments egg', () => {
it('egg always returns segmentDelta 0 (protected)', () => {
const changes = previewStatChangesWithSegments(
{ health: 50 },
{ health: 30 },
'egg',
);
const h = find(changes, 'health')!;
expect(h).toBeDefined();
expect(h.delta).toBe(30);
expect(h.beforeSegments).toBe(4);
expect(h.afterSegments).toBe(4);
expect(h.segmentDelta).toBe(0);
expect(h.beforeCareState).toBe('protected');
expect(h.afterCareState).toBe('protected');
});
it('egg with low value still shows full segments', () => {
const changes = previewStatChangesWithSegments(
{ hygiene: 10 },
{ hygiene: 25 },
'egg',
);
const h = find(changes, 'hygiene')!;
expect(h.segmentDelta).toBe(0);
expect(h.beforeSegments).toBe(4);
expect(h.afterSegments).toBe(4);
});
});
// ─── Edge cases ───────────────────────────────────────────────────────────────
describe('previewStatChangesWithSegments edge cases', () => {
it('returns empty array for undefined effects', () => {
expect(previewStatChangesWithSegments({ hunger: 50 }, undefined, 'baby')).toEqual([]);
});
it('skips stats with zero delta', () => {
const changes = previewStatChangesWithSegments(
{ hunger: 50, happiness: 50 },
{ hunger: 25, happiness: 0 },
'baby',
);
expect(changes).toHaveLength(1);
expect(changes[0].stat).toBe('hunger');
});
it('handles missing stats in currentStats (defaults to clamped 1)', () => {
// Missing stat defaults to 0, which gets clamped to 1 by clampStat
const changes = previewStatChangesWithSegments(
{},
{ hunger: 50 },
'baby',
);
const h = find(changes, 'hunger')!;
expect(h.beforeValue).toBe(1); // clampStat(0) = 1
expect(h.afterValue).toBe(51);
});
it('handles multi-stat effects returning all affected stats', () => {
const changes = previewStatChangesWithSegments(
{ hunger: 50, happiness: 30, hygiene: 80 },
{ hunger: 25, happiness: 10, hygiene: -8 },
'baby',
);
expect(changes).toHaveLength(3);
expect(changes.map(c => c.stat)).toEqual(['hunger', 'happiness', 'hygiene']);
});
});
@@ -53,10 +53,10 @@
<!-- 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 75 70 L 60 80 L 60 105 L 100 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 100 105 L 140 105 L 140 130 L 125 140 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) -->
@@ -72,13 +72,19 @@
<!-- 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" />
<!-- 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.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" />
</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.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" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -52,12 +52,12 @@
<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" />
<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 60 80 L 60 105 L 100 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 140 130 L 125 140 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" />
<!-- Sleeping eyes -->
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -37,16 +37,16 @@
<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" />
<circle cx="70" cy="45" r="18" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="130" cy="45" r="18" fill="#1f2937" data-blobbi-skip="true" />
<!-- Inner ears -->
<circle cx="70" cy="45" r="12" fill="#374151" />
<circle cx="130" cy="45" r="12" fill="#374151" />
<circle cx="70" cy="45" r="12" fill="#374151" data-blobbi-skip="true" />
<circle cx="130" cy="45" r="12" fill="#374151" data-blobbi-skip="true" />
<!-- 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="20" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="115" cy="82" r="20" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="85" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
<circle cx="115" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -37,16 +37,16 @@
<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" />
<circle cx="70" cy="45" r="18" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="130" cy="45" r="18" fill="#1f2937" data-blobbi-skip="true" />
<!-- Inner ears -->
<circle cx="70" cy="45" r="12" fill="#374151" />
<circle cx="130" cy="45" r="12" fill="#374151" />
<circle cx="70" cy="45" r="12" fill="#374151" data-blobbi-skip="true" />
<circle cx="130" cy="45" r="12" fill="#374151" data-blobbi-skip="true" />
<!-- 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="20" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="115" cy="82" r="20" fill="#1f2937" data-blobbi-skip="true" />
<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" />

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -11,22 +11,25 @@
*/
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { hexToHsl, hslToHex } from '@/blobbi/core/lib/color-guardrails';
import { lightenColor, darkenColor, uniquifySvgIds, ensureSvgFillsContainer } from '@/blobbi/ui/lib/svg';
import type { AdultForm, AdultSvgCustomization } from '../types/adult.types';
// ─── Gradient Builders ────────────────────────────────────────────────────────
/**
* Build a 3-stop radial gradient (highlight -> mid -> base)
* Build a 3-stop radial gradient (highlight -> mid -> base).
* When innerColor is provided, it replaces the highlight/mid stops for two-tone effect.
*/
function buildRadialGradient3Stop(
id: string,
baseColor: string,
cx = '0.3',
cy = '0.2'
cy = '0.2',
innerColor?: string
): string {
const highlight = lightenColor(baseColor, 40);
const mid = lightenColor(baseColor, 20);
const highlight = innerColor ?? lightenColor(baseColor, 40);
const mid = innerColor ? lightenColor(innerColor, 20) : 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" />
@@ -51,16 +54,18 @@ function buildRadialGradient2Stop(
}
/**
* Build a 4-stop radial gradient (used by droppi, rocky, starri bodies)
* Build a 4-stop radial gradient (used by droppi, rocky, starri bodies).
* When innerColor is provided, it replaces the veryLight/light stops for two-tone effect.
*/
function buildRadialGradient4Stop(
id: string,
baseColor: string,
cx = '0.3',
cy = '0.2'
cy = '0.2',
innerColor?: string
): string {
const veryLight = lightenColor(baseColor, 50);
const light = lightenColor(baseColor, 25);
const veryLight = innerColor ?? lightenColor(baseColor, 50);
const light = innerColor ? lightenColor(innerColor, 20) : 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" />
@@ -71,16 +76,18 @@ function buildRadialGradient4Stop(
}
/**
* Build a petal gradient (outer -> inner style, like rosey/leafy)
* Build a petal gradient (outer -> inner style, like rosey/leafy).
* When innerColor is provided, it replaces the veryLight/light stops for two-tone effect.
*/
function buildPetalGradient(
id: string,
baseColor: string,
cx = '0.3',
cy = '0.2'
cy = '0.2',
innerColor?: string
): string {
const veryLight = lightenColor(baseColor, 50);
const light = lightenColor(baseColor, 30);
const veryLight = innerColor ?? lightenColor(baseColor, 50);
const light = innerColor ? lightenColor(innerColor, 20) : 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" />
@@ -130,11 +137,11 @@ function replaceGradient(
* Catti: Body, ears, and tail should use Blobbi color
* Gradients: cattiBody3D, cattiEar3D, cattiEarInner, cattiTail3D, cattiTailHighlight
*/
function customizeCatti(svgText: string, baseColor: string): string {
function customizeCatti(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body gradient (3-stop)
svg = replaceGradient(svg, 'cattiBody3D', buildRadialGradient3Stop('cattiBody3D', baseColor));
// Body gradient (3-stop) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'cattiBody3D', buildRadialGradient3Stop('cattiBody3D', baseColor, '0.3', '0.2', secondaryColor));
// Ear gradients (2-stop)
svg = replaceGradient(svg, 'cattiEar3D', buildRadialGradient2Stop('cattiEar3D', baseColor));
@@ -159,11 +166,11 @@ function customizeCatti(svgText: string, baseColor: string): string {
* Droppi: Body, arms, legs, and droplets should use Blobbi color
* Gradients: droppiBody, droppiInner, droppiArm, droppiLeg, droppiDroplet
*/
function customizeDroppi(svgText: string, baseColor: string): string {
function customizeDroppi(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (4-stop)
svg = replaceGradient(svg, 'droppiBody', buildRadialGradient4Stop('droppiBody', baseColor));
// Body (4-stop) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'droppiBody', buildRadialGradient4Stop('droppiBody', baseColor, '0.3', '0.2', secondaryColor));
// Inner reflection (lighter, 2-stop)
const innerColor = lightenColor(baseColor, 45);
@@ -185,11 +192,11 @@ function customizeDroppi(svgText: string, baseColor: string): string {
* 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 {
function customizeFlammi(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (4-stop gradient with warm progression)
svg = replaceGradient(svg, 'flammiBody', buildRadialGradient4Stop('flammiBody', baseColor));
// Body (4-stop gradient with warm progression) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'flammiBody', buildRadialGradient4Stop('flammiBody', baseColor, '0.3', '0.2', secondaryColor));
// Inner (3-stop, lighter)
const innerColor = lightenColor(baseColor, 25);
@@ -219,11 +226,11 @@ function customizeFlammi(svgText: string, baseColor: string): string {
* Froggi: Body, eye base, feet should use Blobbi color
* Gradients: froggiBody3D, froggiEyeBase3D, froggiFeet3D, froggiFeetHighlight, froggiToe3D
*/
function customizeFroggi(svgText: string, baseColor: string): string {
function customizeFroggi(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (3-stop)
svg = replaceGradient(svg, 'froggiBody3D', buildRadialGradient3Stop('froggiBody3D', baseColor));
// Body (3-stop) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'froggiBody3D', buildRadialGradient3Stop('froggiBody3D', baseColor, '0.3', '0.2', secondaryColor));
// Eye base (matches body color, 2-stop)
svg = replaceGradient(svg, 'froggiEyeBase3D', buildRadialGradient2Stop('froggiEyeBase3D', lightenColor(baseColor, 15)));
@@ -248,14 +255,16 @@ function customizeFroggi(svgText: string, baseColor: string): string {
* Leafy: Petals should use Blobbi color (center/face keeps brown)
* Gradients: leafyPetal (petals only - the yellow parts)
*/
function customizeLeafy(svgText: string, baseColor: string): string {
function customizeLeafy(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Petal gradient (the sunflower petals)
// Petal gradient (the sunflower petals) - two-tone when secondaryColor present
const petalInner = secondaryColor ?? lightenColor(baseColor, 15);
const petalMid = secondaryColor ? lightenColor(secondaryColor, 20) : lightenColor(baseColor, 25);
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" />
<stop offset="30%" style="stop-color:${petalMid};stop-opacity:1" />
<stop offset="0%" style="stop-color:${petalInner};stop-opacity:1" />
</radialGradient>`);
return svg;
@@ -265,11 +274,11 @@ function customizeLeafy(svgText: string, baseColor: string): string {
* Mushie: Cap should use Blobbi color (stem keeps original)
* Gradients: mushieCap, mushieCapHighlight
*/
function customizeMushie(svgText: string, baseColor: string): string {
function customizeMushie(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Cap (4-stop)
svg = replaceGradient(svg, 'mushieCap', buildRadialGradient4Stop('mushieCap', baseColor));
// Cap (4-stop) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'mushieCap', buildRadialGradient4Stop('mushieCap', baseColor, '0.3', '0.2', secondaryColor));
// Cap highlight (lighter)
svg = replaceGradient(svg, 'mushieCapHighlight', buildRadialGradient2Stop('mushieCapHighlight', lightenColor(baseColor, 25), '0.4', '0.3'));
@@ -281,11 +290,11 @@ function customizeMushie(svgText: string, baseColor: string): string {
* Rocky: Body, inner, arms, legs, and pebbles should use Blobbi color
* Gradients: rockyBody, rockyInner, rockyArm, rockyLeg, rockyPebble
*/
function customizeRocky(svgText: string, baseColor: string): string {
function customizeRocky(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (4-stop)
svg = replaceGradient(svg, 'rockyBody', buildRadialGradient4Stop('rockyBody', baseColor));
// Body (4-stop) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'rockyBody', buildRadialGradient4Stop('rockyBody', baseColor, '0.3', '0.2', secondaryColor));
// Inner (2-stop, lighter)
svg = replaceGradient(svg, 'rockyInner', buildRadialGradient2Stop('rockyInner', lightenColor(baseColor, 35), '0.4', '0.3'));
@@ -306,11 +315,11 @@ function customizeRocky(svgText: string, baseColor: string): string {
* Rosey: Petals, center, and floating petals should use Blobbi color
* Gradients: roseyPetal1, roseyPetal2, roseyPetal3, roseyCenter, roseyFloatingPetal
*/
function customizeRosey(svgText: string, baseColor: string): string {
function customizeRosey(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Petal layers (outer to inner, using petal gradient style)
svg = replaceGradient(svg, 'roseyPetal1', buildPetalGradient('roseyPetal1', baseColor));
// Petal layers (outer to inner) - two-tone on outer petals when secondaryColor present
svg = replaceGradient(svg, 'roseyPetal1', buildPetalGradient('roseyPetal1', baseColor, '0.3', '0.2', secondaryColor));
// Petal2 (slightly lighter)
svg = replaceGradient(svg, 'roseyPetal2', buildRadialGradient2Stop('roseyPetal2', lightenColor(baseColor, 15), '0.4', '0.3'));
@@ -331,13 +340,15 @@ function customizeRosey(svgText: string, baseColor: string): string {
* 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 {
function customizeStarri(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Inner star (3-stop gradient to maintain depth)
// Inner star (3-stop gradient to maintain depth) - two-tone when secondaryColor present
const starInner = secondaryColor ?? lightenColor(baseColor, 35);
const starMid = secondaryColor ? lightenColor(secondaryColor, 20) : lightenColor(baseColor, 15);
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="0%" style="stop-color:${starInner};stop-opacity:1" />
<stop offset="50%" style="stop-color:${starMid};stop-opacity:1" />
<stop offset="100%" style="stop-color:${baseColor};stop-opacity:1" />
</radialGradient>`);
@@ -348,11 +359,11 @@ function customizeStarri(svgText: string, baseColor: string): string {
* 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 {
function customizeBreezy(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (4-stop leaf gradient)
svg = replaceGradient(svg, 'breezyBody', buildRadialGradient4Stop('breezyBody', baseColor));
// Body (4-stop leaf gradient) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'breezyBody', buildRadialGradient4Stop('breezyBody', baseColor, '0.3', '0.2', secondaryColor));
// Inner highlight (lighter, 2-stop)
svg = replaceGradient(svg, 'breezyInner', buildRadialGradient2Stop('breezyInner', lightenColor(baseColor, 40), '0.4', '0.3'));
@@ -381,7 +392,7 @@ function customizeBreezy(svgText: string, baseColor: string): string {
* 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 {
function customizeBloomi(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// All 6 petals use variations of the Blobbi color
@@ -393,10 +404,12 @@ function customizeBloomi(svgText: string, baseColor: string): string {
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)
// Center (3-stop, face area) - two-tone when secondaryColor present
const centerInner = secondaryColor ?? lightenColor(baseColor, 45);
const centerMid = secondaryColor ? lightenColor(secondaryColor, 20) : lightenColor(baseColor, 35);
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="0%" style="stop-color:${centerInner};stop-opacity:1" />
<stop offset="50%" style="stop-color:${centerMid};stop-opacity:1" />
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 25)};stop-opacity:1" />
</radialGradient>`);
@@ -410,11 +423,11 @@ function customizeBloomi(svgText: string, baseColor: string): string {
* Cacti: Body and arms should use Blobbi color (pot keeps original red)
* Gradients: cactiBody, cactiArm
*/
function customizeCacti(svgText: string, baseColor: string): string {
function customizeCacti(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (4-stop)
svg = replaceGradient(svg, 'cactiBody', buildRadialGradient4Stop('cactiBody', baseColor));
// Body (4-stop) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'cactiBody', buildRadialGradient4Stop('cactiBody', baseColor, '0.3', '0.2', secondaryColor));
// Arms (2-stop)
svg = replaceGradient(svg, 'cactiArm', buildRadialGradient2Stop('cactiArm', lightenColor(baseColor, 10)));
@@ -426,13 +439,15 @@ function customizeCacti(svgText: string, baseColor: string): string {
* Cloudi: Body, highlights, and raindrops should use Blobbi color
* Gradients: cloudiBody, cloudiHighlight, cloudiRain
*/
function customizeCloudi(svgText: string, baseColor: string): string {
function customizeCloudi(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (3-stop, cloud-like progression from light to slightly darker)
// Body (3-stop, cloud-like progression) - two-tone when secondaryColor present
const bodyInner = secondaryColor ?? lightenColor(baseColor, 45);
const bodyMid = secondaryColor ? lightenColor(secondaryColor, 20) : lightenColor(baseColor, 30);
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="0%" style="stop-color:${bodyInner};stop-opacity:1" />
<stop offset="50%" style="stop-color:${bodyMid};stop-opacity:1" />
<stop offset="100%" style="stop-color:${lightenColor(baseColor, 15)};stop-opacity:1" />
</radialGradient>`);
@@ -452,11 +467,11 @@ function customizeCloudi(svgText: string, baseColor: string): string {
* Crysti: Body and inner should use Blobbi color (facets keep their colorful nature)
* Gradients: crystiBody, crystiInner
*/
function customizeCrysti(svgText: string, baseColor: string): string {
function customizeCrysti(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (4-stop crystal gradient)
svg = replaceGradient(svg, 'crystiBody', buildRadialGradient4Stop('crystiBody', baseColor));
// Body (4-stop crystal gradient) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'crystiBody', buildRadialGradient4Stop('crystiBody', baseColor, '0.3', '0.2', secondaryColor));
// Inner highlight (semi-transparent white feel preserved but tinted)
svg = replaceGradient(svg, 'crystiInner', `<radialGradient id="crystiInner" cx="0.4" cy="0.3">
@@ -471,11 +486,11 @@ function customizeCrysti(svgText: string, baseColor: string): string {
* 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 {
function customizeOwli(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// Body (3-stop)
svg = replaceGradient(svg, 'owliBody3D', buildRadialGradient3Stop('owliBody3D', baseColor));
// Body (3-stop) - two-tone when secondaryColor present
svg = replaceGradient(svg, 'owliBody3D', buildRadialGradient3Stop('owliBody3D', baseColor, '0.3', '0.2', secondaryColor));
// Ears (2-stop, slightly darker)
svg = replaceGradient(svg, 'owliEar3D', buildRadialGradient2Stop('owliEar3D', darkenColor(baseColor, 10), '0.3', '0.2'));
@@ -491,7 +506,71 @@ function customizeOwli(svgText: string, baseColor: string): string {
// ─── Form Customizer Map ──────────────────────────────────────────────────────
type FormCustomizer = (svgText: string, baseColor: string) => string;
type FormCustomizer = (svgText: string, baseColor: string, secondaryColor?: string) => string;
/**
* Pandi: Light areas get a soft tinted-white from baseColor;
* dark areas (ears, eye patches, arms, legs) get a dark tint from secondaryColor.
*
* The tinted white preserves the hue of baseColor at very high lightness (L=95)
* so Pandi looks subtly colored rather than pure white, while the dark areas
* use secondaryColor's hue at panda-appropriate darkness (L=20/27) to maintain
* the characteristic light-vs-dark panda contrast.
*/
function customizePandi(svgText: string, baseColor: string, secondaryColor?: string): string {
let svg = svgText;
// ── Derive tinted-white from baseColor ──
const baseHsl = hexToHsl(baseColor);
const tintFill = hslToHex(baseHsl.h, Math.min(baseHsl.s, 30), 95);
const tintStroke = hslToHex(baseHsl.h, Math.min(baseHsl.s, 20), 90);
// ── Derive dark tints from secondaryColor (or baseColor if no secondary) ──
const darkSource = secondaryColor ?? baseColor;
const darkHsl = hexToHsl(darkSource);
const darkPrimary = hslToHex(darkHsl.h, 30, 20); // replaces #1f2937
const darkLight = hslToHex(darkHsl.h, 20, 27); // replaces #374151
// ── Light areas: body & head (flat fills + strokes) ──
// Original: fill="#f8fafc" stroke="#e2e8f0"
svg = svg.replaceAll('fill="#f8fafc"', `fill="${tintFill}"`);
svg = svg.replaceAll('stroke="#e2e8f0"', `stroke="${tintStroke}"`);
// ── Dark areas with data-blobbi-skip: ear patches, inner ears, eye patches ──
// These use data-blobbi-skip="true" to prevent eye-animation from touching them.
// Original dark: fill="#1f2937", lighter dark: fill="#374151"
svg = svg.replaceAll('fill="#1f2937" data-blobbi-skip="true"', `fill="${darkPrimary}" data-blobbi-skip="true"`);
svg = svg.replaceAll('fill="#374151" data-blobbi-skip="true"', `fill="${darkLight}" data-blobbi-skip="true"`);
// ── Arm & leg gradients ──
svg = replaceGradient(svg, 'pandiArm3D', `<radialGradient id="pandiArm3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:${darkPrimary};stop-opacity:1" />
<stop offset="100%" style="stop-color:${darkLight};stop-opacity:1" />
</radialGradient>`);
svg = replaceGradient(svg, 'pandiLeg3D', `<radialGradient id="pandiLeg3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:${darkPrimary};stop-opacity:1" />
<stop offset="100%" style="stop-color:${darkLight};stop-opacity:1" />
</radialGradient>`);
// ── Nose gradient ──
svg = replaceGradient(svg, 'pandiNose3D', `<radialGradient id="pandiNose3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:${darkPrimary};stop-opacity:1" />
<stop offset="100%" style="stop-color:${darkLight};stop-opacity:1" />
</radialGradient>`);
// ── Mouth gradient ──
svg = replaceGradient(svg, 'pandiMouth3D', `<linearGradient id="pandiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:${darkLight};stop-opacity:1" />
<stop offset="50%" style="stop-color:${darkPrimary};stop-opacity:1" />
<stop offset="100%" style="stop-color:${darkLight};stop-opacity:1" />
</linearGradient>`);
// ── Sleeping variant: closed-eye strokes and mouth dot use #1e293b ──
svg = svg.replaceAll('stroke="#1e293b"', `stroke="${darkPrimary}"`);
svg = svg.replaceAll('fill="#1e293b"', `fill="${darkPrimary}"`);
return svg;
}
const FORM_CUSTOMIZERS: Partial<Record<AdultForm, FormCustomizer>> = {
bloomi: customizeBloomi,
@@ -506,10 +585,10 @@ const FORM_CUSTOMIZERS: Partial<Record<AdultForm, FormCustomizer>> = {
leafy: customizeLeafy,
mushie: customizeMushie,
owli: customizeOwli,
pandi: customizePandi,
rocky: customizeRocky,
rosey: customizeRosey,
starri: customizeStarri,
// pandi keeps original colors - it's a panda with black/white coloring by design
};
// ─── Main Customization ───────────────────────────────────────────────────────
@@ -551,7 +630,7 @@ export function customizeAdultSvg(
if (customization.baseColor) {
const customizer = FORM_CUSTOMIZERS[form];
if (customizer) {
modifiedSvg = customizer(modifiedSvg, customization.baseColor);
modifiedSvg = customizer(modifiedSvg, customization.baseColor, customization.secondaryColor);
} else {
// Fallback for forms without specific customizer: try generic body gradient
modifiedSvg = applyGenericBodyGradient(modifiedSvg, form, customization.baseColor);
@@ -600,8 +679,34 @@ function applyGenericBodyGradient(
return modified;
}
// ─── Pupil/Eye Color Application ──────────────────────────────────────────────
/**
* Apply pupil gradient customization
* Default hardcoded pupil fill colors for forms without pupil gradients.
* Used by the flat-fill fallback in applyPupilGradient().
*/
const HARDCODED_PUPIL_FILLS: Partial<Record<AdultForm, string>> = {
bloomi: '#1f2937',
breezy: '#1f2937',
cacti: '#1f2937',
cloudi: '#64748b',
crysti: '#1e1b4b',
droppi: '#0891b2',
flammi: '#1f2937',
leafy: '#1f2937',
mushie: '#1f2937',
rocky: '#1f2937',
rosey: '#1f2937',
starri: '#1e1b4b',
};
/**
* Apply pupil/eye color customization.
*
* First tries gradient-based replacement (for forms with {form}Pupil3D gradients).
* Falls back to scoped fill replacement for forms with hardcoded flat pupil fills,
* only replacing within the <!-- Pupils ... --> comment block to avoid touching
* other elements (mouths, strokes, etc.) that may share the same hex color.
*/
function applyPupilGradient(
svgText: string,
@@ -610,7 +715,7 @@ function applyPupilGradient(
): string {
let modified = svgText;
// Try common patterns: {form}Pupil3D, {form}Pupil
// Try gradient-based approach first: {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'),
@@ -622,7 +727,21 @@ function applyPupilGradient(
const gradientId = match[1];
const newGradient = buildPupilGradient(gradientId, eyeColor);
modified = modified.replace(match[0], newGradient);
break;
return modified;
}
}
// Fallback: replace hardcoded pupil fills scoped to the <!-- Pupils ... --> block.
// Each form has exactly 2 pupil circles + 2 white highlight circles in this block.
// We only replace the known default fill color, not the white highlights.
const defaultFill = HARDCODED_PUPIL_FILLS[form];
if (defaultFill) {
const pupilBlockRegex = /<!-- Pupils[^>]*-->([\s\S]*?)(?=<!--|$)/;
const blockMatch = modified.match(pupilBlockRegex);
if (blockMatch) {
const block = blockMatch[0];
const newBlock = block.replaceAll(`fill="${defaultFill}"`, `fill="${eyeColor}" data-blobbi-pupil="true"`);
modified = modified.replace(block, newBlock);
}
}
+67 -61
View File
@@ -38,7 +38,7 @@ const BLOOMI_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/sv
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" data-blobbi-body="true" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Eyes (white/base eye shapes) -->
@@ -139,7 +139,7 @@ const BLOOMI_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/200
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" data-blobbi-body="true" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Olhos dormindo -->
@@ -208,7 +208,7 @@ const BLOOMI_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/200
const BREEZY_BASE = `<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)" />
fill="url(#breezyBody)" data-blobbi-body="true" />
<!-- Leaf veins - central vein -->
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
@@ -309,7 +309,7 @@ const BREEZY_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/sv
const BREEZY_SLEEPING = `<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)" />
fill="url(#breezyBody)" data-blobbi-body="true" />
<!-- Leaf veins -->
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
@@ -406,7 +406,7 @@ const BREEZY_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/200
const CACTI_BASE = `<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)" />
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" data-blobbi-body="true" />
<!-- Cactus arms -->
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
@@ -482,7 +482,7 @@ const CACTI_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg
const CACTI_SLEEPING = `<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)" />
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" data-blobbi-body="true" />
<!-- Cactus arms -->
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
@@ -604,7 +604,7 @@ const CATTI_BASE = `<?xml version="1.0" encoding="UTF-8"?>
</defs>
<!-- Oval upright body -->
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" data-blobbi-body="true" />
<!-- Triangle ears -->
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
@@ -696,7 +696,7 @@ const CATTI_SLEEPING = `<?xml version="1.0" encoding="UTF-8"?>
</defs>
<!-- Oval upright body -->
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" data-blobbi-body="true" />
<!-- Triangle ears -->
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
@@ -738,7 +738,7 @@ const CATTI_SLEEPING = `<?xml version="1.0" encoding="UTF-8"?>
const CLOUDI_BASE = `<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="100" cy="120" r="45" fill="url(#cloudiBody)" data-blobbi-body="true" />
<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)" />
@@ -788,7 +788,7 @@ const CLOUDI_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/sv
const CLOUDI_SLEEPING = `<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="100" cy="120" r="45" fill="url(#cloudiBody)" data-blobbi-body="true" />
<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)" />
@@ -888,15 +888,15 @@ const CRYSTI_BASE = `<?xml version="1.0" encoding="UTF-8"?>
</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 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" data-blobbi-body="true" />
<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 75 70 L 60 80 L 60 105 L 100 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 100 105 L 140 105 L 140 130 L 125 140 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) -->
@@ -912,15 +912,21 @@ const CRYSTI_BASE = `<?xml version="1.0" encoding="UTF-8"?>
<!-- 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" />
<!-- 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.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" />
</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.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" />
</g>
</svg>`;
const CRYSTI_SLEEPING = `<?xml version="1.0" encoding="UTF-8"?>
@@ -973,16 +979,16 @@ const CRYSTI_SLEEPING = `<?xml version="1.0" encoding="UTF-8"?>
</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 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" data-blobbi-body="true" />
<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" />
<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 60 80 L 60 105 L 100 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 140 130 L 125 140 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" />
<!-- Sleeping eyes -->
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
@@ -1046,7 +1052,7 @@ const DROPPI_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/sv
<!-- 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)" />
fill="url(#droppiBody)" data-blobbi-body="true" />
<!-- Inner water reflection -->
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
@@ -1136,7 +1142,7 @@ const DROPPI_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/200
<!-- 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)" />
fill="url(#droppiBody)" data-blobbi-body="true" />
<!-- Inner water reflection -->
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
@@ -1243,7 +1249,7 @@ const FLAMMI_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/sv
</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 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)" data-blobbi-body="true" />
<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" />
@@ -1320,7 +1326,7 @@ const FLAMMI_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/200
</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 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)" data-blobbi-body="true" />
<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" />
@@ -1400,7 +1406,7 @@ const FROGGI_BASE = `<?xml version="1.0" encoding="UTF-8"?>
</defs>
<!-- Flattened oval body -->
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" data-blobbi-body="true" />
<!-- Big circular pop-out eyes -->
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
@@ -1502,7 +1508,7 @@ const FROGGI_SLEEPING = `<?xml version="1.0" encoding="UTF-8"?>
</defs>
<!-- Flattened oval body -->
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" data-blobbi-body="true" />
<!-- Big circular pop-out eyes -->
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
@@ -1577,7 +1583,7 @@ const LEAFY_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg
<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="30" fill="url(#leafyCenter)" data-blobbi-body="true" />
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
<!-- Eyes (white base) -->
@@ -1694,7 +1700,7 @@ const LEAFY_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000
<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="30" fill="url(#leafyCenter)" data-blobbi-body="true" />
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
<!-- Sleeping eyes -->
@@ -1784,7 +1790,7 @@ const MUSHIE_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/sv
<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 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" data-blobbi-body="true" />
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
<!-- Cap spots -->
@@ -1857,7 +1863,7 @@ const MUSHIE_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/200
<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 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" data-blobbi-body="true" />
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
<!-- Cap spots -->
@@ -1972,7 +1978,7 @@ const OWLI_BASE = `<?xml version="1.0" encoding="UTF-8"?>
</defs>
<!-- Round body -->
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" />
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" data-blobbi-body="true" />
<!-- Triangle ears -->
<path d="M 60 70 L 70 48 L 82 70 Z" fill="url(#owliEar3D)" />
@@ -2053,7 +2059,7 @@ const OWLI_SLEEPING = `<?xml version="1.0" encoding="UTF-8"?>
</defs>
<!-- Round body -->
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" />
<circle cx="100" cy="110" r="60" fill="url(#owliBody3D)" data-blobbi-body="true" />
<!-- Triangle ears -->
<path d="M 60 70 L 70 48 L 82 70 Z" fill="url(#owliEar3D)" />
@@ -2124,22 +2130,22 @@ const PANDI_BASE = `<?xml version="1.0" encoding="UTF-8"?>
</defs>
<!-- Main body - perfect circle -->
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" data-blobbi-body="true" />
<!-- 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" />
<circle cx="70" cy="45" r="18" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="130" cy="45" r="18" fill="#1f2937" data-blobbi-skip="true" />
<!-- Inner ears -->
<circle cx="70" cy="45" r="12" fill="#374151" />
<circle cx="130" cy="45" r="12" fill="#374151" />
<circle cx="70" cy="45" r="12" fill="#374151" data-blobbi-skip="true" />
<circle cx="130" cy="45" r="12" fill="#374151" data-blobbi-skip="true" />
<!-- 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="20" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="115" cy="82" r="20" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="85" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
<circle cx="115" cy="82" r="12" fill="url(#pandiEyeWhite3D)" />
@@ -2197,22 +2203,22 @@ const PANDI_SLEEPING = `<?xml version="1.0" encoding="UTF-8"?>
</defs>
<!-- Main body - perfect circle -->
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" />
<circle cx="100" cy="120" r="55" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2" data-blobbi-body="true" />
<!-- 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" />
<circle cx="70" cy="45" r="18" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="130" cy="45" r="18" fill="#1f2937" data-blobbi-skip="true" />
<!-- Inner ears -->
<circle cx="70" cy="45" r="12" fill="#374151" />
<circle cx="130" cy="45" r="12" fill="#374151" />
<circle cx="70" cy="45" r="12" fill="#374151" data-blobbi-skip="true" />
<circle cx="130" cy="45" r="12" fill="#374151" data-blobbi-skip="true" />
<!-- 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="20" fill="#1f2937" data-blobbi-skip="true" />
<circle cx="115" cy="82" r="20" fill="#1f2937" data-blobbi-skip="true" />
<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" />
@@ -2284,7 +2290,7 @@ const ROCKY_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg
</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 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)" data-blobbi-body="true" />
<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 -->
@@ -2389,7 +2395,7 @@ const ROCKY_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000
<!-- 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)" />
fill="url(#rockyBody)" data-blobbi-body="true" />
<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" />
@@ -2457,7 +2463,7 @@ const ROSEY_BASE = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg
<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)" />
<circle cx="100" cy="90" r="35" fill="url(#roseyPetal1)" data-blobbi-body="true" />
<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)" />
@@ -2552,7 +2558,7 @@ const ROSEY_SLEEPING = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000
<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)" />
<circle cx="100" cy="90" r="35" fill="url(#roseyPetal1)" data-blobbi-body="true" />
<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)" />
@@ -2671,7 +2677,7 @@ const STARRI_BASE = `<?xml version="1.0" encoding="UTF-8"?>
</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 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)" data-blobbi-body="true" />
<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) -->
@@ -2744,7 +2750,7 @@ const STARRI_SLEEPING = `<?xml version="1.0" encoding="UTF-8"?>
<!-- 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)" />
fill="url(#starriBody)" data-blobbi-body="true" />
<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" />
+11 -9
View File
@@ -102,16 +102,18 @@ export function resolveAdultForm(blobbi: Blobbi): AdultForm {
/**
* Derives adult form deterministically from a seed string.
* Uses simple hash-based selection for consistency.
*
* Uses the same seed-slice approach as all other visual-trait derivations
* in blobbi.ts: reads 8 hex chars from offset [40..48] and maps to a
* form index via modular arithmetic.
*
* This is the single canonical seed → adult form derivation. blobbi.ts
* imports and delegates to this function for all adult-type resolution.
*/
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;
const slice = seed.slice(40, 48);
const value = parseInt(slice, 16);
if (Number.isNaN(value)) return getDefaultAdultForm();
const index = value % ADULT_FORMS.length;
return ADULT_FORMS[index];
}
@@ -289,6 +289,7 @@ export function BlobbiCompanion({
return (
<div
ref={containerRef}
data-blobbi-companion
className="select-none touch-none"
style={{
position: 'fixed',
@@ -23,6 +23,7 @@ import { useOverstimulationReaction } from '../hooks/useOverstimulationReaction'
import { useShakeReaction } from '../hooks/useShakeReaction';
import { createShakeTracker, recordSample, computeShakeResult, resetTracker } from '../core/shakeDetection';
import { BlobbiCompanion } from './BlobbiCompanion';
import { OverstimulationBlockOverlay } from './OverstimulationBlockOverlay';
import { DebugGroundOverlay } from './DebugGroundOverlay';
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
import { calculateGroundY } from '../utils/movement';
@@ -323,83 +324,78 @@ export function BlobbiCompanionLayer() {
const debugGroundY = calculateGroundY(viewport.height, config.size, config);
return (
<div
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 9999 }}
aria-hidden="true"
>
{DEBUG_GROUND_CONTACT && (
<DebugGroundOverlay
groundY={debugGroundY}
size={config.size}
viewportHeight={viewport.height}
paddingBottom={config.padding.bottom}
isEntering={isEntering}
entryState={entryState}
/>
)}
<>
<div
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 9999 }}
aria-hidden="true"
>
{DEBUG_GROUND_CONTACT && (
<DebugGroundOverlay
groundY={debugGroundY}
size={config.size}
viewportHeight={viewport.height}
paddingBottom={config.padding.bottom}
isEntering={isEntering}
entryState={entryState}
/>
)}
<div className="pointer-events-auto">
<BlobbiCompanion
companion={companion}
state={state}
motion={motion}
eyeOffsetRef={eyeOffsetRef}
isEntering={isEntering}
entryProgress={entryProgress}
entryState={entryState}
wasResolvedFromStuck={wasResolvedFromStuck}
groundPosition={groundPosition}
viewport={viewport}
onStartDrag={handleStartDrag}
onUpdateDrag={updateDrag}
onEndDrag={handleEndDrag}
onClick={handleCompanionClick}
isClickBlocked={isOverstimBlocked}
recipe={companionRecipe}
recipeLabel={companionRecipeLabel}
onPositionUpdate={handlePositionUpdate}
onDragSample={handleDragSample}
debugMode={DEBUG_GROUND_CONTACT}
<div className="pointer-events-auto">
<BlobbiCompanion
companion={companion}
state={state}
motion={motion}
eyeOffsetRef={eyeOffsetRef}
isEntering={isEntering}
entryProgress={entryProgress}
entryState={entryState}
wasResolvedFromStuck={wasResolvedFromStuck}
groundPosition={groundPosition}
viewport={viewport}
onStartDrag={handleStartDrag}
onUpdateDrag={updateDrag}
onEndDrag={handleEndDrag}
onClick={handleCompanionClick}
isClickBlocked={isOverstimBlocked}
recipe={companionRecipe}
recipeLabel={companionRecipeLabel}
onPositionUpdate={handlePositionUpdate}
onDragSample={handleDragSample}
debugMode={DEBUG_GROUND_CONTACT}
/>
</div>
<CompanionActionMenu
isOpen={menuState.isOpen}
companionPosition={renderedPosition}
companionSize={config.size}
actions={availableActions}
selectedAction={menuState.selectedAction}
onActionClick={handleActionClick}
onClickOutside={handleClickOutside}
isSleeping={isSleeping}
/>
<HangingItems
isVisible={menuState.isOpen && menuState.selectedAction !== null}
selectedAction={menuState.selectedAction}
items={menuState.items}
viewportHeight={viewport.height}
groundOffset={config.padding.bottom}
companionPosition={renderedPosition}
companionSize={config.size}
onItemRelease={handleItemClick}
onItemLanded={handleItemLanded}
onItemUse={handleItemUse}
isItemOnCooldown={isItemOnCooldown}
/>
</div>
<CompanionActionMenu
isOpen={menuState.isOpen}
companionPosition={renderedPosition}
companionSize={config.size}
actions={availableActions}
selectedAction={menuState.selectedAction}
onActionClick={handleActionClick}
onClickOutside={handleClickOutside}
isSleeping={isSleeping}
{/* Overlay sits outside the zoom container so it stays at viewport scale */}
<OverstimulationBlockOverlay
isBlocked={isOverstimBlocked}
/>
<HangingItems
isVisible={menuState.isOpen && menuState.selectedAction !== null}
selectedAction={menuState.selectedAction}
items={menuState.items}
viewportHeight={viewport.height}
groundOffset={config.padding.bottom}
companionPosition={renderedPosition}
companionSize={config.size}
onItemRelease={handleItemClick}
onItemLanded={handleItemLanded}
onItemUse={handleItemUse}
isItemOnCooldown={isItemOnCooldown}
/>
{/* Global click shield while Blobbi is in blocked (overstimulated) phase */}
{isOverstimBlocked && (
<div
aria-hidden
style={{
position: 'fixed',
inset: 0,
zIndex: 99999,
pointerEvents: 'all',
}}
/>
)}
</div>
</>
);
}
@@ -0,0 +1,122 @@
/**
* OverstimulationBlockOverlay — Full-screen visual feedback when Blobbi is
* overstimulated. Zooms the UI toward Blobbi (#root transform), fires a
* radial shockwave, and holds a red vignette. Portaled to document.body
* so the overlay stays at viewport scale while #root is zoomed.
*/
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const SHOCKWAVE_MS = 600;
const VIGNETTE_IN_MS = 300;
const VIGNETTE_OUT_MS = 600;
const ZOOM = 5;
const ZOOM_IN_MS = 280;
const ZOOM_OUT_MS = 700;
interface Props {
isBlocked: boolean;
}
export function OverstimulationBlockOverlay({ isBlocked }: Props) {
const [visible, setVisible] = useState(false);
const [shockwave, setShockwave] = useState(false);
const wasBlocked = useRef(false);
const origin = useRef({ x: 0, y: 0 });
const savedScroll = useRef(0);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const root = document.getElementById('root');
if (!root) return;
if (isBlocked && !wasBlocked.current) {
// Save scroll position and snap to top so sticky nav bars and
// scroll-hidden elements don't end up in broken positions when
// the zoom transform creates a new containing block.
savedScroll.current = window.scrollY;
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior });
// Find Blobbi's true visual center via DOM query (after scroll reset)
const el = document.querySelector<HTMLElement>('[data-blobbi-companion]');
const rect = el?.getBoundingClientRect();
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
origin.current = { x: cx, y: cy };
root.style.transformOrigin = `${cx}px ${cy}px`;
root.style.transition = `transform ${ZOOM_IN_MS}ms cubic-bezier(0.22,1,0.36,1)`;
root.style.transform = `scale(${ZOOM})`;
document.body.style.overflow = 'hidden';
if (fadeTimer.current) { clearTimeout(fadeTimer.current); fadeTimer.current = null; }
setVisible(true);
setShockwave(true);
} else if (!isBlocked && wasBlocked.current) {
root.style.transition = `transform ${ZOOM_OUT_MS}ms cubic-bezier(0.16,1,0.3,1)`;
root.style.transform = 'scale(1)';
setShockwave(false);
fadeTimer.current = setTimeout(() => {
root.style.transform = root.style.transformOrigin = root.style.transition = '';
document.body.style.overflow = '';
window.scrollTo({ top: savedScroll.current, behavior: 'instant' as ScrollBehavior });
setVisible(false);
fadeTimer.current = null;
}, Math.max(VIGNETTE_OUT_MS, ZOOM_OUT_MS));
}
wasBlocked.current = isBlocked;
}, [isBlocked]);
useEffect(() => () => {
const root = document.getElementById('root');
if (root) root.style.transform = root.style.transformOrigin = root.style.transition = '';
document.body.style.overflow = '';
if (savedScroll.current) window.scrollTo({ top: savedScroll.current, behavior: 'instant' as ScrollBehavior });
if (fadeTimer.current) clearTimeout(fadeTimer.current);
}, []);
useEffect(() => {
if (!shockwave) return;
const t = setTimeout(() => setShockwave(false), SHOCKWAVE_MS);
return () => clearTimeout(t);
}, [shockwave]);
if (!visible) return null;
const { x, y } = origin.current;
const css = { '--sx': `${x}px`, '--sy': `${y}px` } as React.CSSProperties;
return createPortal(
<>
<div
aria-hidden
style={{
position: 'fixed', inset: 0, zIndex: 99999,
pointerEvents: isBlocked ? 'all' : 'none',
background: `radial-gradient(ellipse 60% 60% at var(--sx) var(--sy),
transparent 0%, rgba(127,29,29,0.06) 40%, rgba(127,29,29,0.18) 70%, rgba(127,29,29,0.30) 100%)`,
animation: isBlocked
? `overstim-vignette-in ${VIGNETTE_IN_MS}ms ease-out forwards`
: `overstim-vignette-out ${VIGNETTE_OUT_MS}ms ease-in forwards`,
...css,
}}
/>
{shockwave && (
<div
aria-hidden
style={{
position: 'fixed', zIndex: 100000, pointerEvents: 'none', borderRadius: '50%',
left: `calc(var(--sx) - 50vmax)`, top: `calc(var(--sy) - 50vmax)`,
width: '100vmax', height: '100vmax',
background: `radial-gradient(circle at center,
transparent 30%, rgba(239,68,68,0.35) 45%, rgba(239,68,68,0.20) 55%, transparent 70%)`,
animation: `overstim-shockwave ${SHOCKWAVE_MS}ms ease-out forwards`,
...css,
}}
/>
)}
</>,
document.body,
);
}
+43 -172
View File
@@ -1,222 +1,93 @@
/**
* Shake Detection — Pure utility for detecting vigorous shaking during drag.
* Shake Detection — Detects vigorous shaking during drag via pointer samples.
*
* Records pointer position samples in a sliding time window and computes a
* "shake intensity" score based on:
* 1. Average speed of pointer movement (px/s)
* 2. Number of direction reversals (oscillation count)
*
* This is a reusable "motion stress" signal — future systems can consume
* the same samples to detect different physical interactions (e.g. spinning,
* slamming, sustained vibration).
*
* The module is framework-agnostic (no React). All state lives in a plain
* object that the caller creates and passes to each function.
*
* Usage:
* const tracker = createShakeTracker();
* // On each pointer move during drag:
* recordSample(tracker, { x, y });
* // On drag end:
* const result = computeShakeResult(tracker);
* resetTracker(tracker);
* Framework-agnostic. Scores shake intensity based on speed, direction
* reversals, and cumulative energy in a sliding time window.
*/
import type { Position } from '../types/companion.types';
// ─── Configuration ────────────────────────────────────────────────────────────
// ─── Config ───────────────────────────────────────────────────────────────────
/** How long samples are kept (ms). Older samples are pruned. */
const SAMPLE_WINDOW_MS = 2000;
/** Minimum time between recorded samples (ms). Prevents over-sampling at
* high pointer event rates (120 Hz+ on modern devices). */
const MIN_SAMPLE_INTERVAL_MS = 16; // ~60 samples/s max
/** Minimum speed (px/s) for movement to count as "vigorous".
* Normal gentle dragging stays well below this. */
const SPEED_THRESHOLD = 400;
/** Minimum direction reversals in the window for it to count as "shaking"
* rather than just fast linear dragging. */
const REVERSAL_THRESHOLD = 3;
/**
* Minimum cumulative shake energy (speed * reversals * time) before a
* shake is considered "meaningful". Prevents micro-shakes from triggering.
* Tuned so ~1s of moderate shaking crosses this.
*/
const MIN_SHAKE_ENERGY = 800;
const WINDOW_MS = 2000;
const MIN_INTERVAL_MS = 16; // ~60 samples/s max
const SPEED_THRESH = 400; // px/s for "vigorous"
const REVERSAL_THRESH = 3;
const MIN_ENERGY = 800;
// ─── Types ────────────────────────────────────────────────────────────────────
export interface MotionSample {
x: number;
y: number;
t: number; // performance.now() timestamp
}
interface Sample { x: number; y: number; t: number }
/**
* Mutable state object for the shake tracker.
* Caller creates this once and passes it to each function.
*/
export interface ShakeTracker {
samples: MotionSample[];
/** Running sum of per-segment speed values in the current window. */
speedAccumulator: number;
/** Number of direction reversals detected in the current window. */
reversalCount: number;
/** Last recorded movement direction (for reversal detection). */
samples: Sample[];
speedSum: number;
reversals: number;
lastDx: number;
lastDy: number;
/** Whether we have a valid "last direction" yet. */
hasDirection: boolean;
/** Accumulated shake energy across the drag session. Energy is the
* integral of (instantaneous speed * reversal density) over time.
* It only grows while the user is actively shaking. */
hasDir: boolean;
energy: number;
}
/**
* Result of shake analysis after drag ends.
*/
export interface ShakeResult {
/** Whether the shake was meaningful enough to trigger a reaction. */
triggered: boolean;
/** Normalized shake intensity (01). 0 = no shake, 1 = maximum shake. */
intensity: number;
/** Accumulated energy value for duration scaling. */
energy: number;
/** Duration of the active shaking portion (ms). */
shakeDurationMs: number;
}
// ─── API ──────────────────────────────────────────────────────────────────────
/** Create a fresh shake tracker. */
export function createShakeTracker(): ShakeTracker {
return {
samples: [],
speedAccumulator: 0,
reversalCount: 0,
lastDx: 0,
lastDy: 0,
hasDirection: false,
energy: 0,
};
return { samples: [], speedSum: 0, reversals: 0, lastDx: 0, lastDy: 0, hasDir: false, energy: 0 };
}
/** Reset a tracker for reuse (avoids allocation). */
export function resetTracker(tracker: ShakeTracker): void {
tracker.samples.length = 0;
tracker.speedAccumulator = 0;
tracker.reversalCount = 0;
tracker.lastDx = 0;
tracker.lastDy = 0;
tracker.hasDirection = false;
tracker.energy = 0;
export function resetTracker(t: ShakeTracker): void {
t.samples.length = 0;
t.speedSum = t.reversals = t.lastDx = t.lastDy = t.energy = 0;
t.hasDir = false;
}
/**
* Record a pointer position sample.
*
* Call this on every pointer move event during drag. The function
* handles its own rate-limiting and pruning.
*/
export function recordSample(tracker: ShakeTracker, position: Position): void {
export function recordSample(t: ShakeTracker, pos: Position): void {
const now = performance.now();
const { samples } = tracker;
const { samples } = t;
// Rate-limit: skip if too close to the last sample
if (samples.length > 0) {
const last = samples[samples.length - 1]!;
if (now - last.t < MIN_SAMPLE_INTERVAL_MS) return;
}
if (samples.length > 0 && now - samples[samples.length - 1]!.t < MIN_INTERVAL_MS) return;
samples.push({ x: pos.x, y: pos.y, t: now });
// Push new sample
samples.push({ x: position.x, y: position.y, t: now });
// Compute instantaneous velocity from the last two samples
if (samples.length >= 2) {
const prev = samples[samples.length - 2]!;
const curr = samples[samples.length - 1]!;
const dt = (curr.t - prev.t) / 1000; // seconds
const dt = (curr.t - prev.t) / 1000;
if (dt > 0) {
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const dx = curr.x - prev.x, dy = curr.y - prev.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = dist / dt;
t.speedSum += speed;
tracker.speedAccumulator += speed;
// Direction reversal detection (dot product of consecutive deltas)
if (tracker.hasDirection) {
const dot = dx * tracker.lastDx + dy * tracker.lastDy;
if (dot < 0) {
// Direction reversed
tracker.reversalCount++;
}
}
// Update last direction (only if movement was non-trivial)
if (dist > 2) {
tracker.lastDx = dx;
tracker.lastDy = dy;
tracker.hasDirection = true;
}
// Accumulate energy when movement is vigorous
if (speed > SPEED_THRESHOLD && tracker.reversalCount >= 1) {
tracker.energy += speed * dt;
}
if (t.hasDir && dx * t.lastDx + dy * t.lastDy < 0) t.reversals++;
if (dist > 2) { t.lastDx = dx; t.lastDy = dy; t.hasDir = true; }
if (speed > SPEED_THRESH && t.reversals >= 1) t.energy += speed * dt;
}
}
// Prune old samples outside the window
const cutoff = now - SAMPLE_WINDOW_MS;
while (samples.length > 0 && samples[0]!.t < cutoff) {
samples.shift();
}
const cutoff = now - WINDOW_MS;
while (samples.length > 0 && samples[0]!.t < cutoff) samples.shift();
}
/**
* Compute the shake result from the current tracker state.
*
* Call this when the drag ends to determine if shaking occurred
* and how intense it was.
*/
export function computeShakeResult(tracker: ShakeTracker): ShakeResult {
const { samples, speedAccumulator, reversalCount, energy } = tracker;
const sampleCount = samples.length;
export function computeShakeResult(t: ShakeTracker): ShakeResult {
const { samples, speedSum, reversals, energy } = t;
const n = samples.length;
const none: ShakeResult = { triggered: false, intensity: 0, energy: 0, shakeDurationMs: 0 };
if (n < 4) return none;
// Not enough data
if (sampleCount < 4) {
return { triggered: false, intensity: 0, energy: 0, shakeDurationMs: 0 };
}
const avg = speedSum / (n - 1);
if (avg <= SPEED_THRESH || reversals < REVERSAL_THRESH || energy < MIN_ENERGY) return none;
const avgSpeed = speedAccumulator / (sampleCount - 1);
const isVigorous = avgSpeed > SPEED_THRESHOLD;
const isOscillating = reversalCount >= REVERSAL_THRESHOLD;
const hasSufficientEnergy = energy >= MIN_SHAKE_ENERGY;
const dur = samples[n - 1]!.t - samples[0]!.t;
const normEnergy = Math.min(1, (energy - MIN_ENERGY) / 4200);
const normReversals = Math.min(1, reversals / 20);
const intensity = Math.min(1, normEnergy * 0.7 + normReversals * 0.3);
const triggered = isVigorous && isOscillating && hasSufficientEnergy;
if (!triggered) {
return { triggered: false, intensity: 0, energy: 0, shakeDurationMs: 0 };
}
// Compute shake duration from first to last sample
const shakeDurationMs = samples[sampleCount - 1]!.t - samples[0]!.t;
// Normalize intensity (01) based on energy
// Tuning: ~800 = minimum, ~5000 = maximum (about 4s of vigorous shaking)
const normalizedEnergy = Math.min(1, (energy - MIN_SHAKE_ENERGY) / 4200);
// Also factor in reversal density (more reversals = more shaky)
const reversalDensity = Math.min(1, reversalCount / 20);
// Weighted combination: energy dominates, reversals add bonus
const intensity = Math.min(1, normalizedEnergy * 0.7 + reversalDensity * 0.3);
return { triggered, intensity, energy, shakeDurationMs };
return { triggered: true, intensity, energy, shakeDurationMs: dur };
}
@@ -1,145 +1,60 @@
/**
* useOverstimulationReaction — Blobbi reacts to rapid repeated clicks.
* useOverstimulationReaction — Blobbi gets angry from rapid repeated clicks.
*
* Tracks global pointer-down events in a sliding time window. When the
* click count crosses a threshold, the overstimulation level starts rising.
* Additional clicks push the level higher. When clicks stop, the level
* cools back down gradually.
*
* The visual output is determined by an **OverstimulationProfile** that maps
* level ranges to visual recipes. The default profile produces angry
* expressions, but future personalities can supply different profiles
* (e.g. confused, nervous) without changing any of the core logic.
*
* Escalation timeline (with default tuning):
* - 4 rapid clicks → mild angry face (level > 0)
* - 6 rapid clicks → red body fill begins rising (level crosses 0.2)
* - 15 rapid clicks → max level, Blobbi blocks clicks for 24 s
* - After block ends → level cools naturally back to zero
*
* Cooling timeline:
* - 1.5 s after last click → cooling phase starts
* - ~4 s to drain from full (1.0) to zero at 0.25/s
* - Total recovery from max: ~5.5 s
*
* Phases:
* - idle: level = 0, no reaction active
* - rising: user is clicking, level increasing
* - cooling: clicks stopped, level decreasing gradually
* - blocked: level reached max, Blobbi ignores clicks temporarily
*
* Performance: the real level lives in a ref and updates via rAF.
* A visible-level state is only pushed when the delta exceeds a threshold,
* yielding ~610 visual updates per second during transitions.
* Phases: idle → rising → cooling → idle, or rising → blocked → cooling → idle.
* At max level, enters a timed block where clicks are ignored.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { toast } from '@/hooks/useToast';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
// ─── Profile System ───────────────────────────────────────────────────────────
// ─── Profile & Defaults ──────────────────────────────────────────────────────
/**
* Maps overstimulation level ranges to visual recipes.
*
* Future personalities supply different profiles to produce different
* expressions (confused, nervous, etc.) from the same level/phase logic.
*/
export interface OverstimulationProfile {
/** Recipe when level crosses the mild threshold (face only). */
mild: {
recipe: BlobbiVisualRecipe;
label: string;
};
/** Recipe when level crosses the strong threshold (face + body effect). */
strong: {
recipe: BlobbiVisualRecipe;
label: string;
};
/** Color used for the body fill effect at the strong tier. */
mild: { recipe: BlobbiVisualRecipe; label: string };
strong: { recipe: BlobbiVisualRecipe; label: string };
fillColor: string;
}
/** Mildly annoyed: furrowed brows, slight frown, no body effects. */
const ANNOYED_RECIPE: BlobbiVisualRecipe = {
mouth: { droopyMouth: { widthScale: 0.85, curveScale: 0.25 } },
eyebrows: {
config: { angle: 14, offsetY: -9, strokeWidth: 1.6, color: '#4b5563' },
},
};
/** Very annoyed: angry brows, sad mouth. Body fill is added dynamically. */
const FURIOUS_RECIPE: BlobbiVisualRecipe = {
mouth: { sadMouth: true },
eyebrows: {
config: { angle: 22, offsetY: -9, strokeWidth: 2.2, color: '#374151' },
},
};
/** Default profile: angry personality. */
export const ANGRY_PROFILE: OverstimulationProfile = {
mild: { recipe: ANNOYED_RECIPE, label: 'annoyed' },
strong: { recipe: FURIOUS_RECIPE, label: 'furious' },
mild: {
recipe: {
mouth: { droopyMouth: { widthScale: 0.85, curveScale: 0.25 } },
eyebrows: { config: { angle: 14, offsetY: -9, strokeWidth: 1.6, color: '#4b5563' } },
},
label: 'annoyed',
},
strong: {
recipe: {
mouth: { sadMouth: true },
eyebrows: { config: { angle: 22, offsetY: -9, strokeWidth: 2.2, color: '#374151' } },
},
label: 'furious',
},
fillColor: '#ef4444',
};
// ─── Thresholds & Timing ──────────────────────────────────────────────────────
// ─── Constants ────────────────────────────────────────────────────────────────
/** Sliding window for counting clicks (ms). */
const WINDOW_MS = 2000;
/** Clicks in the sliding window to trigger the mild reaction. Click #4 is the
* first to increment the level, so the angry face appears on the 4th rapid click. */
const ACTIVATION_THRESHOLD = 4;
/** Level at which the strong tier activates (face + red body fill).
* With CLICK_INCREMENT = 0.09, this is crossed on the 6th rapid click (level 0.27). */
const ACTIVATION = 4;
const STRONG_LEVEL = 0.2;
/** Level added per click above the activation threshold.
* 11 clicks past threshold (15 total) reach 0.99 → clamped to 1.0 → blocked. */
const CLICK_INCREMENT = 0.09;
/** Milliseconds of no clicks before the cooling phase begins. */
const COOLDOWN_DELAY_MS = 1500;
/** Level units drained per second during cooling.
* Full-to-zero takes ~4 s. Combined with the 1.5 s delay, total recovery is ~5.5 s. */
const COOLING_RATE = 0.25;
/** Minimum blocked duration (ms). */
const BLOCK_MIN_MS = 2000;
/** Maximum blocked duration (ms). */
const BLOCK_MAX_MS = 4000;
/**
* Minimum delta in visible level before pushing a React state update.
* ~50 visual steps from 0→1 keeps renders at ~610fps during transitions.
*/
const VISIBLE_LEVEL_THRESHOLD = 0.02;
const INCREMENT = 0.09;
const COOLDOWN_DELAY = 1500;
const DRAIN_RATE = 0.25;
const BLOCK_MIN = 2000;
const BLOCK_MAX = 4000;
const VIS_THRESH = 0.02;
// ─── Types ────────────────────────────────────────────────────────────────────
export type OverstimulationPhase = 'idle' | 'rising' | 'cooling' | 'blocked';
export interface UseOverstimulationReactionOptions {
/** Whether the hook should listen for clicks. */
isActive: boolean;
/** Visual profile. Defaults to ANGRY_PROFILE. */
profile?: OverstimulationProfile;
}
export interface UseOverstimulationReactionResult {
/** Current overstimulation level (01), throttled for rendering. */
level: number;
/** Current phase. */
phase: OverstimulationPhase;
/** Whether Blobbi clicks should be blocked. */
isBlocked: boolean;
/** Visual recipe override, or null when idle. */
recipe: BlobbiVisualRecipe | null;
/** Human-readable label for the recipe. */
recipeLabel: string | null;
}
@@ -148,241 +63,113 @@ export interface UseOverstimulationReactionResult {
export function useOverstimulationReaction({
isActive,
profile = ANGRY_PROFILE,
}: UseOverstimulationReactionOptions): UseOverstimulationReactionResult {
// ── Visible state (throttled) ──
const [visibleLevel, setVisibleLevel] = useState(0);
}: {
isActive: boolean;
profile?: OverstimulationProfile;
}): UseOverstimulationReactionResult {
const [visLevel, setVisLevel] = useState(0);
const [phase, setPhase] = useState<OverstimulationPhase>('idle');
// ── Refs for high-frequency data ──
const levelRef = useRef(0);
const phaseRef = useRef<OverstimulationPhase>('idle');
const clicksRef = useRef<number[]>([]);
const lastClickRef = useRef(0);
const lastVisibleRef = useRef(0);
const rafRef = useRef<number | null>(null);
const blockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastShownRef = useRef(false);
const prevTimeRef = useRef(0);
const lvl = useRef(0);
const ph = useRef<OverstimulationPhase>('idle');
const clicks = useRef<number[]>([]);
const lastClick = useRef(0);
const lastVis = useRef(0);
const raf = useRef<number | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevT = useRef(0);
const prof = useRef(profile);
prof.current = profile;
// Keep profile in a ref so the rAF loop doesn't need it as a dep
const profileRef = useRef(profile);
profileRef.current = profile;
const stop = useCallback(() => { if (raf.current !== null) { cancelAnimationFrame(raf.current); raf.current = null; } }, []);
const clearTimer = useCallback(() => { if (timer.current !== null) { clearTimeout(timer.current); timer.current = null; } }, []);
// ── Helpers ──
const clearRaf = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
const push = useCallback((level: number, p: OverstimulationPhase) => {
const changed = p !== ph.current;
if (changed || Math.abs(level - lastVis.current) >= VIS_THRESH || (level === 0 && lastVis.current !== 0)) {
lastVis.current = level;
setVisLevel(level);
}
if (changed) { ph.current = p; setPhase(p); }
}, []);
const clearBlockTimer = useCallback(() => {
if (blockTimerRef.current !== null) {
clearTimeout(blockTimerRef.current);
blockTimerRef.current = null;
}
}, []);
// rAF loop — drains level during cooling, detects cooldown in rising
const startRef = useRef<() => void>(() => {});
const tick = useCallback((now: number) => {
const dt = prevT.current > 0 ? (now - prevT.current) / 1000 : 0;
prevT.current = now;
if (ph.current === 'cooling') {
const next = Math.max(0, lvl.current - DRAIN_RATE * dt);
lvl.current = next;
push(next, next <= 0 ? 'idle' : 'cooling');
if (next <= 0) { clicks.current.length = 0; stop(); return; }
} else if (ph.current === 'rising') {
if (now - lastClick.current >= COOLDOWN_DELAY) push(lvl.current, 'cooling');
} else { stop(); return; }
raf.current = requestAnimationFrame(tick);
}, [push, stop]);
/**
* Push visible state if the delta is meaningful.
*
* This is the **single owner** of phase transitions — callers must NOT
* mutate phaseRef before calling pushVisible. The function compares the
* requested phase `p` against the current ref, commits the ref write,
* and then sets the React state.
*/
const pushVisible = useCallback((level: number, p: OverstimulationPhase) => {
const delta = Math.abs(level - lastVisibleRef.current);
const phaseChanged = p !== phaseRef.current;
// Always push on phase change, or snap to 0 at idle
if (phaseChanged || delta >= VISIBLE_LEVEL_THRESHOLD || (level === 0 && lastVisibleRef.current !== 0)) {
lastVisibleRef.current = level;
setVisibleLevel(level);
}
if (phaseChanged) {
phaseRef.current = p;
setPhase(p);
}
}, []);
const start = useCallback(() => {
if (raf.current !== null) return;
prevT.current = performance.now();
raf.current = requestAnimationFrame(tick);
}, [tick]);
startRef.current = start;
// ── rAF cooling loop ──
const startRafLoopRef = useRef<() => void>(() => {});
const rafTick = useCallback((now: number) => {
const dt = prevTimeRef.current > 0 ? (now - prevTimeRef.current) / 1000 : 0;
prevTimeRef.current = now;
const currentPhase = phaseRef.current;
if (currentPhase === 'cooling') {
const newLevel = Math.max(0, levelRef.current - COOLING_RATE * dt);
levelRef.current = newLevel;
pushVisible(newLevel, newLevel <= 0 ? 'idle' : 'cooling');
if (newLevel <= 0) {
levelRef.current = 0;
phaseRef.current = 'idle';
toastShownRef.current = false;
// Clear the sliding window so stale timestamps from this cycle
// don't interfere with the next activation attempt.
clicksRef.current.length = 0;
clearRaf();
return;
}
} else if (currentPhase === 'rising') {
// In rising, we just keep the loop alive to detect cooldown transition.
// pushVisible owns the phase transition — do NOT mutate phaseRef here.
const elapsed = now - lastClickRef.current;
if (elapsed >= COOLDOWN_DELAY_MS) {
pushVisible(levelRef.current, 'cooling');
}
} else {
// idle or blocked — stop the loop
clearRaf();
return;
}
rafRef.current = requestAnimationFrame(rafTick);
}, [pushVisible, clearRaf]);
const startRafLoop = useCallback(() => {
if (rafRef.current !== null) return; // already running
prevTimeRef.current = performance.now();
rafRef.current = requestAnimationFrame(rafTick);
}, [rafTick]);
startRafLoopRef.current = startRafLoop;
/** Enter blocked state. Uses startRafLoopRef to avoid circular dependency. */
const enterBlocked = useCallback(() => {
phaseRef.current = 'blocked';
setPhase('blocked');
const duration = BLOCK_MIN_MS + Math.random() * (BLOCK_MAX_MS - BLOCK_MIN_MS);
if (!toastShownRef.current) {
toastShownRef.current = true;
const seconds = Math.ceil(duration / 1000);
toast({
title: 'Too many clicks!',
description: `Blobbi is overwhelmed\u2026 calm down for ${seconds}s`,
});
}
clearBlockTimer();
blockTimerRef.current = setTimeout(() => {
blockTimerRef.current = null;
phaseRef.current = 'cooling';
setPhase('cooling');
prevTimeRef.current = performance.now();
startRafLoopRef.current();
}, duration);
}, [clearBlockTimer]);
// ── Global click handler ──
push(1, 'blocked');
const dur = BLOCK_MIN + Math.random() * (BLOCK_MAX - BLOCK_MIN);
clearTimer();
timer.current = setTimeout(() => {
timer.current = null;
push(lvl.current, 'cooling');
startRef.current();
}, dur);
}, [push, clearTimer]);
// Global click handler
useEffect(() => {
if (!isActive) {
// Reset everything
clearRaf();
clearBlockTimer();
clicksRef.current = [];
levelRef.current = 0;
phaseRef.current = 'idle';
lastVisibleRef.current = 0;
toastShownRef.current = false;
setVisibleLevel(0);
setPhase('idle');
stop(); clearTimer(); clicks.current = [];
lvl.current = 0; lastVis.current = 0;
push(0, 'idle');
return;
}
const handlePointerDown = () => {
const handler = () => {
if (ph.current === 'blocked') return;
const now = Date.now();
const clicks = clicksRef.current;
// Don't process clicks during blocked phase
if (phaseRef.current === 'blocked') return;
// Add timestamp and prune outside window
clicks.push(now);
const c = clicks.current;
c.push(now);
const cutoff = now - WINDOW_MS;
while (clicks.length > 0 && clicks[0]! < cutoff) {
clicks.shift();
}
lastClickRef.current = performance.now();
const count = clicks.length;
if (count < ACTIVATION_THRESHOLD) {
// Below threshold — if we were rising/cooling, stay in that phase
// (additional slow clicks don't cancel an ongoing reaction)
return;
}
// Above threshold: increase level
const newLevel = Math.min(1, levelRef.current + CLICK_INCREMENT);
levelRef.current = newLevel;
if (newLevel >= 1) {
// Max reached — enter blocked
clearRaf();
pushVisible(1, 'blocked');
enterBlocked();
return;
}
// Rising — pushVisible owns the phase transition, do NOT mutate phaseRef here
pushVisible(newLevel, 'rising');
// Ensure rAF loop is running (for cooldown-delay detection)
startRafLoopRef.current();
while (c.length > 0 && c[0]! < cutoff) c.shift();
lastClick.current = performance.now();
if (c.length < ACTIVATION) return;
const next = Math.min(1, lvl.current + INCREMENT);
lvl.current = next;
if (next >= 1) { stop(); enterBlocked(); return; }
push(next, 'rising');
startRef.current();
};
document.addEventListener('pointerdown', handler, { passive: true });
return () => document.removeEventListener('pointerdown', handler);
}, [isActive, stop, clearTimer, push, enterBlocked]);
document.addEventListener('pointerdown', handlePointerDown, { passive: true });
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [isActive, clearRaf, clearBlockTimer, pushVisible, enterBlocked]);
useEffect(() => () => { stop(); clearTimer(); }, [stop, clearTimer]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearRaf();
clearBlockTimer();
};
}, [clearRaf, clearBlockTimer]);
// ── Resolve level + phase → recipe ──
const result = useMemo((): UseOverstimulationReactionResult => {
if (phase === 'idle' || visibleLevel <= 0) {
// Resolve level → recipe
return useMemo((): UseOverstimulationReactionResult => {
if (phase === 'idle' || visLevel <= 0)
return { level: 0, phase: 'idle', isBlocked: false, recipe: null, recipeLabel: null };
}
const p = prof.current;
const isBlocked = phase === 'blocked';
const p = profileRef.current;
if (visibleLevel >= STRONG_LEVEL) {
// Strong tier: face recipe + level-controlled body fill
if (visLevel >= STRONG_LEVEL) {
const recipe: BlobbiVisualRecipe = {
...p.strong.recipe,
bodyEffects: {
...p.strong.recipe.bodyEffects,
angerRise: { color: p.fillColor, duration: 0, level: visibleLevel },
},
bodyEffects: { ...p.strong.recipe.bodyEffects, angerRise: { color: p.fillColor, duration: 0, level: visLevel } },
};
return { level: visibleLevel, phase, isBlocked, recipe, recipeLabel: p.strong.label };
return { level: visLevel, phase, isBlocked, recipe, recipeLabel: p.strong.label };
}
// Mild tier: face only
return { level: visibleLevel, phase, isBlocked, recipe: p.mild.recipe, recipeLabel: p.mild.label };
// profile must be in deps to recompute recipes when profile changes at runtime,
// even though profileRef.current is read inside (ref is stale-safe, dep triggers recompute).
return { level: visLevel, phase, isBlocked, recipe: p.mild.recipe, recipeLabel: p.mild.label };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, visibleLevel, profile]);
return result;
}, [phase, visLevel, profile]);
}
+235 -340
View File
@@ -1,156 +1,85 @@
/**
* useShakeReaction — Blobbi reacts to being shaken during drag.
* useShakeReaction — Blobbi gets dizzy (and optionally nauseous) when shaken.
*
* Produces a live visual reaction while the user is actively shaking,
* and sustains the dizzy state after release for a duration proportional
* to the total shake intensity.
*
* 1. **Shaking phase** (during drag): When shake energy crosses the
* trigger threshold, Blobbi immediately looks dizzy. If nausea is
* eligible (hunger >= threshold), the green body fill rises in real
* time as the user continues shaking.
* Phases:
* - idle: No shake reaction active
* - shaking: User is actively shaking (dizzy face + live nausea fill)
* - dizzy: Post-release hold (spiral eyes, sustained nausea level)
* - recovering: Nausea draining (rAF loop)
*
* 2. **Dizzy phase** (after release): The dizzy expression and any
* accumulated nausea fill are held for a duration that scales with
* the final shake intensity (~38 s). Nausea fill begins draining
* immediately during this phase.
*
* 3. **Recovering phase**: Nausea fill continues draining via rAF.
* Once fully drained, transitions to idle.
* Nausea (green body fill) only triggers when hunger >= 90.
*
* Stacking: If the user starts a new shake during an active dizzy or
* recovering phase, the reaction continues from the current state
* instead of resetting. The nausea fill can only rise (never drops
* below its current level), and the dizzy hold timer extends.
*
* Architecture notes:
* - Follows the same phase/level/profile pattern as useOverstimulationReaction
* - The **ShakeReactionProfile** interface enables future personality
* variants (e.g. a hardy Blobbi that resists nausea, or one that
* gets scared instead of dizzy)
* - The nausea level caps at 1.0, leaving room for future escalation
* phases (e.g. additional outcomes at max nausea).
* - The dizzy recipe reuses the existing EMOTION_RECIPES.dizzy preset
*
* Phases:
* - idle: No shake reaction active
* - shaking: User is actively shaking (dizzy face + live nausea fill)
* - dizzy: Post-release hold (spiral eyes, sustained nausea level)
* - recovering: Nausea draining (rAF loop)
*
* Performance: Same ref+rAF pattern as overstimulation. Visible level
* state updates are throttled to ~610 fps.
* instead of resetting. The nausea fill can only rise, never drop below
* its current level, and the dizzy hold timer extends.
*/
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { toast } from '@/hooks/useToast';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import { resolveVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { ShakeResult } from '../core/shakeDetection';
// ─── Profile System ───────────────────────────────────────────────────────────
// ─── Profile & Defaults ──────────────────────────────────────────────────────
/**
* Maps shake reaction states to visual recipes.
*
* Future personalities can supply different profiles to produce different
* reactions (e.g. scared instead of dizzy, or resistant to nausea).
*/
export interface ShakeReactionProfile {
/** Recipe for the dizzy state (face only, no body fill). */
dizzy: {
recipe: BlobbiVisualRecipe;
label: string;
};
/** Recipe for the dizzy+nausea state (face; body fill is added dynamically). */
nauseated: {
recipe: BlobbiVisualRecipe;
label: string;
};
/** Color used for the nausea body fill effect. */
dizzy: { recipe: BlobbiVisualRecipe; label: string };
nauseated: { recipe: BlobbiVisualRecipe; label: string };
nauseaFillColor: string;
/** Opacity at the bottom of the nausea fill (01). Default: 0.78. */
nauseaBottomOpacity?: number;
/** Opacity at the feathered top edge of the nausea fill (01). Default: 0.65. */
nauseaEdgeOpacity?: number;
}
/** Dizzy-only recipe: reuse the existing emotion preset. */
const DIZZY_RECIPE = resolveVisualRecipe('dizzy');
/**
* Nauseated recipe: same dizzy eyes but with a queasy mouth twist.
* The green body fill is added dynamically based on nausea level.
*/
const NAUSEATED_RECIPE: BlobbiVisualRecipe = {
...DIZZY_RECIPE,
// Slightly different mouth — wider, more distressed
mouth: { roundMouth: { rx: 5, ry: 6, filled: true } },
eyebrows: {
config: { angle: -15, offsetY: -12, strokeWidth: 1.5, color: '#6b7280', curve: 0.15 },
},
};
/** Default profile: dizzy + dark cartoon-sickness green nausea. */
export const DIZZY_NAUSEA_PROFILE: ShakeReactionProfile = {
dizzy: { recipe: DIZZY_RECIPE, label: 'dizzy' },
nauseated: { recipe: NAUSEATED_RECIPE, label: 'nauseated' },
// Dark cartoon-sickness green — stylized, not neon, not realistic
nauseated: {
recipe: {
...DIZZY_RECIPE,
mouth: { roundMouth: { rx: 5, ry: 6, filled: true } },
eyebrows: {
config: {
angle: -15,
offsetY: -12,
strokeWidth: 1.5,
color: '#6b7280',
curve: 0.15,
},
},
},
label: 'nauseated',
},
nauseaFillColor: '#4a7a3d',
// Strong presence so Blobbi visibly turns green/sick
nauseaBottomOpacity: 0.78,
nauseaEdgeOpacity: 0.65,
};
// ─── Thresholds & Timing ──────────────────────────────────────────────────────
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* Hunger stat at or above which shaking triggers nausea (very full).
* When hunger is below this, Blobbi still gets dizzy but without the
* green body fill escalation.
*/
const NAUSEA_HUNGER_THRESHOLD = 90;
/** Minimum dizzy duration (seconds) for a barely-qualifying shake. */
const MIN_DIZZY_DURATION_S = 3;
/** Maximum dizzy duration (seconds) for the most intense shake. */
const MAX_DIZZY_DURATION_S = 8;
/** Rate at which nausea level drains during recovery (units/s).
* Full nausea (1.0) takes ~4 s to drain. */
const NAUSEA_DRAIN_RATE = 0.25;
/** Minimum delta before pushing a visible nausea level update. */
const VISIBLE_LEVEL_THRESHOLD = 0.02;
const HUNGER_THRESH = 90;
const DIZZY_MIN_S = 3;
const DIZZY_MAX_S = 8;
const DRAIN_RATE = 0.25;
const VIS_THRESH = 0.02;
// ─── Types ────────────────────────────────────────────────────────────────────
export type ShakeReactionPhase = 'idle' | 'shaking' | 'dizzy' | 'recovering';
export interface UseShakeReactionOptions {
/** Whether the hook is active. */
isActive: boolean;
/** Current hunger stat value (1100). Used to determine nausea eligibility. */
hunger: number;
/** Visual profile. Defaults to DIZZY_NAUSEA_PROFILE. */
profile?: ShakeReactionProfile;
}
export interface UseShakeReactionResult {
/** Current phase. */
phase: ShakeReactionPhase;
/** Current nausea level (01), throttled for rendering. 0 when no nausea. */
nauseaLevel: number;
/** Visual recipe override, or null when idle. */
recipe: BlobbiVisualRecipe | null;
/** Human-readable label for the recipe. */
recipeLabel: string | null;
/** Call this on each drag sample with the live ShakeResult. */
onDragUpdate: (result: ShakeResult) => void;
/** Call this when drag ends with the final ShakeResult. */
onDragEnd: (result: ShakeResult) => void;
/** Call this when drag starts. Does not reset active reactions (stacking). */
/** Call this when drag starts. Does not reset active reactions. */
onDragStart: () => void;
}
@@ -160,295 +89,246 @@ export function useShakeReaction({
isActive,
hunger,
profile = DIZZY_NAUSEA_PROFILE,
}: UseShakeReactionOptions): UseShakeReactionResult {
// ── Visible state (throttled) ──
const [visibleNauseaLevel, setVisibleNauseaLevel] = useState(0);
}: {
isActive: boolean;
hunger: number;
profile?: ShakeReactionProfile;
}): UseShakeReactionResult {
const [visLevel, setVisLevel] = useState(0);
const [phase, setPhase] = useState<ShakeReactionPhase>('idle');
/** Whether nausea was activated at any point during the current cycle.
* Promoted to React state so the recipe resolver can use a consistent
* face recipe (nauseated) even after the fill fully drains, preventing
* a structural SVG rebuild that would kill SMIL spiral animations. */
/**
* Once nausea activates in a cycle, keep using the nauseated recipe
* until the full cycle ends. This avoids a structural SVG rebuild
* that could kill SMIL spiral eye animations mid-reaction.
*/
const [cycleHadNausea, setCycleHadNausea] = useState(false);
// ── Refs for high-frequency data ──
const nauseaLevelRef = useRef(0);
const phaseRef = useRef<ShakeReactionPhase>('idle');
const lastVisibleRef = useRef(0);
const rafRef = useRef<number | null>(null);
const dizzyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevTimeRef = useRef(0);
const lvl = useRef(0);
const ph = useRef<ShakeReactionPhase>('idle');
const lastVis = useRef(0);
const raf = useRef<number | null>(null);
const prevT = useRef(0);
const dizzyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hungerRef = useRef(hunger);
hungerRef.current = hunger;
const toastShownRef = useRef(false);
/** Whether nausea was triggered at any point during this reaction cycle.
* Used by the recipe resolver to keep the nauseated face (same structural
* recipe) even after the fill drains to 0, avoiding an SVG rebuild that
* would kill SMIL spiral eye animations mid-reaction. */
const toasted = useRef(false);
const prof = useRef(profile);
const cycleHadNauseaRef = useRef(false);
const profileRef = useRef(profile);
profileRef.current = profile;
hungerRef.current = hunger;
prof.current = profile;
// ── Helpers ──
const clearRaf = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
const stop = useCallback(() => {
if (raf.current !== null) {
cancelAnimationFrame(raf.current);
raf.current = null;
}
}, []);
const clearDizzyTimer = useCallback(() => {
if (dizzyTimerRef.current !== null) {
clearTimeout(dizzyTimerRef.current);
dizzyTimerRef.current = null;
const clearDizzy = useCallback(() => {
if (dizzyTimer.current !== null) {
clearTimeout(dizzyTimer.current);
dizzyTimer.current = null;
}
}, []);
const pushVisible = useCallback((level: number, p: ShakeReactionPhase) => {
const delta = Math.abs(level - lastVisibleRef.current);
const phaseChanged = p !== phaseRef.current;
const push = useCallback((level: number, nextPhase: ShakeReactionPhase) => {
const changed = nextPhase !== ph.current;
if (phaseChanged || delta >= VISIBLE_LEVEL_THRESHOLD || (level === 0 && lastVisibleRef.current !== 0)) {
lastVisibleRef.current = level;
setVisibleNauseaLevel(level);
if (
changed ||
Math.abs(level - lastVis.current) >= VIS_THRESH ||
(level === 0 && lastVis.current !== 0)
) {
lastVis.current = level;
setVisLevel(level);
}
if (phaseChanged) {
phaseRef.current = p;
setPhase(p);
if (changed) {
ph.current = nextPhase;
setPhase(nextPhase);
}
}, []);
/** Transition the full cycle to idle, cleaning up all cycle-scoped state. */
const finishCycle = useCallback(() => {
pushVisible(0, 'idle');
toastShownRef.current = false;
lvl.current = 0;
lastVis.current = 0;
toasted.current = false;
cycleHadNauseaRef.current = false;
setCycleHadNausea(false);
}, [pushVisible]);
push(0, 'idle');
}, [push]);
// ── rAF nausea drain loop ──
// Runs during BOTH 'dizzy' and 'recovering' phases so the green fill
// begins descending the moment shaking stops, not only after the dizzy
// hold ends. The drain rate is the same in both phases for a smooth,
// continuous descent.
const tick = useCallback(
(now: number) => {
const dt = prevT.current > 0 ? (now - prevT.current) / 1000 : 0;
prevT.current = now;
const rafTick = useCallback((now: number) => {
const dt = prevTimeRef.current > 0 ? (now - prevTimeRef.current) / 1000 : 0;
prevTimeRef.current = now;
const next = Math.max(0, lvl.current - DRAIN_RATE * dt);
lvl.current = next;
const currentPhase = phaseRef.current;
if (currentPhase === 'dizzy' || currentPhase === 'recovering') {
if (nauseaLevelRef.current <= 0) {
nauseaLevelRef.current = 0;
// During dizzy hold, keep the phase — the timer will handle the
// dizzy→idle transition. During recovering, go straight to idle.
if (currentPhase === 'recovering') {
if (next <= 0) {
if (ph.current === 'recovering') {
finishCycle();
} else {
pushVisible(0, 'dizzy');
// During dizzy hold, keep the phase. Timer handles dizzy → idle.
push(0, ph.current);
}
clearRaf();
stop();
return;
}
const newLevel = Math.max(0, nauseaLevelRef.current - NAUSEA_DRAIN_RATE * dt);
nauseaLevelRef.current = newLevel;
push(next, ph.current);
raf.current = requestAnimationFrame(tick);
},
[finishCycle, push, stop],
);
if (newLevel <= 0) {
if (currentPhase === 'recovering') {
finishCycle();
} else {
pushVisible(0, 'dizzy');
}
clearRaf();
return;
}
const startDrain = useCallback(() => {
if (raf.current !== null) return;
// Stay in the current phase — level changed but phase didn't
pushVisible(newLevel, currentPhase);
} else {
clearRaf();
return;
}
prevT.current = performance.now();
raf.current = requestAnimationFrame(tick);
}, [tick]);
rafRef.current = requestAnimationFrame(rafTick);
}, [pushVisible, clearRaf, finishCycle]);
const startRafLoop = useCallback(() => {
if (rafRef.current !== null) return;
prevTimeRef.current = performance.now();
rafRef.current = requestAnimationFrame(rafTick);
}, [rafTick]);
const startRafLoopRef = useRef(startRafLoop);
startRafLoopRef.current = startRafLoop;
// ── Full reset ──
const startRef = useRef(startDrain);
startRef.current = startDrain;
const resetAll = useCallback(() => {
clearRaf();
clearDizzyTimer();
nauseaLevelRef.current = 0;
phaseRef.current = 'idle';
lastVisibleRef.current = 0;
toastShownRef.current = false;
cycleHadNauseaRef.current = false;
setVisibleNauseaLevel(0);
setPhase('idle');
setCycleHadNausea(false);
}, [clearRaf, clearDizzyTimer]);
stop();
clearDizzy();
// ── Drag start: prepare for potential shake ──
// If an active reaction is running (dizzy/recovering), we do NOT reset.
// The new shake will build on the current state — onDragUpdate handles
// the transition back to 'shaking' from any active phase.
lvl.current = 0;
lastVis.current = 0;
toasted.current = false;
cycleHadNauseaRef.current = false;
setCycleHadNausea(false);
push(0, 'idle');
}, [stop, clearDizzy, push]);
const onDragStart = useCallback(() => {
// Only reset the toast guard when starting fresh from idle.
// During an active reaction, let the existing toast state persist.
if (phaseRef.current === 'idle') {
toastShownRef.current = false;
// Starting a new drag while dizzy/recovering should not reset the cycle.
if (ph.current === 'idle') {
toasted.current = false;
}
}, []);
// ── Live drag update: transition to shaking when threshold crossed ──
const onDragUpdate = useCallback(
(result: ShakeResult) => {
if (!result.triggered) return;
const onDragUpdate = useCallback((result: ShakeResult) => {
if (!result.triggered) return;
const sick = hungerRef.current >= HUNGER_THRESH;
const nausea = sick ? Math.min(1, result.intensity) : 0;
const isNauseated = hungerRef.current >= NAUSEA_HUNGER_THRESHOLD;
const nauseaLevel = isNauseated ? Math.min(1, result.intensity) : 0;
if (isNauseated && !cycleHadNauseaRef.current) {
cycleHadNauseaRef.current = true;
setCycleHadNausea(true);
}
// Update nausea level live — take the max of current and new so that
// re-shaking during an active reaction can only raise the fill, never
// drop it below what's already accumulated.
nauseaLevelRef.current = Math.max(nauseaLevelRef.current, nauseaLevel);
// Transition idle/shaking → shaking on first trigger.
// Also absorb dizzy/recovering phases — a new shake during an active
// reaction continues from the current state instead of resetting.
const currentPhase = phaseRef.current;
if (currentPhase === 'idle' || currentPhase === 'shaking' ||
currentPhase === 'dizzy' || currentPhase === 'recovering') {
// Cancel the dizzy hold timer — we're back to active shaking
if (currentPhase === 'dizzy' || currentPhase === 'recovering') {
clearDizzyTimer();
clearRaf();
if (sick && !cycleHadNauseaRef.current) {
cycleHadNauseaRef.current = true;
setCycleHadNausea(true);
}
pushVisible(nauseaLevelRef.current, 'shaking');
// Show nausea warning toast once per shake cycle
if (isNauseated && !toastShownRef.current) {
toastShownRef.current = true;
toast({
title: 'Careful\u2026',
description: 'Blobbi is feeling sick!',
});
// Re-shaking should only raise the fill, never lower it.
lvl.current = Math.max(lvl.current, nausea);
const currentPhase = ph.current;
if (
currentPhase === 'idle' ||
currentPhase === 'shaking' ||
currentPhase === 'dizzy' ||
currentPhase === 'recovering'
) {
if (currentPhase === 'dizzy' || currentPhase === 'recovering') {
clearDizzy();
stop();
}
push(lvl.current, 'shaking');
if (sick && !toasted.current) {
toasted.current = true;
toast({
title: 'Careful\u2026',
description: 'Blobbi is feeling sick!',
});
}
}
}
}, [pushVisible, clearDizzyTimer, clearRaf]);
},
[clearDizzy, push, stop],
);
// ── Drag end: finalize and hold dizzy ──
//
// If the user was actively shaking (phase === 'shaking'), transition
// to the dizzy hold. The nausea level takes the max of the current
// (possibly mid-drain from a prior shake) and the new shake's level,
// so re-shaking during recovery can only raise the fill.
const onDragEnd = useCallback(
(result: ShakeResult) => {
const wasShaking = ph.current === 'shaking';
const onDragEnd = useCallback((result: ShakeResult) => {
// If we were in shaking phase, always finalize (even if the
// final result is below threshold — the user already saw the reaction)
const wasShaking = phaseRef.current === 'shaking';
if (!result.triggered && !wasShaking) return;
if (!result.triggered && !wasShaking) return;
const intensity = result.triggered ? result.intensity : 0;
const dur = DIZZY_MIN_S + intensity * (DIZZY_MAX_S - DIZZY_MIN_S);
// Calculate dizzy duration from final intensity
const intensity = result.triggered ? result.intensity : 0;
const dizzyDurationS = MIN_DIZZY_DURATION_S +
intensity * (MAX_DIZZY_DURATION_S - MIN_DIZZY_DURATION_S);
const sick = hungerRef.current >= HUNGER_THRESH;
const nausea = sick ? Math.min(1, intensity) : 0;
const isNauseated = hungerRef.current >= NAUSEA_HUNGER_THRESHOLD;
const newNauseaLevel = isNauseated ? Math.min(1, intensity) : 0;
// Take the max of current and new — re-shaking only raises the fill
const effectiveLevel = Math.max(nauseaLevelRef.current, newNauseaLevel);
// Lock in the effective nausea level and start draining immediately
nauseaLevelRef.current = effectiveLevel;
pushVisible(effectiveLevel, 'dizzy');
// Start the rAF drain loop right away so the green fill begins
// descending during the dizzy hold, not only after it ends.
if (effectiveLevel > 0) {
// Stop existing loop (if mid-drain) and restart with fresh timing
clearRaf();
startRafLoopRef.current();
}
// Reschedule the dizzy hold timer — a new shake extends the hold
clearDizzyTimer();
dizzyTimerRef.current = setTimeout(() => {
dizzyTimerRef.current = null;
if (nauseaLevelRef.current > 0) {
// Nausea still draining — transition to recovering (loop is
// already running, it will pick up the new phase automatically)
pushVisible(nauseaLevelRef.current, 'recovering');
} else {
finishCycle();
if (sick && nausea > 0 && !cycleHadNauseaRef.current) {
cycleHadNauseaRef.current = true;
setCycleHadNausea(true);
}
}, dizzyDurationS * 1000);
}, [pushVisible, clearDizzyTimer, clearRaf, finishCycle]);
// ── Reset on deactivation ──
// Keep the strongest accumulated nausea level.
lvl.current = Math.max(lvl.current, nausea);
push(lvl.current, 'dizzy');
if (lvl.current > 0) {
stop();
startRef.current();
}
clearDizzy();
dizzyTimer.current = setTimeout(() => {
dizzyTimer.current = null;
if (lvl.current > 0) {
push(lvl.current, 'recovering');
// Usually already running, but safe if the fill was reintroduced.
startRef.current();
} else {
finishCycle();
}
}, dur * 1000);
},
[clearDizzy, finishCycle, push, stop],
);
useEffect(() => {
if (!isActive) {
resetAll();
}
if (!isActive) resetAll();
}, [isActive, resetAll]);
// ── Cleanup on unmount ──
useEffect(() => {
return () => {
clearRaf();
clearDizzyTimer();
stop();
clearDizzy();
};
}, [clearRaf, clearDizzyTimer]);
}, [stop, clearDizzy]);
// ── Resolve phase + nausea level → recipe ──
//
// Key invariant: once nausea activates in a cycle, the nauseated face
// recipe is used for ALL remaining non-idle phases — even after the
// green fill fully drains to 0. This prevents a structural recipe
// switch (nauseated → dizzy) mid-reaction, which would trigger an SVG
// rebuild and kill the running SMIL spiral eye animation.
const result = useMemo((): UseShakeReactionResult => {
return useMemo((): UseShakeReactionResult => {
const base = { onDragUpdate, onDragEnd, onDragStart };
if (phase === 'idle') {
return { ...base, phase: 'idle', nauseaLevel: 0, recipe: null, recipeLabel: null };
return {
...base,
phase: 'idle',
nauseaLevel: 0,
recipe: null,
recipeLabel: null,
};
}
const p = profileRef.current;
const p = prof.current;
// Use the nauseated face for the entire cycle if nausea was triggered,
// even when the fill has drained to 0. This keeps the structural recipe
// stable so SMIL animations survive.
const useNauseatedFace = cycleHadNausea;
// ── Active nausea fill ──
if (visibleNauseaLevel > 0 && useNauseatedFace) {
if (visLevel > 0 && cycleHadNausea) {
const recipe: BlobbiVisualRecipe = {
...p.nauseated.recipe,
bodyEffects: {
@@ -456,24 +336,39 @@ export function useShakeReaction({
angerRise: {
color: p.nauseaFillColor,
duration: 0,
level: visibleNauseaLevel,
level: visLevel,
bottomOpacity: p.nauseaBottomOpacity,
edgeOpacity: p.nauseaEdgeOpacity,
},
},
};
return { ...base, phase, nauseaLevel: visibleNauseaLevel, recipe, recipeLabel: p.nauseated.label };
return {
...base,
phase,
nauseaLevel: visLevel,
recipe,
recipeLabel: p.nauseated.label,
};
}
// ── Nauseated face without fill (fill drained but cycle still active) ──
if (useNauseatedFace) {
return { ...base, phase, nauseaLevel: 0, recipe: p.nauseated.recipe, recipeLabel: p.nauseated.label };
if (cycleHadNausea) {
return {
...base,
phase,
nauseaLevel: 0,
recipe: p.nauseated.recipe,
recipeLabel: p.nauseated.label,
};
}
// ── Dizzy only (no nausea in this cycle) ──
return { ...base, phase, nauseaLevel: 0, recipe: p.dizzy.recipe, recipeLabel: p.dizzy.label };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, visibleNauseaLevel, cycleHadNausea, profile, onDragUpdate, onDragEnd, onDragStart]);
return result;
}
return {
...base,
phase,
nauseaLevel: 0,
recipe: p.dizzy.recipe,
recipeLabel: p.dizzy.label,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, visLevel, cycleHadNausea, profile, onDragUpdate, onDragEnd, onDragStart]);
}
@@ -48,8 +48,8 @@ import {
type InventoryAction,
ACTION_METADATA,
} from '@/blobbi/actions/lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '@/blobbi/actions/lib/daily-mission-tracker';
import type { DailyMissionAction } from '@/blobbi/actions/lib/daily-missions';
import { trackEvolutionMissionTally, readEvolutionFromStorage, trackInventoryDailyActions } from '@/blobbi/actions/lib/daily-mission-tracker';
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import type { UseItemFunction } from './BlobbiActionsContextDef';
@@ -354,9 +354,18 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const progressionState = companion.progressionState;
const updatedTags = companion.allTags;
if (progressionState === 'incubating' || progressionState === 'evolving') {
trackEvolutionMissionTally('interactions', 1, user?.pubkey);
trackEvolutionMissionTally('interactions', 1, user?.pubkey, companion.d);
}
// ─── Build content with latest evolution state ───
let content = companion.event.content;
if (progressionState === 'incubating' || progressionState === 'evolving') {
const evo = readEvolutionFromStorage(user?.pubkey, companion.d);
if (evo && evo.length > 0) {
content = serializeEvolutionContent(companion.event.content, evo);
}
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(companion) ?? {};
@@ -369,8 +378,9 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
content,
tags: blobbiTags,
prev: companion.event,
});
updateCompanionInCache(blobbiEvent);
@@ -391,10 +401,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
// Track daily mission progress
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
trackInventoryDailyActions(action, user?.pubkey);
// Set success cooldown (short)
setItemCooldown(itemId, true);
@@ -148,6 +148,7 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
tags: newTags,
prev: companion.event,
});
// Optimistic cache update + background invalidation
+104 -35
View File
@@ -9,24 +9,23 @@ import { toast } from '@/hooks/useToast';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
BLOBBONAUT_PROFILE_KINDS,
getBlobbonautQueryDValues,
BLOBBI_ECOSYSTEM_NAMESPACE,
buildMigrationTags,
generatePetId10,
deriveMigrationPetId,
getCanonicalBlobbiD,
isValidBlobbiEvent,
isValidBlobbonautEvent,
isLegacyBlobbonautKind,
migratePetInHas,
updateBlobbonautTags,
parseBlobbiEvent,
parseBlobbonautEvent,
parseStorageTags,
findCanonicalEquivalent,
type BlobbiCompanion,
type BlobbonautProfile,
type StorageItem,
} from '../lib/blobbi';
import { fetchFreshBlobbonautProfile } from '../lib/fetchFreshBlobbonautProfile';
/**
* Result of a successful migration.
*/
@@ -153,8 +152,10 @@ export function useBlobbiMigration() {
console.log('[Blobbi Migration] Starting migration for:', companion.d);
try {
// Generate new canonical d-tag
const newPetId = generatePetId10();
// Derive deterministic canonical d-tag from legacy identity.
// Same (pubkey, legacyD) always produces the same canonicalD, making
// the entire migration chain (d → seed → visuals) stable.
const newPetId = deriveMigrationPetId(user.pubkey, companion.d);
const canonicalD = getCanonicalBlobbiD(user.pubkey, newPetId);
// Build migration tags (preserves name, stage, stats, generates seed if missing)
@@ -193,8 +194,9 @@ export function useBlobbiMigration() {
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: profile.event.content ?? '',
tags: profileTags,
prev: profile.event,
});
// Update query caches (optimistic — no invalidation needed since we
@@ -264,35 +266,35 @@ export function useBlobbiMigration() {
}, [nostr]);
/**
* Fetch the freshest profile event directly from relays, bypassing cache.
* Fetch all companions for a user from relays, parse and deduplicate by d-tag.
* Used to find existing canonical equivalents before migrating a legacy Blobbi.
*/
const fetchFreshProfile = useCallback(async (
const fetchAllCompanions = useCallback(async (
pubkey: string,
): Promise<BlobbonautProfile | null> => {
const dValues = getBlobbonautQueryDValues(pubkey);
): Promise<BlobbiCompanion[]> => {
const events = await nostr.query([{
kinds: [...BLOBBONAUT_PROFILE_KINDS],
kinds: [KIND_BLOBBI_STATE],
authors: [pubkey],
'#d': dValues,
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
}]);
const validEvents = events.filter(isValidBlobbonautEvent);
if (validEvents.length === 0) return null;
// Prefer current kind over legacy
const currentKindEvents = validEvents.filter(e => e.kind === KIND_BLOBBONAUT_PROFILE);
if (currentKindEvents.length > 0) {
const sorted = currentKindEvents.sort((a, b) => b.created_at - a.created_at);
return parseBlobbonautEvent(sorted[0]) ?? null;
// Deduplicate by d-tag (newest wins), same logic as useBlobbisCollection
const eventsByD = new Map<string, NostrEvent>();
for (const event of events.filter(isValidBlobbiEvent)) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (!dTag) continue;
const existing = eventsByD.get(dTag);
if (!existing || event.created_at > existing.created_at) {
eventsByD.set(dTag, event);
}
}
const legacyKindEvents = validEvents.filter(e => isLegacyBlobbonautKind(e));
if (legacyKindEvents.length > 0) {
const sorted = legacyKindEvents.sort((a, b) => b.created_at - a.created_at);
return parseBlobbonautEvent(sorted[0]) ?? null;
const companions: BlobbiCompanion[] = [];
for (const event of eventsByD.values()) {
const parsed = parseBlobbiEvent(event);
if (parsed) companions.push(parsed);
}
return null;
return companions;
}, [nostr]);
/**
@@ -302,14 +304,19 @@ export function useBlobbiMigration() {
* instead of using potentially stale cache data. This prevents state resets
* caused by publishing over a newer event with stale cached data.
*
* If the companion is legacy, it will be migrated first.
* If the companion is legacy, it checks for an existing canonical equivalent
* (by normalized name) before migrating. This prevents creating duplicate
* canonical events when interacting with a legacy Blobbi multiple times.
*
* Returns the canonical companion to use for the action.
*
* Flow:
* 1. Fetch fresh companion + profile from relays
* 2. Check if Blobbi is legacy
* 3. If legacy: migrate Blobbi
* 4. Return the resolved canonical Blobbi with fresh data
* 3. If legacy: look for existing canonical equivalent by name
* 4. If found: reuse it (no migration needed)
* 5. If not found: migrate to canonical format
* 6. Return the resolved canonical Blobbi with fresh data
*
* All interaction handlers should call this before publishing events.
*/
@@ -323,7 +330,7 @@ export function useBlobbiMigration() {
// Fetch fresh data from relays (read step of read-modify-write)
const [freshCompanion, freshProfile] = await Promise.all([
fetchFreshCompanion(user.pubkey, cachedCompanion.d),
fetchFreshProfile(user.pubkey),
fetchFreshBlobbonautProfile(nostr, user.pubkey),
]);
// Use fresh data, falling back to cached only if relay fetch returned nothing
@@ -332,7 +339,69 @@ export function useBlobbiMigration() {
// Check if the companion needs migration
if (companion.isLegacy) {
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
console.log('[Blobbi Migration] Legacy companion detected, checking for existing canonical equivalent');
// Check if a canonical equivalent already exists (by migrated_from tag,
// name+base_color, or name-only fallback). This prevents duplicate migrations
// when interacting with a legacy Blobbi that was already migrated.
const allCompanions = await fetchAllCompanions(user.pubkey);
const existing = findCanonicalEquivalent(companion, allCompanions);
if (existing) {
console.log('[Blobbi Migration] Found existing canonical equivalent:', existing.d, '— skipping migration');
// Update profile.has and current_companion to point to the canonical version
// (in case profile still references the legacy d-tag)
const hasLegacyInProfile = profile.has.includes(companion.d);
const hasCanonicalInProfile = profile.has.includes(existing.d);
if (hasLegacyInProfile || !hasCanonicalInProfile) {
const updatedHas = migratePetInHas(profile.has, companion.d, existing.d);
const profileUpdates: Record<string, string | string[]> = { has: updatedHas };
if (profile.currentCompanion === companion.d) {
profileUpdates.current_companion = existing.d;
}
const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: profile.event.content ?? '',
tags: profileTags,
prev: profile.event,
});
options.updateProfileEvent(profileEvent);
// Update localStorage selection if it was pointing to legacy d
if (options.updateStoredSelectedD) {
options.updateStoredSelectedD(existing.d);
}
// Update the canonical companion in query cache
options.updateCompanionEvent(existing.event);
return {
wasMigrated: false,
companion: existing,
allTags: existing.allTags,
content: existing.event.content,
profileAllTags: profileTags,
profileEvent,
profileStorage: parseStorageTags(profileTags),
};
}
// Profile is already correct, just return the existing canonical companion
return {
wasMigrated: false,
companion: existing,
allTags: existing.allTags,
content: existing.event.content,
profileAllTags: profile.allTags,
profileEvent: profile.event,
profileStorage: profile.storage,
};
}
console.log('[Blobbi Migration] No canonical equivalent found, migrating');
// Use fresh data in migration options
const migrationOptions = { ...options, companion, profile };
@@ -367,7 +436,7 @@ export function useBlobbiMigration() {
profileEvent: profile.event,
profileStorage: profile.storage,
};
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
}, [user?.pubkey, nostr, fetchFreshCompanion, fetchAllCompanions, migrateLegacyBlobbi, publishEvent]);
return {
/** Migrate a legacy Blobbi to canonical format */
@@ -0,0 +1,116 @@
/**
* Seed Identity Sync Hook
*
* Automatically republishes visible Blobbi companions whose persisted
* mirror tags (colors, pattern, size, adult_type) don't match the
* seed-derived canonical values.
*
* Runs once per companion list change, only republishes on actual mismatch.
* Uses fetchFreshEvent before each publish to avoid stale-read overwrites
* (matches the project convention for replaceable event mutations).
*/
import { useEffect, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
type BlobbiCompanion,
} from '../lib/blobbi';
/**
* For each visible companion that has needsSeedIdentitySync === true,
* republish it with an `updateBlobbiTags` call that includes the
* companion's (possibly adjusted) seed. The merge pipeline's
* syncMirrorTagsToSeed will overwrite all stale mirror tags.
*
* Skips companions that are legacy (handled by migration) or have
* no seed (nothing to sync to).
*/
export function useSeedIdentitySync(
companions: BlobbiCompanion[],
updateCompanionEvent: (event: NostrEvent) => void,
): void {
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
// Track which d-tags we've already synced in this session to avoid loops.
const syncedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (companions.length === 0) return;
const toSync = companions.filter(
(c) => c.needsSeedIdentitySync && !c.isLegacy && c.seed && !syncedRef.current.has(c.d),
);
if (toSync.length === 0) return;
// Mark as synced immediately to prevent re-entry on re-render
for (const c of toSync) {
syncedRef.current.add(c.d);
}
// Abort flag: set by the cleanup function when the effect is torn down
// (component unmount or dependency change). The async loop checks this
// before each publish and before calling updateCompanionEvent.
let aborted = false;
// Process sequentially to avoid relay rate-limiting
(async () => {
for (const c of toSync) {
if (aborted) break;
try {
// Fetch the freshest version from relays to avoid stale overwrites
// (another device may have updated the event since our cache was populated).
const prev = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBI_STATE],
authors: [c.event.pubkey],
'#d': [c.d],
});
if (aborted) break;
if (!prev) {
if (import.meta.env.DEV) {
console.warn('[SeedSync] No fresh event found for', c.d.slice(0, 20) + '...');
}
continue;
}
// Include the (possibly adjusted) seed in updates so that
// syncMirrorTagsToSeed reads the correct seed value.
const newTags = updateBlobbiTags(prev.tags, { seed: c.seed! });
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: prev.content,
tags: newTags,
prev,
});
if (aborted) break;
updateCompanionEvent(event);
if (import.meta.env.DEV) {
console.log('[SeedSync] Synced mirror tags for', c.d.slice(0, 20) + '...');
}
} catch (err) {
if (aborted) break;
console.warn('[SeedSync] Failed to sync', c.d.slice(0, 20) + '...', err);
// Remove from synced set so it can be retried next render
syncedRef.current.delete(c.d);
}
}
})();
return () => {
aborted = true;
};
}, [companions, nostr, publishEvent, updateCompanionEvent]);
}
+335
View File
@@ -0,0 +1,335 @@
import { describe, it, expect } from 'vitest';
import { applyBlobbiDecay } from './blobbi-decay';
import type { BlobbiStats } from './blobbi';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const FULL_STATS: BlobbiStats = { hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100 };
/** Create a DecayInput with sensible defaults. */
function decay(overrides: {
stage?: 'egg' | 'baby' | 'adult';
state?: 'active' | 'sleeping' | 'hibernating';
stats?: Partial<BlobbiStats>;
elapsedSeconds: number;
}) {
const now = 1_000_000;
return applyBlobbiDecay({
stage: overrides.stage ?? 'baby',
state: overrides.state ?? 'active',
stats: overrides.stats ?? FULL_STATS,
lastDecayAt: now - overrides.elapsedSeconds,
now,
});
}
// ─── Baby awake ───────────────────────────────────────────────────────────────
describe('baby awake decay', () => {
it('1 hour from full stats: decays at tuned rates', () => {
const r = decay({ stage: 'baby', state: 'active', elapsedSeconds: 3600 });
// hunger: 100 + trunc(-8) = 92
// happiness: 100 + trunc(-4.5) = 96
// hygiene: 100 + trunc(-6) = 94
// energy: 100 + trunc(-9) = 91
expect(r.stats.hunger).toBe(92);
expect(r.stats.happiness).toBe(96);
expect(r.stats.hygiene).toBe(94);
expect(r.stats.energy).toBe(91);
});
it('1 hour from full: health regens (all stats after decay ≥ 76)', () => {
const r = decay({ stage: 'baby', state: 'active', elapsedSeconds: 3600 });
// After deltas: hunger=92, happiness=96, hygiene=94, energy=91 — all ≥ 76.
// healthDelta = -0.4 + 1.5 = 1.1, trunc(1.1) = 1. health = 101 → 100.
expect(r.stats.health).toBe(100);
});
});
// ─── Baby health penalty alignment ───────────────────────────────────────────
describe('baby health penalties aligned to segment boundaries', () => {
it('stats in "okay" range (60): no penalties, health barely decays', () => {
const r = decay({
stage: 'baby',
state: 'active',
stats: { hunger: 60, happiness: 100, health: 100, hygiene: 60, energy: 100 },
elapsedSeconds: 3600,
});
// hunger after: 60 + trunc(-8) = 52 (still ≥ 50, no penalty)
// hygiene after: 60 + trunc(-6) = 54 (still ≥ 50, no penalty)
// happiness after: 96, energy after: 91. Neither < 50.
// healthDelta = -0.4 (base only), no penalties, no regen (hunger 52 < 76).
// trunc(-0.4) = 0 → health stays 100.
expect(r.stats.health).toBe(100);
expect(r.stats.hunger).toBe(52);
expect(r.stats.hygiene).toBe(54);
});
it('stats in "attention" range (40): mild penalties apply', () => {
const r = decay({
stage: 'baby',
state: 'active',
stats: { hunger: 40, happiness: 100, health: 100, hygiene: 40, energy: 100 },
elapsedSeconds: 3600,
});
// hunger after: 40 + trunc(-8) = 32 (≤ 50, mild penalty)
// hygiene after: 40 + trunc(-6) = 34 (≤ 50, mild penalty)
// healthDelta = -0.4 (base) + -0.5 (hunger≤50) + -0.5 (hygiene≤50) = -1.4
// trunc(-1.4) = -1 → health = 99.
expect(r.stats.health).toBe(99);
});
it('stats in "urgent" range (15): strong penalties stack', () => {
const r = decay({
stage: 'baby',
state: 'active',
stats: { hunger: 15, happiness: 100, health: 100, hygiene: 15, energy: 100 },
elapsedSeconds: 3600,
});
// hunger after: 15 + trunc(-8) = 7 (≤ 50 + ≤ 25)
// hygiene after: 15 + trunc(-6) = 9 (≤ 50 + ≤ 25)
// healthDelta = -0.4 + -0.5 + -1.0 + -0.5 + -1.0 = -3.4
// trunc(-3.4) = -3 → health = 97.
expect(r.stats.health).toBe(97);
});
it('penalty fires at exact boundary (hunger decays to exactly 50)', () => {
const r = decay({
stage: 'baby',
state: 'active',
// hunger 90 → 90 + trunc(-8*5) = 50. Exactly at attention boundary.
stats: { hunger: 90, happiness: 100, health: 100, hygiene: 100, energy: 100 },
elapsedSeconds: 3600 * 5,
});
// hunger after = 50. careState "attention" starts at ≤ 50, penalty must fire.
// happiness: 100 + trunc(-4.5*5) = 78. hygiene: 100 + trunc(-6*5) = 70. energy: 100 + trunc(-9*5) = 55.
// No other stat ≤ 50 → only hunger penalty.
// healthDelta = -0.4*5 + -0.5*5 = -4.5, trunc(-4.5) = -4. health = 96.
expect(r.stats.hunger).toBe(50);
expect(r.stats.health).toBe(96);
});
it('all stats urgent: maximum penalty pressure', () => {
const r = decay({
stage: 'baby',
state: 'active',
stats: { hunger: 10, happiness: 10, health: 80, hygiene: 10, energy: 10 },
elapsedSeconds: 3600,
});
// After deltas: hunger=2, happiness=6, hygiene=4, energy=1.
// All four stats ≤ 50 AND ≤ 25 → all mild + strong penalties fire.
// healthDelta = -0.4 (base)
// + 4 × -0.5 (mild) + 4 × -1.0 (strong) = -0.4 - 2.0 - 4.0 = -6.4
// trunc(-6.4) = -6 → health = 80 - 6 = 74.
expect(r.stats.health).toBe(74);
});
});
// ─── Baby health regen threshold ──────────────────────────────────────────────
describe('baby health regen threshold (≥ 76)', () => {
it('all stats = 85: regens (after decay all ≥ 76)', () => {
const r = decay({
stage: 'baby',
state: 'active',
stats: { hunger: 85, happiness: 85, health: 85, hygiene: 85, energy: 85 },
elapsedSeconds: 3600,
});
// After deltas: hunger=77, happiness=81, hygiene=79, energy=76 — all ≥ 76.
// healthDelta = -0.4 + 1.5 = 1.1, trunc(1.1) = 1.
expect(r.stats.health).toBe(86);
});
it('all stats = 76: does NOT regen (after decay < 76)', () => {
const r = decay({
stage: 'baby',
state: 'active',
stats: { hunger: 76, happiness: 76, health: 76, hygiene: 76, energy: 76 },
elapsedSeconds: 3600,
});
// After deltas: hunger=68, happiness=72, hygiene=70, energy=67 — NOT all ≥ 76.
// healthDelta = -0.4, no regen. trunc(-0.4) = 0 → health stays 76.
expect(r.stats.health).toBe(76);
});
});
// ─── Adult awake ──────────────────────────────────────────────────────────────
describe('adult awake decay', () => {
it('1 hour from full stats: decays at tuned rates', () => {
const r = decay({ stage: 'adult', state: 'active', elapsedSeconds: 3600 });
// hunger: 100 + trunc(-5) = 95
// happiness: 100 + trunc(-2.5) = 98
// hygiene: 100 + trunc(-4) = 96
// energy: 100 + trunc(-5.5) = 95
expect(r.stats.hunger).toBe(95);
expect(r.stats.happiness).toBe(98);
expect(r.stats.hygiene).toBe(96);
expect(r.stats.energy).toBe(95);
});
it('1 hour from full: health stays at 100 (regen cancels base)', () => {
const r = decay({ stage: 'adult', state: 'active', elapsedSeconds: 3600 });
// After deltas all ≥ 80. healthDelta = -0.25 + 1.0 = 0.75. trunc(0.75) = 0.
expect(r.stats.health).toBe(100);
});
it('adult penalty thresholds unchanged: hunger < 60 triggers mild', () => {
const r = decay({
stage: 'adult',
state: 'active',
stats: { hunger: 55, happiness: 100, health: 100, hygiene: 100, energy: 100 },
elapsedSeconds: 3600,
});
// hunger after: 55 + trunc(-5) = 50 (< 60, mild penalty fires)
// healthDelta = -0.25 + -0.5 = -0.75, trunc = 0 → health stays 100.
expect(r.stats.health).toBe(100);
expect(r.stats.hunger).toBe(50);
});
});
// ─── Baby sleeping ────────────────────────────────────────────────────────────
describe('baby sleeping decay', () => {
it('1 hour: energy stays capped at 100', () => {
const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 });
expect(r.stats.energy).toBe(100);
});
it('1 hour: hunger decays only 20% of awake rate', () => {
// Awake hunger rate = -8.0/hr → sleeping = -8.0 * 0.2 = -1.6/hr
// trunc(-1.6) = -1 → 100 - 1 = 99
const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 });
expect(r.stats.hunger).toBe(99);
});
it('1 hour: happiness does not decay (rate too small to truncate)', () => {
// Awake happiness rate = -4.5/hr → sleeping = -0.9/hr → trunc(-0.9) = 0
const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 });
expect(r.stats.happiness).toBe(100);
});
it('1 hour: hygiene decays only 20% of awake rate', () => {
// Awake hygiene rate = -6.0/hr → sleeping = -1.2/hr → trunc(-1.2) = -1
const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 });
expect(r.stats.hygiene).toBe(99);
});
it('1 hour: health base does not decay when stats are healthy', () => {
const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 3600 });
// After deltas: hunger=99, happiness=100, hygiene=99, energy=100 — all ≥ 76
// Base health = 0 (sleeping), regen = trunc(1.5) = 1. 100 + 1 → clamped to 100.
expect(r.stats.health).toBe(100);
});
it('30 minutes: stats barely change due to Math.trunc', () => {
const r = decay({ stage: 'baby', state: 'sleeping', elapsedSeconds: 1800 });
// hunger trunc(-8*0.2*0.5) = trunc(-0.8) = 0 → 100
// happiness trunc(-4.5*0.2*0.5) = trunc(-0.45) = 0 → 100
// hygiene trunc(-6*0.2*0.5) = trunc(-0.6) = 0 → 100
// energy trunc(40*0.5) = 20 → stays 100
expect(r.stats.hunger).toBe(100);
expect(r.stats.happiness).toBe(100);
expect(r.stats.hygiene).toBe(100);
expect(r.stats.energy).toBe(100);
});
it('energy recovers from low value at +40/hr', () => {
const r = decay({ stage: 'baby', state: 'sleeping', stats: { ...FULL_STATS, energy: 20 }, elapsedSeconds: 3600 });
// 20 + trunc(40*1) = 60
expect(r.stats.energy).toBe(60);
});
it('sleeping with very low stats: health penalties at 25% strength', () => {
const r = decay({
stage: 'baby',
state: 'sleeping',
stats: { hunger: 20, happiness: 50, health: 50, hygiene: 20, energy: 50 },
elapsedSeconds: 3600,
});
// hunger after: 20 + trunc(-8*0.2) = 20 + trunc(-1.6) = 19
// hygiene after: 20 + trunc(-6*0.2) = 20 + trunc(-1.2) = 19
// happiness after: 50 + trunc(-4.5*0.2) = 50 + trunc(-0.9) = 50
// energy after: 50 + trunc(40) = 90
//
// healthDelta = 0 (base sleeping)
// hunger 19 < 50: -0.5*0.25 = -0.125
// hunger 19 < 25: -1.0*0.25 = -0.25
// hygiene 19 < 50: -0.5*0.25 = -0.125
// hygiene 19 < 25: -1.0*0.25 = -0.25
// energy 90 ≥ 50 → 0
// happiness 50 ≥ 50 → 0
// total = -0.75, trunc(-0.75) = 0 → health stays 50
expect(r.stats.health).toBe(50);
expect(r.stats.hunger).toBe(19);
expect(r.stats.hygiene).toBe(19);
});
});
// ─── Adult sleeping ───────────────────────────────────────────────────────────
describe('adult sleeping decay', () => {
it('1 hour: energy stays capped at 100', () => {
const r = decay({ stage: 'adult', state: 'sleeping', elapsedSeconds: 3600 });
expect(r.stats.energy).toBe(100);
});
it('1 hour from full stats: hunger decays slightly, happiness/hygiene unchanged', () => {
// hunger: trunc(-5.0*0.2) = trunc(-1.0) = -1 → 99
// happiness: trunc(-2.5*0.2) = trunc(-0.5) = 0 → 100
// hygiene: trunc(-4.0*0.2) = trunc(-0.8) = 0 → 100
const r = decay({ stage: 'adult', state: 'sleeping', elapsedSeconds: 3600 });
expect(r.stats.hunger).toBe(99);
expect(r.stats.happiness).toBe(100);
expect(r.stats.hygiene).toBe(100);
});
it('1 hour: health base does not decay when stats are healthy', () => {
const r = decay({ stage: 'adult', state: 'sleeping', elapsedSeconds: 3600 });
expect(r.stats.health).toBe(100);
});
it('energy recovers from low value at +35/hr', () => {
const r = decay({ stage: 'adult', state: 'sleeping', stats: { ...FULL_STATS, energy: 10 }, elapsedSeconds: 3600 });
// 10 + trunc(35*1) = 45
expect(r.stats.energy).toBe(45);
});
it('sleeping with very low stats: health penalties at 25% strength', () => {
const r = decay({
stage: 'adult',
state: 'sleeping',
stats: { hunger: 15, happiness: 15, health: 50, hygiene: 15, energy: 10 },
elapsedSeconds: 3600,
});
// hunger after: 15 + trunc(-5*0.2) = 15 + trunc(-1.0) = 14
// happiness after: 15 + trunc(-2.5*0.2) = 15 + trunc(-0.5) = 15
// hygiene after: 15 + trunc(-4*0.2) = 15 + trunc(-0.8) = 15
// energy after: 10 + trunc(35) = 45
//
// healthDelta = 0 (base sleeping)
// hunger 14 < 60: -0.5*0.25 = -0.125
// hunger 14 < 30: -1.0*0.25 = -0.25
// hygiene 15 < 60: -0.5*0.25 = -0.125
// hygiene 15 < 30: -1.0*0.25 = -0.25
// energy 45 ≥ 40 → 0
// happiness 15 < 40: -0.4*0.25 = -0.1
// happiness 15 < 20: -0.8*0.25 = -0.2
// total = -1.05, trunc(-1.05) = -1
expect(r.stats.health).toBe(49);
expect(r.stats.hunger).toBe(14);
});
});
// ─── Hibernating is not affected ──────────────────────────────────────────────
describe('hibernating is not treated as sleeping', () => {
it('baby hibernating uses awake decay rates', () => {
const r = decay({ stage: 'baby', state: 'hibernating', elapsedSeconds: 3600 });
// Same as awake — energy uses awake rate (-9), not sleep regen
expect(r.stats.energy).toBe(91);
expect(r.stats.hunger).toBe(92);
});
});
+93 -54
View File
@@ -52,48 +52,56 @@ export interface DecayInput {
/**
* Baby stage decay rates (per hour).
*
* Design goal: Needs attention every 3-5 hours.
*
* Design goal: First stat (energy) drops to "okay" (3/4) around 2.7 hours,
* first "attention" (2/4) around 5-6 hours. Simpler than adult, needs care
* sooner but not punitively.
*
* Health penalty thresholds are aligned to baby segment boundaries:
* attention = value ≤ 50, urgent = value ≤ 25.
* Penalties only begin at "attention" — no silent health drain while UI
* still shows "okay".
*/
const BABY_DECAY = {
hunger: -7.0,
happiness: -4.0,
hygiene: -5.0,
energy: {
awake: -8.0,
sleeping: 6.0, // Regeneration
},
hunger: -8.0,
happiness: -4.5,
hygiene: -6.0,
energy: -9.0,
health: {
base: -0.75,
hungerBelow70: -0.75,
hungerBelow40: -1.25,
hygieneBelow70: -0.75,
hygieneBelow40: -1.25,
base: -0.4,
// Tier 1: mild — stat in attention range (≤ 50)
hungerBelow50: -0.5,
hygieneBelow50: -0.5,
energyBelow50: -0.5,
energyBelow25: -1.0,
happinessBelow50: -0.5,
// Tier 2: strong — stat in urgent range (≤ 25)
hungerBelow25: -1.0,
hygieneBelow25: -1.0,
energyBelow25: -1.0,
happinessBelow25: -1.0,
// Regeneration when all stats are >= 80
regenThreshold: 80,
// Regeneration when all stats are in "good" range (4/4 = value ≥ 76)
regenThreshold: 76,
regenRate: 1.5,
},
} as const;
/**
* Adult stage decay rates (per hour).
*
* Design goal: Needs attention every 5-7 hours.
*
* Design goal: First stat (energy) drops to "okay" (7/10) around 5-6 hours,
* first "attention" (6/10) around 7-8 hours. More resilient than baby — growing
* up should feel like a reward, not more annoyance.
*
* Adult penalty thresholds were already close to segment boundaries and
* are left unchanged.
*/
const ADULT_DECAY = {
hunger: -4.5,
hunger: -5.0,
happiness: -2.5,
hygiene: -3.5,
energy: {
awake: -5.0,
sleeping: 5.0, // Regeneration
},
hygiene: -4.0,
energy: -5.5,
health: {
base: -0.4,
base: -0.25,
hungerBelow60: -0.5,
hungerBelow30: -1.0,
hygieneBelow60: -0.5,
@@ -108,6 +116,27 @@ const ADULT_DECAY = {
},
} as const;
// ─── Constants: Sleep Modifiers ───────────────────────────────────────────────
/**
* Sleep modifiers — applied when state === 'sleeping'.
*
* Design goal: Sleep should feel restorative/protective, not punitive.
* Energy recovers fast, other stats decay very slowly, and health is sheltered.
*/
/** Fraction of awake hunger/happiness/hygiene decay applied while sleeping. */
const SLEEP_STAT_DECAY_FRACTION = 0.2;
/** Fraction of awake health penalties applied while sleeping. */
const SLEEP_HEALTH_PENALTY_FRACTION = 0.25;
/** Baby energy regen rate while sleeping (per hour). */
const BABY_SLEEP_ENERGY_REGEN = 40.0;
/** Adult energy regen rate while sleeping (per hour). */
const ADULT_SLEEP_ENERGY_REGEN = 35.0;
// ─── Constants: Warning Thresholds ────────────────────────────────────────────
/**
@@ -236,6 +265,10 @@ function calculateBabyDecay(
elapsedHours: number
): BlobbiStats {
const isSleeping = state === 'sleeping';
// Sleep modifiers: reduce stat drain, boost energy regen, shelter health.
const statMul = isSleeping ? SLEEP_STAT_DECAY_FRACTION : 1;
const penaltyMul = isSleeping ? SLEEP_HEALTH_PENALTY_FRACTION : 1;
// Get current values
let hunger = getStat(stats, 'hunger');
@@ -245,10 +278,10 @@ function calculateBabyDecay(
let health = getStat(stats, 'health');
// Calculate basic stat decay/regen
const hungerDelta = BABY_DECAY.hunger * elapsedHours;
const happinessDelta = BABY_DECAY.happiness * elapsedHours;
const hygieneDelta = BABY_DECAY.hygiene * elapsedHours;
const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours;
const hungerDelta = BABY_DECAY.hunger * statMul * elapsedHours;
const happinessDelta = BABY_DECAY.happiness * statMul * elapsedHours;
const hygieneDelta = BABY_DECAY.hygiene * statMul * elapsedHours;
const energyDelta = (isSleeping ? BABY_SLEEP_ENERGY_REGEN : BABY_DECAY.energy) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + roundDelta(hungerDelta));
@@ -257,25 +290,26 @@ function calculateBabyDecay(
energy = clamp(energy + roundDelta(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = BABY_DECAY.health.base * elapsedHours;
// Base health decay is 0 while sleeping.
let healthDelta = isSleeping ? 0 : BABY_DECAY.health.base * elapsedHours;
// Hunger penalties
if (hunger < 70) healthDelta += BABY_DECAY.health.hungerBelow70 * elapsedHours;
if (hunger < 40) healthDelta += BABY_DECAY.health.hungerBelow40 * elapsedHours;
// Hunger penalties (aligned to baby segment boundaries: attention ≤ 50, urgent ≤ 25)
if (hunger <= 50) healthDelta += BABY_DECAY.health.hungerBelow50 * penaltyMul * elapsedHours;
if (hunger <= 25) healthDelta += BABY_DECAY.health.hungerBelow25 * penaltyMul * elapsedHours;
// Hygiene penalties
if (hygiene < 70) healthDelta += BABY_DECAY.health.hygieneBelow70 * elapsedHours;
if (hygiene < 40) healthDelta += BABY_DECAY.health.hygieneBelow40 * elapsedHours;
if (hygiene <= 50) healthDelta += BABY_DECAY.health.hygieneBelow50 * penaltyMul * elapsedHours;
if (hygiene <= 25) healthDelta += BABY_DECAY.health.hygieneBelow25 * penaltyMul * elapsedHours;
// Energy penalties
if (energy < 50) healthDelta += BABY_DECAY.health.energyBelow50 * elapsedHours;
if (energy < 25) healthDelta += BABY_DECAY.health.energyBelow25 * elapsedHours;
if (energy <= 50) healthDelta += BABY_DECAY.health.energyBelow50 * penaltyMul * elapsedHours;
if (energy <= 25) healthDelta += BABY_DECAY.health.energyBelow25 * penaltyMul * elapsedHours;
// Happiness penalties
if (happiness < 50) healthDelta += BABY_DECAY.health.happinessBelow50 * elapsedHours;
if (happiness < 25) healthDelta += BABY_DECAY.health.happinessBelow25 * elapsedHours;
if (happiness <= 50) healthDelta += BABY_DECAY.health.happinessBelow50 * penaltyMul * elapsedHours;
if (happiness <= 25) healthDelta += BABY_DECAY.health.happinessBelow25 * penaltyMul * elapsedHours;
// Health regeneration (all stats >= 80)
// Health regeneration (all stats in "good" range: 4/4 = value ≥ 76)
const threshold = BABY_DECAY.health.regenThreshold;
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
healthDelta += BABY_DECAY.health.regenRate * elapsedHours;
@@ -295,6 +329,10 @@ function calculateAdultDecay(
elapsedHours: number
): BlobbiStats {
const isSleeping = state === 'sleeping';
// Sleep modifiers: reduce stat drain, boost energy regen, shelter health.
const statMul = isSleeping ? SLEEP_STAT_DECAY_FRACTION : 1;
const penaltyMul = isSleeping ? SLEEP_HEALTH_PENALTY_FRACTION : 1;
// Get current values
let hunger = getStat(stats, 'hunger');
@@ -304,10 +342,10 @@ function calculateAdultDecay(
let health = getStat(stats, 'health');
// Calculate basic stat decay/regen
const hungerDelta = ADULT_DECAY.hunger * elapsedHours;
const happinessDelta = ADULT_DECAY.happiness * elapsedHours;
const hygieneDelta = ADULT_DECAY.hygiene * elapsedHours;
const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours;
const hungerDelta = ADULT_DECAY.hunger * statMul * elapsedHours;
const happinessDelta = ADULT_DECAY.happiness * statMul * elapsedHours;
const hygieneDelta = ADULT_DECAY.hygiene * statMul * elapsedHours;
const energyDelta = (isSleeping ? ADULT_SLEEP_ENERGY_REGEN : ADULT_DECAY.energy) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + roundDelta(hungerDelta));
@@ -316,23 +354,24 @@ function calculateAdultDecay(
energy = clamp(energy + roundDelta(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = ADULT_DECAY.health.base * elapsedHours;
// Base health decay is 0 while sleeping.
let healthDelta = isSleeping ? 0 : ADULT_DECAY.health.base * elapsedHours;
// Hunger penalties
if (hunger < 60) healthDelta += ADULT_DECAY.health.hungerBelow60 * elapsedHours;
if (hunger < 30) healthDelta += ADULT_DECAY.health.hungerBelow30 * elapsedHours;
if (hunger < 60) healthDelta += ADULT_DECAY.health.hungerBelow60 * penaltyMul * elapsedHours;
if (hunger < 30) healthDelta += ADULT_DECAY.health.hungerBelow30 * penaltyMul * elapsedHours;
// Hygiene penalties
if (hygiene < 60) healthDelta += ADULT_DECAY.health.hygieneBelow60 * elapsedHours;
if (hygiene < 30) healthDelta += ADULT_DECAY.health.hygieneBelow30 * elapsedHours;
if (hygiene < 60) healthDelta += ADULT_DECAY.health.hygieneBelow60 * penaltyMul * elapsedHours;
if (hygiene < 30) healthDelta += ADULT_DECAY.health.hygieneBelow30 * penaltyMul * elapsedHours;
// Energy penalties
if (energy < 40) healthDelta += ADULT_DECAY.health.energyBelow40 * elapsedHours;
if (energy < 20) healthDelta += ADULT_DECAY.health.energyBelow20 * elapsedHours;
if (energy < 40) healthDelta += ADULT_DECAY.health.energyBelow40 * penaltyMul * elapsedHours;
if (energy < 20) healthDelta += ADULT_DECAY.health.energyBelow20 * penaltyMul * elapsedHours;
// Happiness penalties
if (happiness < 40) healthDelta += ADULT_DECAY.health.happinessBelow40 * elapsedHours;
if (happiness < 20) healthDelta += ADULT_DECAY.health.happinessBelow20 * elapsedHours;
if (happiness < 40) healthDelta += ADULT_DECAY.health.happinessBelow40 * penaltyMul * elapsedHours;
if (happiness < 20) healthDelta += ADULT_DECAY.health.happinessBelow20 * penaltyMul * elapsedHours;
// Health regeneration (all stats >= 80)
const threshold = ADULT_DECAY.health.regenThreshold;
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect } from 'vitest';
import { getBlobbiStatDisplayState } from './blobbi-segments';
import type { CareState, StatDisplayState } from './blobbi-segments';
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Shorthand to call the helper with a given stage + value (stat doesn't affect logic). */
function get(stage: 'egg' | 'baby' | 'adult', value: number): StatDisplayState {
return getBlobbiStatDisplayState({ stage, stat: 'hunger', value });
}
/** Assert care-state and all derived flags in one call. */
function expectCareState(
result: StatDisplayState,
careState: CareState,
flags: { badge: boolean; pulse: boolean; low: boolean; urgent: boolean },
) {
expect(result.careState).toBe(careState);
expect(result.shouldShowBadge).toBe(flags.badge);
expect(result.shouldPulse).toBe(flags.pulse);
expect(result.isLow).toBe(flags.low);
expect(result.isUrgent).toBe(flags.urgent);
}
// ─── Egg ──────────────────────────────────────────────────────────────────────
describe('egg stage', () => {
it.each([1, 50, 100])('value %i → protected, full segments, no flags', (value) => {
const r = get('egg', value);
expect(r.careState).toBe('protected');
expect(r.filled).toBe(r.max);
expectCareState(r, 'protected', { badge: false, pulse: false, low: false, urgent: false });
});
it('uses 4 as max segments (baby visual)', () => {
expect(get('egg', 50).max).toBe(4);
expect(get('egg', 50).filled).toBe(4);
});
});
// ─── Baby boundaries ──────────────────────────────────────────────────────────
describe('baby stage', () => {
it('max is always 4', () => {
expect(get('baby', 50).max).toBe(4);
});
// urgent: 125 → 1/4
it('value 1 → 1/4 urgent', () => {
const r = get('baby', 1);
expect(r.filled).toBe(1);
expectCareState(r, 'urgent', { badge: true, pulse: true, low: true, urgent: true });
});
it('value 25 → 1/4 urgent', () => {
const r = get('baby', 25);
expect(r.filled).toBe(1);
expectCareState(r, 'urgent', { badge: true, pulse: true, low: true, urgent: true });
});
// attention: 2650 → 2/4
it('value 26 → 2/4 attention', () => {
const r = get('baby', 26);
expect(r.filled).toBe(2);
expectCareState(r, 'attention', { badge: true, pulse: false, low: true, urgent: false });
});
it('value 50 → 2/4 attention', () => {
const r = get('baby', 50);
expect(r.filled).toBe(2);
expectCareState(r, 'attention', { badge: true, pulse: false, low: true, urgent: false });
});
// okay: 5175 → 3/4
it('value 51 → 3/4 okay', () => {
const r = get('baby', 51);
expect(r.filled).toBe(3);
expectCareState(r, 'okay', { badge: false, pulse: false, low: false, urgent: false });
});
it('value 75 → 3/4 okay', () => {
const r = get('baby', 75);
expect(r.filled).toBe(3);
expectCareState(r, 'okay', { badge: false, pulse: false, low: false, urgent: false });
});
// good: 76100 → 4/4
it('value 76 → 4/4 good', () => {
const r = get('baby', 76);
expect(r.filled).toBe(4);
expectCareState(r, 'good', { badge: false, pulse: false, low: false, urgent: false });
});
it('value 100 → 4/4 good', () => {
const r = get('baby', 100);
expect(r.filled).toBe(4);
expectCareState(r, 'good', { badge: false, pulse: false, low: false, urgent: false });
});
});
// ─── Adult boundaries ─────────────────────────────────────────────────────────
describe('adult stage', () => {
it('max is always 10', () => {
expect(get('adult', 50).max).toBe(10);
});
// urgent: 130 → 13/10
it('value 1 → 1/10 urgent', () => {
const r = get('adult', 1);
expect(r.filled).toBe(1);
expectCareState(r, 'urgent', { badge: true, pulse: true, low: true, urgent: true });
});
it('value 10 → 1/10 urgent', () => {
const r = get('adult', 10);
expect(r.filled).toBe(1);
expectCareState(r, 'urgent', { badge: true, pulse: true, low: true, urgent: true });
});
it('value 11 → 2/10 urgent', () => {
const r = get('adult', 11);
expect(r.filled).toBe(2);
expectCareState(r, 'urgent', { badge: true, pulse: true, low: true, urgent: true });
});
it('value 30 → 3/10 urgent', () => {
const r = get('adult', 30);
expect(r.filled).toBe(3);
expectCareState(r, 'urgent', { badge: true, pulse: true, low: true, urgent: true });
});
// attention: 3160 → 46/10
it('value 31 → 4/10 attention', () => {
const r = get('adult', 31);
expect(r.filled).toBe(4);
expectCareState(r, 'attention', { badge: true, pulse: false, low: true, urgent: false });
});
it('value 60 → 6/10 attention', () => {
const r = get('adult', 60);
expect(r.filled).toBe(6);
expectCareState(r, 'attention', { badge: true, pulse: false, low: true, urgent: false });
});
// okay: 6170 → 7/10
it('value 61 → 7/10 okay', () => {
const r = get('adult', 61);
expect(r.filled).toBe(7);
expectCareState(r, 'okay', { badge: false, pulse: false, low: false, urgent: false });
});
it('value 70 → 7/10 okay', () => {
const r = get('adult', 70);
expect(r.filled).toBe(7);
expectCareState(r, 'okay', { badge: false, pulse: false, low: false, urgent: false });
});
// good: 71100 → 810/10
it('value 71 → 8/10 good', () => {
const r = get('adult', 71);
expect(r.filled).toBe(8);
expectCareState(r, 'good', { badge: false, pulse: false, low: false, urgent: false });
});
it('value 100 → 10/10 good', () => {
const r = get('adult', 100);
expect(r.filled).toBe(10);
expectCareState(r, 'good', { badge: false, pulse: false, low: false, urgent: false });
});
});
// ─── Clamping ─────────────────────────────────────────────────────────────────
describe('out-of-range clamping', () => {
it('clamps values below STAT_MIN (1) up to 1', () => {
const r = get('baby', -5);
expect(r.value).toBe(1);
expect(r.filled).toBe(1);
});
it('clamps value 0 up to 1', () => {
const r = get('adult', 0);
expect(r.value).toBe(1);
expect(r.filled).toBe(1);
});
it('clamps values above STAT_MAX (100) down to 100', () => {
const r = get('baby', 200);
expect(r.value).toBe(100);
expect(r.filled).toBe(4);
});
it('clamps extreme negative to 1 for egg (still protected)', () => {
const r = get('egg', -999);
expect(r.value).toBe(1);
expect(r.careState).toBe('protected');
expect(r.filled).toBe(r.max);
});
});
// ─── Flag correctness per care-state ──────────────────────────────────────────
describe('flag correctness', () => {
it('protected → no badge, no pulse, not low, not urgent', () => {
expectCareState(get('egg', 50), 'protected', { badge: false, pulse: false, low: false, urgent: false });
});
it('good → no badge, no pulse, not low, not urgent', () => {
expectCareState(get('baby', 100), 'good', { badge: false, pulse: false, low: false, urgent: false });
});
it('okay → no badge, no pulse, not low, not urgent', () => {
expectCareState(get('baby', 60), 'okay', { badge: false, pulse: false, low: false, urgent: false });
});
it('attention → badge, no pulse, low, not urgent', () => {
expectCareState(get('baby', 30), 'attention', { badge: true, pulse: false, low: true, urgent: false });
});
it('urgent → badge, pulse, low, urgent', () => {
expectCareState(get('baby', 10), 'urgent', { badge: true, pulse: true, low: true, urgent: true });
});
});
// ─── Stat key independence ────────────────────────────────────────────────────
describe('stat key does not affect logic', () => {
const stats = ['hunger', 'happiness', 'health', 'hygiene', 'energy'] as const;
it.each(stats)('stat "%s" produces same result for baby at value 50', (stat) => {
const r = getBlobbiStatDisplayState({ stage: 'baby', stat, value: 50 });
expect(r.careState).toBe('attention');
expect(r.filled).toBe(2);
});
});
+140
View File
@@ -0,0 +1,140 @@
// src/blobbi/core/lib/blobbi-segments.ts
//
// Pure helper that derives UI display state from internal 1100 stats.
// This does NOT change any gameplay behaviour — it is read-only interpretation.
import type { BlobbiStage, BlobbiStats } from './blobbi';
import { STAT_MIN, STAT_MAX } from './blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
export type CareState =
| 'protected'
| 'good'
| 'okay'
| 'attention'
| 'urgent';
export interface StatDisplayState {
/** Clamped internal value (STAT_MINSTAT_MAX). */
value: number;
/** Number of filled segments for the current stage. */
filled: number;
/** Maximum number of segments for the current stage. */
max: number;
/** Derived care state for badge / colour decisions. */
careState: CareState;
/** Whether a warning badge should be shown. */
shouldShowBadge: boolean;
/** Whether the indicator should pulse (urgent only). */
shouldPulse: boolean;
/** True when care state is attention or urgent. */
isLow: boolean;
/** True when care state is urgent only. */
isUrgent: boolean;
}
export interface StatDisplayInput {
stage: BlobbiStage;
stat: keyof BlobbiStats;
value: number;
}
// ─── Segment counts per stage ─────────────────────────────────────────────────
const BABY_SEGMENTS = 4;
const ADULT_SEGMENTS = 10;
// ─── Internal helpers ─────────────────────────────────────────────────────────
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Map a clamped 1100 value to 1maxSegments (never 0).
*
* Uses ceiling division so that the minimum clamped value (1) always
* yields at least 1 filled segment.
*/
function toSegments(value: number, maxSegments: number): number {
return Math.ceil((value / STAT_MAX) * maxSegments) || 1;
}
// ─── Baby care-state mapping (4 segments) ─────────────────────────────────────
function babyCareState(value: number): CareState {
if (value <= 25) return 'urgent';
if (value <= 50) return 'attention';
if (value <= 75) return 'okay';
return 'good';
}
// ─── Adult care-state mapping (10 segments) ───────────────────────────────────
function adultCareState(value: number): CareState {
if (value <= 30) return 'urgent';
if (value <= 60) return 'attention';
if (value <= 70) return 'okay';
return 'good';
}
// ─── Flag derivation ──────────────────────────────────────────────────────────
function deriveFlags(careState: CareState) {
const shouldShowBadge = careState === 'attention' || careState === 'urgent';
const shouldPulse = careState === 'urgent';
const isLow = careState === 'attention' || careState === 'urgent';
const isUrgent = careState === 'urgent';
return { shouldShowBadge, shouldPulse, isLow, isUrgent };
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Derive the UI display state for a single Blobbi stat.
*
* Internal stats remain 0100 (actually 1100 per STAT_MIN).
* This function only interprets them for display — it never mutates state.
*/
export function getBlobbiStatDisplayState(input: StatDisplayInput): StatDisplayState {
const clamped = clamp(input.value, STAT_MIN, STAT_MAX);
// ── Egg: always protected, always full ──────────────────────────────────
if (input.stage === 'egg') {
return {
value: clamped,
filled: BABY_SEGMENTS, // show as full (egg uses baby segment count visually)
max: BABY_SEGMENTS,
careState: 'protected',
shouldShowBadge: false,
shouldPulse: false,
isLow: false,
isUrgent: false,
};
}
// ── Baby: 4 segments ────────────────────────────────────────────────────
if (input.stage === 'baby') {
const filled = toSegments(clamped, BABY_SEGMENTS);
const careState = babyCareState(clamped);
return {
value: clamped,
filled,
max: BABY_SEGMENTS,
careState,
...deriveFlags(careState),
};
}
// ── Adult: 10 segments ──────────────────────────────────────────────────
const filled = toSegments(clamped, ADULT_SEGMENTS);
const careState = adultCareState(clamped);
return {
value: clamped,
filled,
max: ADULT_SEGMENTS,
careState,
...deriveFlags(careState),
};
}
+604 -77
View File
@@ -2,7 +2,12 @@ import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
import type { NostrEvent } from '@nostrify/nostrify';
import { ADULT_FORMS, type AdultForm, deriveAdultFormFromSeed } from '@/blobbi/adult-blobbi/types/adult.types';
import { validateAndRepairBlobbiTags } from './blobbi-tag-schema';
import { applyColorGuardrails, hexToHsl, hslToHex } from './color-guardrails';
import type { Mission } from './missions';
import { parseEvolutionContent } from './missions';
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -147,8 +152,10 @@ export type BlobbiSpecialMark = 'none' | 'star' | 'heart' | 'sparkle' | 'blush';
export type BlobbiSize = 'small' | 'medium' | 'large';
/**
* Base color palette - canonical hex values.
* These are carefully chosen to look good on egg shapes.
* @deprecated Legacy palette — no longer used for seed-based generation.
* Colors are now derived as arbitrary HSL values from the seed, then passed
* through applyColorGuardrails(). Kept only as a historical reference of
* colors that existing events may have stored in explicit tags.
*/
export const BLOBBI_BASE_COLORS: readonly string[] = [
'#F59E0B', // Amber/Gold
@@ -163,9 +170,7 @@ export const BLOBBI_BASE_COLORS: readonly string[] = [
'#FB923C', // Orange
] as const;
/**
* Secondary color palette - complementary/accent hex values.
*/
/** @deprecated See BLOBBI_BASE_COLORS. */
export const BLOBBI_SECONDARY_COLORS: readonly string[] = [
'#FCD34D', // Light Gold
'#6EE7B7', // Light Teal
@@ -179,9 +184,7 @@ export const BLOBBI_SECONDARY_COLORS: readonly string[] = [
'#FDBA74', // Light Orange
] as const;
/**
* Eye color palette - expressive hex values.
*/
/** @deprecated See BLOBBI_BASE_COLORS. */
export const BLOBBI_EYE_COLORS: readonly string[] = [
'#1F2937', // Dark Gray (default)
'#7C3AED', // Violet
@@ -258,6 +261,8 @@ export interface BlobbiCompanion {
visualTraits: BlobbiVisualTraits;
/** Whether this is a legacy event that needs migration */
isLegacy: boolean;
/** Whether stored mirror tags differ from seed-derived identity and need republishing */
needsSeedIdentitySync: boolean;
/** Timestamp of last user interaction (unix seconds) */
lastInteraction: number;
/** Timestamp used for stat decay checkpoint (unix seconds) */
@@ -300,6 +305,8 @@ export interface BlobbiCompanion {
tasks: BlobbiTaskProgress[];
/** Completed task names */
tasksCompleted: string[];
/** Evolution missions parsed from 31124 content JSON (per-Blobbi progression) */
evolution: Mission[];
/** All tags preserved for republishing */
allTags: string[][];
}
@@ -365,6 +372,25 @@ export function generatePetId10(): string {
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Derive a deterministic 10-character lowercase hex petId for legacy migration.
*
* The same (pubkey, legacyD) pair always produces the same petId, which means
* the resulting canonical d-tag is stable across devices and sessions. This
* makes the entire migration chain deterministic: petId → canonicalD → seed →
* visual traits.
*
* Formula: sha256("blobbi:migration:v1|" + pubkey + ":" + legacyD).slice(0, 10)
*
* Only used during legacy → canonical migration. New egg creation still uses
* the random generatePetId10().
*/
export function deriveMigrationPetId(pubkey: string, legacyD: string): string {
const input = `blobbi:migration:v1|${pubkey}:${legacyD}`;
const hashBytes = sha256(new TextEncoder().encode(input));
return bytesToHex(hashBytes).slice(0, 10);
}
/**
* Get the canonical d-tag for a Blobbi (Kind 31124).
* Format: blobbi-{ownerPubkeyPrefix12}-{petId10}
@@ -535,44 +561,126 @@ export function isLegacyBlobbiD(d: string): boolean {
// ─── Visual Trait Derivation ──────────────────────────────────────────────────
/**
* Derive a numeric value from a seed at a specific offset.
* Uses 4 bytes (8 hex chars) starting at offset to create a deterministic number.
*
* Seed offset layout (per spec):
* - [0..8] base_color
* - [8..16] secondary_color / eye_color
* - [0..8] base_color (H/S/L split from 32-bit value)
* - [8..16] secondary_color hue shift / lightness offset from base
* - [12..20] eye_color (H/S/L split from 32-bit value; overlaps secondary)
* - [16..24] pattern
* - [24..32] special_mark
* - [32..40] size
* - [40..48] adult_type
* - [48..64] reserved
*/
function deriveIndexFromSeed(seed: string, offset: number, max: number): number {
/**
* Read 8 hex chars from `seed` at `offset` and return the raw unsigned
* 32-bit integer (0 .. 0xFFFFFFFF).
*
* Returns 0 for empty/unparseable slices so callers never see NaN.
*/
function readSeedUint32(seed: string, offset: number): number {
const slice = seed.slice(offset, offset + 8);
const value = parseInt(slice, 16);
return Math.abs(value) % max;
return Number.isNaN(value) ? 0 : value;
}
/**
* Derive base color (hex) from seed.
* Derive a bounded index from a seed at a specific offset.
* Uses 4 bytes (8 hex chars) starting at offset, then maps to [0, max).
*
* Use this for selecting from small arrays (patterns, marks, sizes, forms).
* For raw 32-bit entropy that will be decomposed further (e.g. into H/S/L
* components via successive division), use readSeedUint32() directly.
*/
function deriveIndexFromSeed(seed: string, offset: number, max: number): number {
return readSeedUint32(seed, offset) % max;
}
/**
* Derive base color (hex) from seed using arbitrary HSL generation.
*
* Extracts a single 32-bit value from seed[0..8] and splits it into
* three components via successive division:
* - Hue: 0..359 (full color wheel)
* - Saturation: 30..100 (vibrant, never dull gray)
* - Lightness: 30..75 (safe range for the SVG gradient pipeline)
*
* The result is passed through clampBaseColor() via applyColorGuardrails()
* at the call site, but the ranges here are already chosen to land within
* the guardrail thresholds, so clamping is a safety net rather than a
* regular adjustment.
*/
export function deriveBaseColorFromSeed(seed: string): string {
const index = deriveIndexFromSeed(seed, 0, BLOBBI_BASE_COLORS.length);
return BLOBBI_BASE_COLORS[index];
const value = readSeedUint32(seed, 0);
const h = value % 360;
const rem1 = Math.floor(value / 360);
const s = (rem1 % 71) + 30; // 30..100
const rem2 = Math.floor(rem1 / 71);
const l = (rem2 % 46) + 30; // 30..75
return hslToHex(h, s, l);
}
/**
* Derive secondary color (hex) from seed.
* Derive secondary color (hex) from seed, harmonized with a base color.
*
* Instead of picking independently from a palette, the secondary is derived
* as a lighter variant of the base with a small hue shift:
* - Hue shift: ±20° from base (subtle tonal variation)
* - Lightness offset: +12..+25 above base (guaranteed visible gradient)
*
* This ensures the base/secondary pair always produces a good 3D body
* gradient regardless of the base color.
*
* @param seed - The Blobbi seed (64-char hex)
* @param baseHex - The already-resolved base color (after guardrails)
*/
export function deriveSecondaryColorFromSeed(seed: string): string {
const index = deriveIndexFromSeed(seed, 8, BLOBBI_SECONDARY_COLORS.length);
return BLOBBI_SECONDARY_COLORS[index];
export function deriveSecondaryColorFromSeed(seed: string, baseHex?: string): string {
const seedValue = readSeedUint32(seed, 8);
// Without a base color, fall back to independent HSL derivation
// (same approach as base, but with a lighter range)
if (!baseHex) {
const h = seedValue % 360;
const rem1 = Math.floor(seedValue / 360);
const s = (rem1 % 71) + 30;
const rem2 = Math.floor(rem1 / 71);
const l = (rem2 % 31) + 60; // 60..90 (lighter range)
return hslToHex(h, s, l);
}
// Harmonized derivation: shift from base
const baseHsl = hexToHsl(baseHex);
const hueShift = (seedValue % 41) - 20; // -20..+20 degrees
const rem1 = Math.floor(seedValue / 41);
const lOffset = (rem1 % 14) + 12; // +12..+25 lightness
const secH = (baseHsl.h + hueShift + 360) % 360;
const secS = baseHsl.s; // preserve base saturation for cohesion
const secL = Math.min(baseHsl.l + lOffset, 90); // cap to avoid near-white
return hslToHex(secH, secS, secL);
}
/**
* Derive eye color (hex) from seed.
* Derive eye color (hex) from seed using arbitrary HSL generation.
*
* Eyes are generated in a darker, more saturated range than base colors
* to ensure visibility against white sclera circles:
* - Hue: 0..359 (full color wheel, independent of base)
* - Saturation: 40..100 (vivid enough to read at small sizes)
* - Lightness: 10..55 (always darker than typical bases)
*
* The result is further validated by ensureEyeVisibility() via
* applyColorGuardrails() at the call site.
*/
export function deriveEyeColorFromSeed(seed: string): string {
const index = deriveIndexFromSeed(seed, 12, BLOBBI_EYE_COLORS.length);
return BLOBBI_EYE_COLORS[index];
const value = readSeedUint32(seed, 12);
const h = value % 360;
const rem1 = Math.floor(value / 360);
const s = (rem1 % 61) + 40; // 40..100
const rem2 = Math.floor(rem1 / 61);
const l = (rem2 % 46) + 10; // 10..55
return hslToHex(h, s, l);
}
/**
@@ -599,6 +707,56 @@ export function deriveSizeFromSeed(seed: string): BlobbiSize {
return BLOBBI_SIZES[index];
}
// ─── Temporary Adult-Type Compatibility ───────────────────────────────────────
//
// TEMPORARY: Seed adjustment for existing adult Blobbies whose stored adult_type
// does not match the seed-derived adult_type. During the compatibility window,
// we mutate the seed so it produces the stored adult_type, then recompute the
// full visual identity from the adjusted seed.
//
// After the cutoff date this code becomes a no-op and can be removed entirely.
//
// Cutoff: 2026-05-01 00:00:00 UTC
//
/** UTC timestamp when the compatibility window closes. */
const ADULT_TYPE_COMPAT_CUTOFF = Date.UTC(2026, 4, 1) / 1000; // 2026-05-01 00:00:00 UTC
/**
* Check whether the temporary adult-type compatibility window is still active.
*/
export function isAdultTypeCompatActive(): boolean {
return Math.floor(Date.now() / 1000) < ADULT_TYPE_COMPAT_CUTOFF;
}
/**
* Adjust a seed so that deriveAdultFormFromSeed(adjusted) === targetForm.
*
* Directly computes the seed bytes at offset [40..48] (the adult_type
* region) that produce the target form index. All other seed regions
* are left untouched, so colors [0..20] are preserved and non-color
* traits are re-derived from the adjusted seed via deriveSeedIdentity().
*
* Returns the original seed unchanged if it already produces targetForm.
*/
export function adjustSeedForAdultType(seed: string, targetForm: AdultForm): string {
// Fast path: already matches
if (deriveAdultFormFromSeed(seed) === targetForm) return seed;
const targetIndex = ADULT_FORMS.indexOf(targetForm);
if (targetIndex < 0) return seed; // unknown form — leave seed unchanged
const prefix = seed.slice(0, 40);
const suffix = seed.slice(48);
// Direct computation: deriveAdultFormFromSeed reads seed[40..48] as a
// hex integer and takes `% ADULT_FORMS.length`. So any 8-hex-char value
// whose parseInt % length === targetIndex works. The simplest is the
// target index itself (always < 16, which is < ADULT_FORMS.length).
const candidate = targetIndex.toString(16).padStart(8, '0');
return prefix + candidate + suffix;
}
/**
* Validate and normalize a pattern value from a tag.
* Returns undefined if invalid, allowing fallback to seed derivation.
@@ -648,16 +806,19 @@ function normalizeHexColor(value: string | undefined): string | undefined {
* ┌─────────────────────────────────────────────────────────────────────────────┐
* │ VISUAL TRAIT POLICY │
* │ │
* │ Trait resolution priority (per field):
* │ 1. Explicit valid tags → always take precedence if present
* │ 2. Derive from seed → primary source for canonical events
* │ 3. Safe defaults → final fallback when both tag and seed are missing
* │ Color resolution priority:
* │ 1. Seed present → colors ALWAYS come from seed + guardrails
* │ (explicit color tags are ignored; they are mirrors, not overrides)
* │ 2. No seed → explicit color tags used as-is (legacy fallback)
* │ 3. Neither → safe defaults │
* │ │
* │ Non-color traits (pattern, special_mark, size): │
* │ 1. Explicit valid tags take precedence │
* │ 2. Derive from seed if no tag present │
* │ 3. Safe defaults as final fallback │
* │ │
* │ IMPORTANT: Legacy events may have explicit tags WITHOUT a seed. │
* │ These tags must be respected - do NOT discard them in favor of defaults. │
* │ │
* │ New canonical events should rely on seed for visual derivation. │
* │ Legacy tags are preserved for backwards compatibility. │
* └─────────────────────────────────────────────────────────────────────────────┘
*
* This function is the SINGLE SOURCE OF TRUTH for visual trait resolution.
@@ -667,39 +828,53 @@ export function deriveVisualTraits(
tags: string[][],
seed: string | undefined
): BlobbiVisualTraits {
// Step 1: Extract and validate explicit tag values
// These always take precedence if present and valid
const hasSeed = seed && seed.length === 64;
// Seed is the canonical source of truth for the entire visual identity.
// When present, all visual trait tags are mirrors — not consulted for rendering.
if (hasSeed) {
return deriveSeedIdentity(seed);
}
// No seed (legacy): use explicit tags with defaults as final fallback.
const tagBaseColor = normalizeHexColor(getTagValue(tags, 'base_color'));
const tagSecondaryColor = normalizeHexColor(getTagValue(tags, 'secondary_color'));
const tagEyeColor = normalizeHexColor(getTagValue(tags, 'eye_color'));
const tagPattern = normalizePatternTag(getTagValue(tags, 'pattern'));
const tagSpecialMark = normalizeSpecialMarkTag(getTagValue(tags, 'special_mark'));
const tagSize = normalizeSizeTag(getTagValue(tags, 'size'));
// Step 2: Determine fallback values (seed-derived or defaults)
const hasSeed = seed && seed.length === 64;
// Resolve baseColor first (needed for secondaryColor fallback)
const fallbackBaseColor = hasSeed ? deriveBaseColorFromSeed(seed) : DEFAULT_VISUAL_TRAITS.baseColor;
const resolvedBaseColor = tagBaseColor ?? fallbackBaseColor;
// Secondary color: if no seed, fall back to resolved baseColor for unified palette
// This ensures legacy events with only base_color don't get a mismatched yellow accent
const fallbackSecondaryColor = hasSeed ? deriveSecondaryColorFromSeed(seed) : resolvedBaseColor;
const fallbackEyeColor = hasSeed ? deriveEyeColorFromSeed(seed) : DEFAULT_VISUAL_TRAITS.eyeColor;
const fallbackPattern = hasSeed ? derivePatternFromSeed(seed) : DEFAULT_VISUAL_TRAITS.pattern;
const fallbackSpecialMark = hasSeed ? deriveSpecialMarkFromSeed(seed) : DEFAULT_VISUAL_TRAITS.specialMark;
const fallbackSize = hasSeed ? deriveSizeFromSeed(seed) : DEFAULT_VISUAL_TRAITS.size;
// Step 3: Priority: explicit valid tag > fallback (seed-derived or default)
const resolvedBaseColor = tagBaseColor ?? DEFAULT_VISUAL_TRAITS.baseColor;
return {
baseColor: resolvedBaseColor,
secondaryColor: tagSecondaryColor ?? fallbackSecondaryColor,
eyeColor: tagEyeColor ?? fallbackEyeColor,
pattern: tagPattern ?? fallbackPattern,
specialMark: tagSpecialMark ?? fallbackSpecialMark,
size: tagSize ?? fallbackSize,
secondaryColor: tagSecondaryColor ?? resolvedBaseColor,
eyeColor: tagEyeColor ?? DEFAULT_VISUAL_TRAITS.eyeColor,
pattern: tagPattern ?? DEFAULT_VISUAL_TRAITS.pattern,
specialMark: tagSpecialMark ?? DEFAULT_VISUAL_TRAITS.specialMark,
size: tagSize ?? DEFAULT_VISUAL_TRAITS.size,
};
}
/**
* Derive the full seed-determined visual identity.
*
* This is the single function that turns a 64-char hex seed into the
* authoritative set of visual traits (colors + pattern + mark + size)
* with color guardrails applied. All call sites that need seed-derived
* visual traits should use this to guarantee consistency.
*/
export function deriveSeedIdentity(seed: string): BlobbiVisualTraits {
const rawBase = deriveBaseColorFromSeed(seed);
const rawEye = deriveEyeColorFromSeed(seed);
const colors = applyColorGuardrails({
baseColor: rawBase,
secondaryColor: deriveSecondaryColorFromSeed(seed, rawBase),
eyeColor: rawEye,
});
return {
...colors,
pattern: derivePatternFromSeed(seed),
specialMark: deriveSpecialMarkFromSeed(seed),
size: deriveSizeFromSeed(seed),
};
}
@@ -768,6 +943,54 @@ export function companionNeedsMigration(companion: BlobbiCompanion): boolean {
return companion.isLegacy;
}
/**
* Check whether an event's stored color tags differ from its seed-derived colors.
*
* Returns true when the event has a seed but its base_color, secondary_color,
* or eye_color tags do not match the canonical seed-derived values. This means
* the event should be republished so the persisted tags mirror the seed.
*
* Checks all seed-derived mirror tags: base_color, secondary_color,
* eye_color, pattern, special_mark, size, and adult_type (for adults).
*
* Returns false when:
* - no seed exists (legacy event; tags are authoritative)
* - all mirror tags already match the seed-derived values
*/
export function eventNeedsSeedIdentitySync(tags: string[][]): boolean {
const seed = getTagValue(tags, 'seed');
if (!seed || seed.length !== 64) return false;
const canonical = deriveSeedIdentity(seed);
// Check color tags
const storedBase = normalizeHexColor(getTagValue(tags, 'base_color'));
const storedSec = normalizeHexColor(getTagValue(tags, 'secondary_color'));
const storedEye = normalizeHexColor(getTagValue(tags, 'eye_color'));
if (!storedBase || !storedSec || !storedEye) return true;
if (storedBase !== canonical.baseColor ||
storedSec !== canonical.secondaryColor ||
storedEye !== canonical.eyeColor) return true;
// Check non-color visual traits
const storedPattern = getTagValue(tags, 'pattern');
const storedMark = getTagValue(tags, 'special_mark');
const storedSize = getTagValue(tags, 'size');
if (storedPattern !== canonical.pattern ||
storedMark !== canonical.specialMark ||
storedSize !== canonical.size) return true;
// Check adult_type (only for adult stage)
const stage = getTagValue(tags, 'stage');
if (stage === 'adult') {
const storedAdultType = getTagValue(tags, 'adult_type');
const canonicalAdultType = deriveAdultFormFromSeed(seed);
if (storedAdultType !== canonicalAdultType) return true;
}
return false;
}
// ─── Event Validation ─────────────────────────────────────────────────────────
/**
@@ -936,20 +1159,50 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
// Resolve name: tag > legacy d-tag derivation > fallback
const name = nameTag ?? deriveNameFromLegacyD(d);
// ─── TEMPORARY: Adult-type compatibility seed adjustment ───
// During the compatibility window, if an existing adult has an explicit
// adult_type tag that doesn't match the seed-derived form, adjust the
// seed so it produces that form. This prevents existing adults from
// suddenly changing form. After the cutoff this block is a no-op.
let effectiveSeed = seed;
if (
seed && seed.length === 64 &&
stage === 'adult' &&
isAdultTypeCompatActive()
) {
const storedAdultType = getTagValue(tags, 'adult_type');
if (storedAdultType && ADULT_FORMS.includes(storedAdultType as AdultForm)) {
const seedDerivedForm = deriveAdultFormFromSeed(seed);
if (storedAdultType !== seedDerivedForm) {
effectiveSeed = adjustSeedForAdultType(seed, storedAdultType as AdultForm);
}
}
}
// Derive visual traits (single source of truth)
const visualTraits = deriveVisualTraits(tags, seed);
const visualTraits = deriveVisualTraits(tags, effectiveSeed);
// Check if this is a legacy event that needs migration
const isLegacy = isLegacyBlobbiEvent(event);
// Concise, structured debug log
console.log('[Blobbi]', {
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
name,
isLegacy,
hasSeed: !!seed,
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
});
// Check if stored mirror tags need syncing with seed-derived values
// Uses the effective seed (which may be adjusted during compat window)
const needsSeedIdentitySync = eventNeedsSeedIdentitySync(
effectiveSeed !== seed
? tags.map((t) => t[0] === 'seed' ? ['seed', effectiveSeed!] : t)
: tags,
);
if (import.meta.env.DEV) {
console.log('[Blobbi]', {
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
name,
isLegacy,
needsSeedIdentitySync,
hasSeed: !!seed,
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
});
}
// Parse task progress tags: ["task", "name:value"]
const tasks: BlobbiTaskProgress[] = [];
@@ -970,6 +1223,9 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
}
}
// Parse evolution missions from 31124 content JSON (per-Blobbi)
const evolution = parseEvolutionContent(event.content);
return {
event,
d,
@@ -977,9 +1233,10 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
stage,
state,
progressionState,
seed,
seed: effectiveSeed,
visualTraits,
isLegacy,
needsSeedIdentitySync,
lastInteraction: parseNumericTag(tags, 'last_interaction')!,
lastDecayAt: parseNumericTag(tags, 'last_decay_at'),
stats: {
@@ -997,11 +1254,14 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
careStreakLastDay: getTagValue(tags, 'care_streak_last_day'),
incubationTime: parseNumericTag(tags, 'incubation_time'),
startIncubation: parseNumericTag(tags, 'start_incubation'),
adultType: getTagValue(tags, 'adult_type'),
adultType: stage === 'adult' && effectiveSeed && effectiveSeed.length === 64
? deriveAdultFormFromSeed(effectiveSeed)
: getTagValue(tags, 'adult_type'),
stateStartedAt: parseNumericTag(tags, 'state_started_at'),
progressionStartedAt: parseNumericTag(tags, 'progression_started_at') ?? parseNumericTag(tags, 'state_started_at'),
tasks,
tasksCompleted,
evolution,
allTags: tags,
};
}
@@ -1076,13 +1336,8 @@ export function buildEggTags(
const seed = deriveBlobbiSeedV1(pubkey, d, createdAt);
const now = createdAt.toString();
// Derive visual traits from seed for explicit storage
const baseColor = deriveBaseColorFromSeed(seed);
const secondaryColor = deriveSecondaryColorFromSeed(seed);
const eyeColor = deriveEyeColorFromSeed(seed);
const pattern = derivePatternFromSeed(seed);
const specialMark = deriveSpecialMarkFromSeed(seed);
const size = deriveSizeFromSeed(seed);
// Derive visual traits from seed for explicit storage (tags mirror the seed).
const { baseColor, secondaryColor, eyeColor, pattern, specialMark, size } = deriveSeedIdentity(seed);
return [
['d', d],
@@ -1257,6 +1512,51 @@ export function mergeTagsForRepublish(
return result;
}
/**
* Overwrite mirror tags so they match the seed-derived canonical identity.
*
* When a seed tag is present, replaces all seed-derived mirror tags with
* the canonical values from deriveSeedIdentity(). For adult-stage events,
* also syncs adult_type. If no seed is found the tags are returned unchanged.
*
* This is called inside mergeBlobbiStateTagsForRepublish so that every
* republish automatically backfills correct mirror tags.
*/
function syncMirrorTagsToSeed(tags: string[][]): string[][] {
const seed = getTagValue(tags, 'seed');
if (!seed || seed.length !== 64) return tags;
const canonical = deriveSeedIdentity(seed);
const MIRROR_TAG_NAMES = new Set([
'base_color', 'secondary_color', 'eye_color',
'pattern', 'special_mark', 'size',
]);
const stage = getTagValue(tags, 'stage');
if (stage === 'adult') {
MIRROR_TAG_NAMES.add('adult_type');
}
// Remove existing mirror tags
const filtered = tags.filter((t) => !MIRROR_TAG_NAMES.has(t[0]));
// Append canonical values
filtered.push(
['base_color', canonical.baseColor],
['secondary_color', canonical.secondaryColor],
['eye_color', canonical.eyeColor],
['pattern', canonical.pattern],
['special_mark', canonical.specialMark],
['size', canonical.size],
);
if (stage === 'adult') {
filtered.push(['adult_type', deriveAdultFormFromSeed(seed)]);
}
return filtered;
}
/**
* Update specific tags in a Blobbi event while preserving unknown tags.
* Uses MANAGED_BLOBBI_STATE_TAG_NAMES for Kind 31124.
@@ -1320,7 +1620,12 @@ export function mergeBlobbiStateTagsForRepublish(
!DEPRECATED_BLOBBI_TAG_NAMES.has(tag[0])
);
const mergedTags = [...newTags, ...unknownTags];
let mergedTags = [...newTags, ...unknownTags];
// ─── Sync mirror tags to seed-derived values ───
// When a seed exists, visual trait tags are mirrors of the seed. Overwrite
// any stale values so persisted tags always match the canonical derivation.
mergedTags = syncMirrorTagsToSeed(mergedTags);
// Skip validation if requested (for internal use)
if (options?.skipValidation) {
@@ -1528,15 +1833,22 @@ export function buildMigrationTags(
const now = Math.floor(Date.now() / 1000).toString();
// Start with required tags
const legacyD = getTagValue(legacyTags, 'd');
const newTags: string[][] = [
['d', canonicalD],
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
['seed', seed],
];
// Store a back-reference to the legacy d-tag for future equivalence lookups.
// This is additive — current dedup logic uses name-based matching, but future
// versions can use this tag for stronger deterministic equivalence.
if (legacyD) {
newTags.push(['migrated_from', legacyD]);
}
// Preserve name with priority: name tag > legacy d-tag derived > fallback
const nameTag = getTagValue(legacyTags, 'name');
const legacyD = getTagValue(legacyTags, 'd');
const resolvedName = nameTag ?? (legacyD ? deriveNameFromLegacyD(legacyD) : 'Unnamed Blobbi');
newTags.push(['name', resolvedName]);
@@ -1678,6 +1990,221 @@ export function migratePetInHas(
return filtered;
}
// ─── Legacy / Canonical Deduplication ──────────────────────────────────────────
/**
* Normalize a Blobbi name for equivalence comparison.
* Lowercases and trims whitespace so "Jack", "jack", and " Jack " all match.
*/
export function normalizeBlobbiName(name: string): string {
return name.trim().toLowerCase();
}
/**
* Check whether a canonical companion is equivalent to a legacy companion.
*
* Equivalence priority (first match wins):
* 1. **migrated_from exact match**: canonical has a `migrated_from` tag that
* equals the legacy d-tag. This is the strongest signal — it was written
* during migration and is preserved across all subsequent updates.
* 2. **name + base_color match**: same normalized name AND same raw `base_color`
* tag value (both present and equal). Covers older canonical copies that
* were created before the `migrated_from` tag existed.
* 3. **name-only fallback**: same normalized name when the legacy event has no
* explicit `base_color` tag (too bare to compare). This is the weakest tier
* and only applies to genuinely old legacy events with no visual tags.
*/
function isCanonicalEquivalentToLegacy(
canonical: BlobbiCompanion,
legacyD: string,
legacyName: string,
legacyBaseColor: string | undefined,
): boolean {
// Priority 1: migrated_from exact match
const migratedFrom = getTagValue(canonical.event.tags, 'migrated_from');
if (migratedFrom === legacyD) return true;
// Priority 2: name + base_color (both must be present and equal)
const canonicalBaseColor = getTagValue(canonical.event.tags, 'base_color');
if (
normalizeBlobbiName(canonical.name) === legacyName &&
legacyBaseColor !== undefined &&
canonicalBaseColor !== undefined &&
legacyBaseColor.toUpperCase() === canonicalBaseColor.toUpperCase()
) {
return true;
}
// Priority 3: name-only when legacy has no base_color to compare
if (
normalizeBlobbiName(canonical.name) === legacyName &&
legacyBaseColor === undefined
) {
return true;
}
return false;
}
/**
* Filter out legacy companions that have been migrated to canonical format.
*
* A legacy companion is hidden when ALL of the following are true:
* 1. It is a legacy event (companion.isLegacy === true)
* 2. The legacy d-tag is NOT present in profile.has (confirming migration occurred)
* 3. A canonical equivalent exists, determined by (first match wins):
* a. migrated_from exact match on the legacy d-tag
* b. same normalized name + same raw base_color tag
* c. same normalized name (fallback when legacy has no base_color tag)
*
* After legacy filtering, a second pass collapses canonical → canonical
* duplicates that share the same `migrated_from` tag value (i.e. were both
* migrated from the same legacy d-tag due to a race condition). For each
* group the companion with the newest `created_at` is kept; the rest are
* hidden. Canonical companions without a `migrated_from` tag are always
* kept — no heuristic (name, color, etc.) grouping is applied.
*
* @param companions - All parsed companions (legacy + canonical)
* @param profileHas - The profile.has array of owned Blobbi d-tags
* @returns Filtered companions with migrated legacy entries and canonical
* duplicates removed
*/
export function filterMigratedLegacyCompanions(
companions: BlobbiCompanion[],
profileHas: string[],
): BlobbiCompanion[] {
// Collect canonical companions for equivalence checks
const canonicals = companions.filter((c) => !c.isLegacy);
// If there are no canonical companions, nothing to filter
if (canonicals.length === 0) return companions;
const hasSet = new Set(profileHas);
const afterLegacyFilter = companions.filter((c) => {
// Keep all canonical companions unconditionally (deduped in next pass)
if (!c.isLegacy) return true;
// Keep legacy companions that are still in profile.has (not yet migrated)
if (hasSet.has(c.d)) return true;
// Check if any canonical companion is equivalent to this legacy one
const legacyName = normalizeBlobbiName(c.name);
const legacyBaseColor = getTagValue(c.event.tags, 'base_color');
const hasEquivalent = canonicals.some((canonical) =>
isCanonicalEquivalentToLegacy(canonical, c.d, legacyName, legacyBaseColor),
);
// Hide if a canonical equivalent exists, keep otherwise
return !hasEquivalent;
});
// ── Canonical → canonical dedup ──────────────────────────────────────────
// Group canonical companions by `migrated_from` tag. Within each group,
// keep only the newest event (highest created_at). Canonicals without the
// tag are never grouped — they pass through untouched.
const canonicalWinners = collapseCanonicalDuplicates(
afterLegacyFilter.filter((c) => !c.isLegacy),
);
const winnerDs = new Set(canonicalWinners.map((c) => c.d));
return afterLegacyFilter.filter((c) => {
// Legacy companions already survived the first pass — keep them
if (c.isLegacy) return true;
// Canonical companions must be in the winner set
return winnerDs.has(c.d);
});
}
/**
* Collapse canonical companions that were duplicated by a migration race.
*
* Two canonical companions are considered duplicates of the same logical
* Blobbi if and only if both carry a `migrated_from` tag with the same
* value. For each such group the companion with the newest `created_at`
* is kept; ties are broken by d-tag lexicographic order (deterministic).
*
* Canonical companions *without* a `migrated_from` tag are always kept —
* no heuristic grouping (name, color, etc.) is applied.
*/
function collapseCanonicalDuplicates(
canonicals: BlobbiCompanion[],
): BlobbiCompanion[] {
// Companions without migrated_from — always kept
const ungrouped: BlobbiCompanion[] = [];
// Group canonical companions by migrated_from value
const groups = new Map<string, BlobbiCompanion[]>();
for (const c of canonicals) {
const migratedFrom = getTagValue(c.event.tags, 'migrated_from');
if (!migratedFrom) {
ungrouped.push(c);
continue;
}
const group = groups.get(migratedFrom);
if (group) {
group.push(c);
} else {
groups.set(migratedFrom, [c]);
}
}
// Pick the winner from each group: newest created_at, tie-break on d-tag
const winners: BlobbiCompanion[] = [...ungrouped];
for (const group of groups.values()) {
let best = group[0];
for (let i = 1; i < group.length; i++) {
const c = group[i];
if (
c.event.created_at > best.event.created_at ||
(c.event.created_at === best.event.created_at && c.d > best.d)
) {
best = c;
}
}
winners.push(best);
}
return winners;
}
/**
* Find an existing canonical companion that is equivalent to a legacy companion.
*
* Used by the migration guard to avoid creating duplicate canonical events.
* Uses the same equivalence priority as `filterMigratedLegacyCompanions`:
* 1. migrated_from exact match (strongest)
* 2. same normalized name + same raw base_color tag
* 3. same normalized name when legacy has no base_color (weakest)
*
* When multiple canonical companions match, the one with the newest
* created_at is returned (most up-to-date state).
*
* @param legacy - The legacy companion to find an equivalent for
* @param companions - All parsed companions
* @returns The best matching canonical companion, or undefined
*/
export function findCanonicalEquivalent(
legacy: BlobbiCompanion,
companions: BlobbiCompanion[],
): BlobbiCompanion | undefined {
const legacyName = normalizeBlobbiName(legacy.name);
const legacyBaseColor = getTagValue(legacy.event.tags, 'base_color');
let best: BlobbiCompanion | undefined;
for (const c of companions) {
if (c.isLegacy) continue;
if (!isCanonicalEquivalentToLegacy(c, legacy.d, legacyName, legacyBaseColor)) continue;
// Among multiple matches, prefer the newest (most up-to-date state)
if (!best || c.event.created_at > best.event.created_at) {
best = c;
}
}
return best;
}
// ─── LocalStorage Cache Types ─────────────────────────────────────────────────
export interface BlobbiBootCache {
+325
View File
@@ -0,0 +1,325 @@
/**
* Color Guardrails for Blobbi Visual Traits
*
* Pure validation/adjustment utilities that ensure generated colors produce
* good visual results in the Blobbi rendering pipeline. Operates in HSL
* color space for perceptually-aware adjustments.
*
* These guardrails are designed to be applied at the generation layer only.
* They do NOT affect existing explicit color tags on Nostr events -- those
* are always passed through unchanged per the tag-priority rule in
* deriveVisualTraits().
*
* Why HSL?
* The SVG rendering pipeline (baby-svg-customizer, adult-svg-customizer)
* uses lightenColor(base, 40) for gradient highlights and darkenColor(base, 15)
* for shadows. These are naive RGB channel operations that clip at 0/255.
* Colors at extreme lightness values cause clipping, producing flat or
* invisible gradients. HSL lightness lets us reason about this directly
* and keep generated colors in the range where the RGB pipeline works well.
*/
// ─── Thresholds ───────────────────────────────────────────────────────────────
//
// Calibrated against the existing 10-color palette (all base colors fall in
// L=50..76, S=48..96) and the rendering pipeline's lighten/darken amounts.
// These thresholds are intentionally wider than the palette range to allow
// creative variety in arbitrary generation while preventing degenerate output.
/** Minimum lightness for base colors. Below this, darkenColor(base, 15) clips to black. */
export const BASE_LIGHTNESS_MIN = 25;
/** Maximum lightness for base colors. Above this, lightenColor(base, 40) clips to white. */
export const BASE_LIGHTNESS_MAX = 80;
/** Minimum saturation for base colors. Below this, colors appear as dull grays. */
export const BASE_SATURATION_MIN = 20;
/**
* Minimum lightness difference between base and secondary when their hues
* are similar (< HUE_DISTINCTION_THRESHOLD degrees apart). Without enough
* lightness separation, the 3D body gradient collapses into a flat fill.
*
* The existing palette's tightest matched pair is Indigo (L=74) / Lt Indigo
* (L=82) at delta=8, which is borderline. We use 12 as the floor for
* arbitrary generation to guarantee visible shading.
*/
export const MIN_BASE_SECONDARY_LIGHTNESS_DELTA = 12;
/**
* Hue difference (degrees) above which base and secondary are considered
* visually distinct regardless of lightness. Two colors 30+ degrees apart
* on the hue wheel produce clearly different tones even at similar lightness.
*/
export const HUE_DISTINCTION_THRESHOLD = 30;
/**
* Maximum lightness for eye colors. Above this, pupils become invisible
* against the white sclera in both baby and adult SVGs.
*/
export const EYE_LIGHTNESS_MAX = 75;
/**
* Minimum lightness difference between eye color and base color. Ensures
* pupils remain visually prominent against the body. The rendering pipeline
* draws pupils inside white sclera circles, so hue difference carries some
* weight -- but for conservative safety we enforce a lightness floor too.
*/
export const MIN_EYE_BASE_LIGHTNESS_DELTA = 20;
// ─── HSL ↔ Hex Conversion ────────────────────────────────────────────────────
/**
* Expand 3-char hex (#RGB) to 6-char hex (#RRGGBB).
* 6-char values pass through unchanged.
*/
function expandHex(hex: string): string {
if (hex.length === 4) {
const r = hex[1], g = hex[2], b = hex[3];
return `#${r}${r}${g}${g}${b}${b}`;
}
return hex;
}
/**
* Convert a hex color (#RGB or #RRGGBB) to HSL.
* Returns h: 0-360, s: 0-100, l: 0-100.
*/
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
const hex6 = expandHex(hex);
const num = parseInt(hex6.slice(1), 16);
const r = ((num >> 16) & 0xff) / 255;
const g = ((num >> 8) & 0xff) / 255;
const b = (num & 0xff) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
/**
* Convert HSL values to a hex color string (#RRGGBB, uppercase).
* Expects h: 0-360, s: 0-100, l: 0-100.
*
* Uses the CSS Color Level 4 conversion formula.
*/
export function hslToHex(h: number, s: number, l: number): string {
const sNorm = s / 100;
const lNorm = l / 100;
const a = sNorm * Math.min(lNorm, 1 - lNorm);
function f(n: number): number {
const k = (n + h / 30) % 12;
return lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
}
const R = Math.round(f(0) * 255);
const G = Math.round(f(8) * 255);
const B = Math.round(f(4) * 255);
return '#' + ((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1).toUpperCase();
}
// ─── Hue Distance ─────────────────────────────────────────────────────────────
/**
* Compute the shortest angular distance between two hue values (0-180).
* Accounts for the circular nature of the hue wheel.
*/
export function hueDelta(h1: number, h2: number): number {
const d = Math.abs(h1 - h2);
return Math.min(d, 360 - d);
}
// ─── Guardrail Functions ──────────────────────────────────────────────────────
/**
* Clamp a base color to a usable lightness and saturation range.
*
* The SVG pipeline gradient builders use:
* - lightenColor(base, 40) → adds ~102 to each RGB channel for highlights
* - darkenColor(base, 15) → subtracts ~38 from each channel for shadows
*
* Colors outside L=[25, 80] cause these operations to clip, flattening
* the 3D gradient into a near-solid fill. Colors with S < 20 appear as
* indistinguishable grays regardless of hue.
*
* Returns the original color if already within range, or an adjusted copy.
*/
export function clampBaseColor(hex: string): string {
const hsl = hexToHsl(hex);
let changed = false;
if (hsl.l < BASE_LIGHTNESS_MIN) {
hsl.l = BASE_LIGHTNESS_MIN;
changed = true;
} else if (hsl.l > BASE_LIGHTNESS_MAX) {
hsl.l = BASE_LIGHTNESS_MAX;
changed = true;
}
if (hsl.s < BASE_SATURATION_MIN && hsl.s > 0) {
// Only enforce saturation floor for chromatic colors.
// True achromatic (s=0) is left alone -- a caller explicitly
// generating gray is making a deliberate choice.
hsl.s = BASE_SATURATION_MIN;
changed = true;
}
return changed ? hslToHex(hsl.h, hsl.s, hsl.l) : hex;
}
/**
* Ensure secondary color is perceptually distinct from base color.
*
* The secondary color provides the inner gradient stops in the 3D body
* gradient. If it's too close to the base, the gradient collapses into
* a flat fill and the Blobbi loses its characteristic rounded shading.
*
* Distinction is measured by a combination of hue and lightness distance:
* - Hue difference >= 30° → colors are visually distinct (passes as-is)
* - Hue difference < 30° → lightness must differ by >= 12 points
*
* When adjustment is needed, the secondary is shifted lighter if the base
* is dark/mid, or darker if the base is light, preserving its hue.
*
* Returns the original secondary if already distinct, or an adjusted copy.
*/
export function ensureDistinctSecondary(baseHex: string, secondaryHex: string): string {
const baseHsl = hexToHsl(baseHex);
const secHsl = hexToHsl(secondaryHex);
// Different hues are already visually distinct
if (hueDelta(baseHsl.h, secHsl.h) >= HUE_DISTINCTION_THRESHOLD) {
return secondaryHex;
}
// Similar hue: check lightness separation
if (Math.abs(baseHsl.l - secHsl.l) >= MIN_BASE_SECONDARY_LIGHTNESS_DELTA) {
return secondaryHex;
}
// Shift secondary away from base in lightness, preserving hue/saturation.
// Prefer making secondary lighter (matching the existing palette convention
// where secondaries are lighter variants of bases).
let targetL: number;
const lighterTarget = baseHsl.l + MIN_BASE_SECONDARY_LIGHTNESS_DELTA;
const darkerTarget = baseHsl.l - MIN_BASE_SECONDARY_LIGHTNESS_DELTA;
if (lighterTarget <= 90) {
targetL = lighterTarget;
} else if (darkerTarget >= 10) {
targetL = darkerTarget;
} else {
// Base is in a very constrained range -- go as light as possible
targetL = 90;
}
return hslToHex(secHsl.h, secHsl.s, targetL);
}
/**
* Ensure eye color has sufficient visibility.
*
* Eye color must satisfy two constraints:
* 1. Not too light (L <= 75) -- pupils sit inside white sclera circles
* in both baby and adult SVGs, so very light colors disappear.
* 2. Enough lightness distance from base color (delta >= 20) -- even
* though pupils render against white sclera, the overall visual
* impression suffers when eye and body colors are too similar.
*
* When adjustment is needed, eyes are preferentially darkened (darker
* pupils are more natural). Eyes are only lightened when the base color
* is very dark and darkening further would push below L=5.
*
* Returns the original eye color if already visible, or an adjusted copy.
*/
export function ensureEyeVisibility(eyeHex: string, baseHex: string): string {
const eyeHsl = hexToHsl(eyeHex);
const baseHsl = hexToHsl(baseHex);
let l = eyeHsl.l;
let changed = false;
// Constraint 1: cap lightness so pupils are visible on white sclera
if (l > EYE_LIGHTNESS_MAX) {
l = EYE_LIGHTNESS_MAX;
changed = true;
}
// Constraint 2: ensure enough distance from base color lightness
if (Math.abs(l - baseHsl.l) < MIN_EYE_BASE_LIGHTNESS_DELTA) {
// Prefer darkening (more natural for pupils)
const darkerTarget = baseHsl.l - MIN_EYE_BASE_LIGHTNESS_DELTA;
const lighterTarget = baseHsl.l + MIN_EYE_BASE_LIGHTNESS_DELTA;
if (darkerTarget >= 5) {
l = darkerTarget;
} else if (lighterTarget <= EYE_LIGHTNESS_MAX) {
l = lighterTarget;
} else {
// Very constrained -- go as dark as possible
l = 5;
}
changed = true;
}
return changed ? hslToHex(eyeHsl.h, eyeHsl.s, l) : eyeHex;
}
// ─── Combined Entry Point ─────────────────────────────────────────────────────
/**
* Apply all color guardrails to a set of generated Blobbi colors.
*
* Guardrails are applied in dependency order:
* 1. Clamp base color to usable luminance/saturation range
* 2. Ensure secondary is distinct from the (clamped) base
* 3. Ensure eye color is visible against the (clamped) base
*
* Colors that are already within safe ranges pass through unchanged.
*
* IMPORTANT: This function is for newly-generated colors only. It must NOT
* be applied to explicit color tags from existing Nostr events -- those are
* preserved unchanged per the tag-priority rule in deriveVisualTraits().
*/
export function applyColorGuardrails(colors: {
baseColor: string;
secondaryColor: string;
eyeColor: string;
}): {
baseColor: string;
secondaryColor: string;
eyeColor: string;
} {
const baseColor = clampBaseColor(colors.baseColor);
const secondaryColor = ensureDistinctSecondary(baseColor, colors.secondaryColor);
const eyeColor = ensureEyeVisibility(colors.eyeColor, baseColor);
return { baseColor, secondaryColor, eyeColor };
}
@@ -0,0 +1,56 @@
import type { NPool } from '@nostrify/nostrify';
import {
BLOBBONAUT_PROFILE_KINDS,
KIND_BLOBBONAUT_PROFILE,
getBlobbonautQueryDValues,
isValidBlobbonautEvent,
isLegacyBlobbonautKind,
parseBlobbonautEvent,
type BlobbonautProfile,
} from './blobbi';
/**
* Fetch the freshest Blobbonaut profile (kind 11125) directly from relays,
* bypassing any local TanStack Query cache.
*
* Prefers the current kind (11125) over legacy (31125). Returns a fully-parsed
* `BlobbonautProfile` including `.event` (the raw NostrEvent) so callers can
* pass it as `prev` to `useNostrPublish`.
*
* Use this inside every mutation that performs a read-modify-write on the
* Blobbonaut profile to avoid overwriting content (daily missions JSON) or
* tags with stale cached data.
*/
export async function fetchFreshBlobbonautProfile(
nostr: NPool,
pubkey: string,
): Promise<BlobbonautProfile | null> {
const dValues = getBlobbonautQueryDValues(pubkey);
const signal = AbortSignal.timeout(10_000);
const events = await nostr.query([{
kinds: [...BLOBBONAUT_PROFILE_KINDS],
authors: [pubkey],
'#d': dValues,
}], { signal });
const validEvents = events.filter(isValidBlobbonautEvent);
if (validEvents.length === 0) return null;
// Prefer current kind over legacy
const currentKindEvents = validEvents.filter(e => e.kind === KIND_BLOBBONAUT_PROFILE);
if (currentKindEvents.length > 0) {
const sorted = currentKindEvents.sort((a, b) => b.created_at - a.created_at);
return parseBlobbonautEvent(sorted[0]) ?? null;
}
const legacyKindEvents = validEvents.filter(e => isLegacyBlobbonautKind(e));
if (legacyKindEvents.length > 0) {
const sorted = legacyKindEvents.sort((a, b) => b.created_at - a.created_at);
return parseBlobbonautEvent(sorted[0]) ?? null;
}
return null;
}
+69 -10
View File
@@ -1,10 +1,9 @@
/**
* Missions Content Model
*
* Defines the JSON shape stored in the kind 11125 content field.
* Two mission categories:
* - daily: reset each day, tally-based or event-based
* - evolution: persist across sessions until stage transition completes
* Two separate persistence locations:
* - Daily missions → kind 11125 content JSON (per-user)
* - Evolution missions → kind 31124 content JSON (per-Blobbi)
*
* Tally missions track a `count` (no event IDs).
* Event missions track an `events` array of Nostr event IDs.
@@ -52,13 +51,12 @@ export function missionProgress(m: Mission): number {
return m.count;
}
// ─── Content Shape ───────────────────────────────────────────────────────────
// ─── Daily Missions (kind 11125) ─────────────────────────────────────────────
/** The full missions object stored in kind 11125 content JSON */
/** Daily missions object stored in kind 11125 content JSON */
export interface MissionsContent {
date: string; // YYYY-MM-DD for daily reset detection
daily: Mission[]; // 3 daily missions, reset each day
evolution: Mission[]; // active evolution missions, cleared on stage transition
rerolls: number; // daily rerolls remaining (resets with date)
}
@@ -70,7 +68,55 @@ export interface ProfileContent {
missions?: MissionsContent;
}
// ─── Parse / Serialize ───────────────────────────────────────────────────────
// ─── Evolution Missions (kind 31124) ─────────────────────────────────────────
/**
* Evolution mission state stored in kind 31124 content JSON.
* Per-Blobbi progression that survives reloads.
*/
export interface EvolutionContent {
evolution: Mission[];
}
/**
* Parse evolution missions from a kind 31124 content field.
* Returns empty array for empty/invalid/non-JSON content. Never throws.
*/
export function parseEvolutionContent(content: string): Mission[] {
if (!content || !content.trim()) return [];
try {
const raw = JSON.parse(content);
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return [];
return parseMissionArray(raw.evolution);
} catch {
// Old-format content is plain text (e.g. "Luna is an egg...") — not JSON
return [];
}
}
/**
* Serialize evolution missions into kind 31124 content JSON.
* Preserves any unknown top-level keys from the existing content.
*/
export function serializeEvolutionContent(
existingContent: string,
evolution: Mission[],
): string {
let base: Record<string, unknown> = {};
if (existingContent && existingContent.trim()) {
try {
const parsed = JSON.parse(existingContent);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
base = parsed;
}
} catch {
// Old-format text content — start fresh
}
}
return JSON.stringify({ ...base, evolution });
}
// ─── Profile Content (kind 11125) ────────────────────────────────────────────
/**
* Parse the kind 11125 content field into a typed ProfileContent.
@@ -94,6 +140,9 @@ export function parseProfileContent(content: string): ProfileContent {
/**
* Serialize ProfileContent back to a JSON string for publishing.
* Preserves any unknown top-level keys from the existing content.
*
* NOTE: Strips any legacy `evolution` key from the missions object
* so old 11125 events don't carry stale per-Blobbi data.
*/
export function serializeProfileContent(
existingContent: string,
@@ -110,7 +159,17 @@ export function serializeProfileContent(
// corrupt content -- start fresh but don't lose updates
}
}
return JSON.stringify({ ...base, ...updates });
const merged = { ...base, ...updates };
// Strip legacy evolution from missions if present
if (merged.missions && typeof merged.missions === 'object' && !Array.isArray(merged.missions)) {
const m = merged.missions as unknown as Record<string, unknown>;
if ('evolution' in m) {
delete m.evolution;
}
}
return JSON.stringify(merged);
}
// ─── Internal Helpers ────────────────────────────────────────────────────────
@@ -120,11 +179,11 @@ function parseMissionsContent(raw: Record<string, unknown>): MissionsContent | u
return {
date: raw.date,
daily: parseMissionArray(raw.daily),
evolution: parseMissionArray(raw.evolution),
rerolls: typeof raw.rerolls === 'number' ? Math.max(0, Math.floor(raw.rerolls)) : 0,
};
}
/** @internal Exported for use by parseEvolutionContent */
function parseMissionArray(raw: unknown): Mission[] {
if (!Array.isArray(raw)) return [];
const result: Mission[] = [];
+42
View File
@@ -38,6 +38,8 @@ interface BlobbiDevEditorProps {
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
/** Whether an update is in progress */
isUpdating?: boolean;
/** DEV: Reset account-level daily missions and trigger persist */
onResetDailyMissions?: () => void;
}
/** Updates that can be applied to a Blobbi */
@@ -170,6 +172,7 @@ export function BlobbiDevEditor({
companion,
onApply,
isUpdating = false,
onResetDailyMissions,
}: BlobbiDevEditorProps) {
// ─── Local State ───
// Initialize from companion values
@@ -332,6 +335,19 @@ export function BlobbiDevEditor({
)}
</div>
{/* ─── Seed (read-only) ─── */}
{companion.seed && (
<div className="space-y-2">
<Label className="text-sm font-semibold">Seed</Label>
<code className="block text-[10px] font-mono text-muted-foreground bg-muted rounded px-2 py-1.5 break-all select-all">
{companion.seed}
</code>
<p className="text-[10px] text-muted-foreground">
Read-only. The seed determines colors, pattern, size, and adult form.
</p>
</div>
)}
{/* ─── Adult Form (only shown for adults) ─── */}
{stage === 'adult' && (
<div className="space-y-3">
@@ -348,6 +364,11 @@ export function BlobbiDevEditor({
))}
</SelectContent>
</Select>
{companion.seed && adultType !== (companion.adultType ?? 'catti') && (
<p className="text-xs text-amber-500">
Changing the form will adjust the seed. Colors and other visual traits will be re-derived.
</p>
)}
</div>
)}
@@ -533,6 +554,27 @@ export function BlobbiDevEditor({
</div>
{/* Daily Missions Reset */}
{onResetDailyMissions && (
<>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">Daily Missions</Label>
<p className="text-xs text-muted-foreground">Reset account-level daily bounties</p>
</div>
<Button
variant="outline"
size="sm"
onClick={onResetDailyMissions}
>
<RotateCcw className="size-3 mr-1.5" />
Reset Daily Missions
</Button>
</div>
</>
)}
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
+11 -5
View File
@@ -15,7 +15,8 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString, adjustSeedForAdultType } from '@/blobbi/core/lib/blobbi';
import type { AdultForm } from '@/blobbi/adult-blobbi/types/adult.types';
import type { BlobbiDevUpdates } from './BlobbiDevEditor';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -90,11 +91,16 @@ export function useBlobbiDevUpdate({
}
}
// Adult type (only valid for adult stage)
// Adult type: adjust the seed so it derives the chosen form.
// syncMirrorTagsToSeed (called inside updateBlobbiTags) will then
// set the adult_type tag and all other mirror tags from the new seed.
const effectiveStage = updates.stage ?? companion.stage;
if (effectiveStage === 'adult' && updates.adultType !== undefined) {
tagUpdates.adult_type = updates.adultType;
changedFields.push('adult_type');
if (effectiveStage === 'adult' && updates.adultType !== undefined && companion.seed) {
const adjusted = adjustSeedForAdultType(companion.seed, updates.adultType as AdultForm);
if (adjusted !== companion.seed) {
tagUpdates.seed = adjusted;
changedFields.push('seed', 'adult_type');
}
}
// Stats
+40
View File
@@ -765,6 +765,39 @@
animation: onboard-golden-fadein 2.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* ─── Evolve Ceremony Animations ─────────────────────────────────────────────── */
/* Energy particles spiral inward toward the blobbi */
@keyframes evolve-spiral-in {
0% {
opacity: 0;
transform: rotate(0deg) translateX(250px) scale(0.5);
}
20% {
opacity: 1;
}
80% {
opacity: 0.8;
transform: rotate(540deg) translateX(20px) scale(1);
}
100% {
opacity: 0;
transform: rotate(720deg) translateX(0px) scale(0);
}
}
/* Pulsing glow behind baby during gather/surge */
@keyframes evolve-glow-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.15);
opacity: 1;
}
}
/* Reduced motion overrides for onboarding */
@media (prefers-reduced-motion: reduce) {
.animate-egg-onboard-breathe,
@@ -788,4 +821,11 @@
transform: none !important;
filter: none !important;
}
/* Evolve ceremony uses inline animation styles, so target all descendants */
[data-evolve-ceremony] *,
[data-evolve-ceremony] {
animation: none !important;
transition: none !important;
}
}
@@ -0,0 +1,415 @@
/**
* BlobbiEvolveCeremony - Immersive evolution experience (baby -> adult)
*
* Flow:
* 1. Full-screen dark backdrop with baby blobbi centered, pulsing glow + spiraling particles
* 2. Screen flash — evolution mutation fires
* 3. Flash clears — adult blobbi revealed with sparkles + radiant glow
* 4. Brief dialog, then fade to white and complete
*/
import { useState, useEffect, useMemo, useRef } from 'react';
import { notificationSuccess } from '@/lib/haptics';
import { cn } from '@/lib/utils';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { useTypewriter } from '../hooks/useTypewriter';
import { hexToRgb, buildRevealGradient } from '../lib/ceremony-colors';
// ─── Phase Machine ────────────────────────────────────────────────────────────
type EvolvePhase =
| 'gather' // baby visible, energy gathering with spiraling particles
| 'flash' // screen flash, mutation fires
| 'reveal' // flash clears, adult revealed with sparkles + text
| 'complete'; // fade out
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiEvolveCeremonyProps {
companion: BlobbiCompanion;
/** Fires the actual evolve mutation (baby -> adult). */
onEvolve: () => Promise<void>;
/** Called when the animation is complete and the overlay should close. */
onComplete: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiEvolveCeremony({
companion,
onEvolve,
onComplete,
}: BlobbiEvolveCeremonyProps) {
const [phase, setPhase] = useState<EvolvePhase>('gather');
const [showFlash, setShowFlash] = useState(false);
const [adultVisible, setAdultVisible] = useState(false);
const [textVisible, setTextVisible] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
const evolveTriggered = useRef(false);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const onEvolveRef = useRef(onEvolve);
onEvolveRef.current = onEvolve;
const baseColor = companion.visualTraits.baseColor ?? '#8b5cf6';
const { r, g, b } = useMemo(() => hexToRgb(baseColor), [baseColor]);
// ── Typewriter for reveal text ──
const line1 = `${companion.name} has evolved!`;
const line2 = 'A new chapter begins...';
const typewriter1 = useTypewriter(line1, textVisible);
const typewriter2 = useTypewriter(line2, typewriter1.done);
// Build adult companion for visual preview (same visual traits, stage=adult)
const adultCompanion = useMemo((): BlobbiCompanion => ({
...companion,
stage: 'adult',
state: 'active' as const,
progressionState: 'none' as const,
}), [companion]);
// Background: baby's base color blended toward white for a soft pastel
const revealBg = useMemo(() => buildRevealGradient(baseColor), [baseColor]);
// Dark background for gather phase
const darkBg = useMemo(() => {
const dr = Math.round(r * 0.12);
const dg = Math.round(g * 0.12);
const db = Math.round(b * 0.12);
return `radial-gradient(ellipse at center, rgb(${dr + 10},${dg + 15},${db + 25}) 0%, rgb(${dr + 5},${dg + 10},${db + 18}) 50%, rgb(${dr},${dg + 5},${db + 12}) 100%)`;
}, [r, g, b]);
// ── Phase timeline ──
// Uses onCompleteRef so parent re-renders (from the evolve mutation updating
// companion data) don't restart the timer chain.
useEffect(() => {
// gather -> flash after 2.8s
const t1 = setTimeout(() => {
setPhase('flash');
setShowFlash(true);
notificationSuccess();
}, 2800);
// flash -> reveal after 3.2s total (near-instant swap)
const t2 = setTimeout(() => {
setShowFlash(false);
setPhase('reveal');
setAdultVisible(true);
setTextVisible(true);
}, 3200);
// fadeout starts at 6s, takes 2s to complete, ceremony closes at 8s
const t4 = setTimeout(() => {
setFadeOut(true);
setTimeout(() => {
setPhase('complete');
onCompleteRef.current();
}, 2000);
}, 6000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t4);
};
}, []);
// ── Fire evolve mutation during flash ──
useEffect(() => {
if (phase === 'flash' && !evolveTriggered.current) {
evolveTriggered.current = true;
onEvolveRef.current().catch(console.error);
}
}, [phase]);
const showBaby = phase === 'gather';
const showAdult = phase === 'reveal';
return (
<div
data-evolve-ceremony
className="fixed inset-0 z-50 overflow-hidden select-none"
style={{
background: showAdult ? revealBg : darkBg,
transition: 'background 0.15s ease-out',
}}
>
{/* ── Vignette shadow for depth ── */}
{showAdult && (
<div
className="absolute inset-0 pointer-events-none"
style={{
background: 'radial-gradient(ellipse at 50% 45%, transparent 30%, rgba(0,0,0,0.12) 70%, rgba(0,0,0,0.25) 100%)',
}}
/>
)}
{/* ── Ambient color glow (gather phase) ── */}
{showBaby && (
<div
className="absolute inset-0"
style={{
background: `radial-gradient(ellipse at 50% 50%, rgba(${r},${g},${b},0.25) 0%, transparent 60%)`,
opacity: 0.15,
}}
/>
)}
{/* ── Spiraling energy particles (gather phase) ── */}
{showBaby && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{Array.from({ length: 12 }).map((_, i) => {
const angle = (i / 12) * 360;
const delay = i * 0.25;
return (
<div
key={`particle-${i}`}
className="absolute left-1/2 top-1/2"
style={{
width: 4 + (i % 3) * 2,
height: 4 + (i % 3) * 2,
borderRadius: '50%',
background: i % 2 === 0
? `radial-gradient(circle, rgba(${r},${g},${b},0.9) 0%, transparent 70%)`
: `radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 70%)`,
animation: `evolve-spiral-in 3s ease-in ${delay}s infinite`,
transform: `rotate(${angle}deg) translateX(200px)`,
}}
/>
);
})}
</div>
)}
{/* ── Baby blobbi (gather phase) ── */}
{showBaby && (
<div className="absolute inset-0 flex items-center justify-center" style={{ paddingBottom: '10%' }}>
{/* Pulsing glow behind baby */}
<div
className="absolute rounded-full"
style={{
width: 250,
height: 250,
background: `radial-gradient(circle, rgba(${r},${g},${b},0.2) 0%, transparent 70%)`,
filter: 'blur(20px)',
animation: 'evolve-glow-pulse 2s ease-in-out infinite',
}}
/>
<div className="relative">
<BlobbiStageVisual
companion={companion}
size="lg"
animated
className="size-56 sm:size-64 md:size-72"
/>
</div>
</div>
)}
{/* ── Screen flash ── */}
{showFlash && (
<div
className="absolute inset-0 bg-white pointer-events-none"
style={{
zIndex: 80,
animation: 'onboard-screen-flash 2s ease-out forwards',
}}
/>
)}
{/* ── Adult blobbi revealed with sparkles ── */}
{showAdult && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ paddingBottom: '18%' }}>
{/* Rotating radiant glow */}
<div
className="absolute"
style={{
opacity: adultVisible ? 1 : 0,
transform: adultVisible ? 'scale(1)' : 'scale(0.7)',
transition: 'opacity 0.2s ease-out, transform 0.2s ease-out',
}}
>
<div
className="animate-onboard-golden-rotate"
style={{
width: 800,
height: 800,
background: `conic-gradient(
from 0deg,
rgba(${r},${g},${b},0.15) 0deg,
rgba(255,255,255,0.35) 50deg,
rgba(${r},${g},${b},0.18) 100deg,
rgba(255,255,255,0.12) 150deg,
rgba(${r},${g},${b},0.30) 210deg,
rgba(255,255,255,0.15) 270deg,
rgba(${r},${g},${b},0.12) 320deg,
rgba(${r},${g},${b},0.15) 360deg
)`,
borderRadius: '50%',
filter: 'blur(30px)',
}}
/>
</div>
{/* Bright center shine */}
<div
className={cn(
'absolute rounded-full transition-opacity duration-150',
adultVisible ? 'opacity-100' : 'opacity-0',
)}
style={{
width: 320,
height: 320,
background: `radial-gradient(circle, rgba(255,255,245,0.60) 0%, rgba(${r},${g},${b},0.20) 40%, transparent 70%)`,
}}
/>
{/* Wider halo */}
<div
className={cn(
'absolute rounded-full transition-opacity duration-200',
adultVisible ? 'opacity-100' : 'opacity-0',
)}
style={{
width: 650,
height: 650,
background: `radial-gradient(circle, rgba(${r},${g},${b},0.25) 0%, rgba(${r},${g},${b},0.10) 40%, transparent 65%)`,
filter: 'blur(15px)',
}}
/>
{/* ── Sparkles ── */}
{Array.from({ length: 18 }).map((_, i) => {
const angle = (i / 18) * Math.PI * 2;
const radius = 90 + (i % 4) * 40;
const size = 4 + (i % 3) * 3;
return (
<div
key={`spark-${i}`}
className="absolute"
style={{
width: size,
height: size,
left: `calc(50% + ${Math.cos(angle) * radius}px - ${size / 2}px)`,
top: `calc(50% + ${Math.sin(angle) * radius}px - ${size / 2}px)`,
borderRadius: '50%',
background: i % 2 === 0
? `radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.3) 50%, transparent 70%)`
: `radial-gradient(circle, rgba(${r},${g},${b},0.9) 0%, rgba(${r},${g},${b},0.2) 50%, transparent 70%)`,
animation: `onboard-sparkle-twinkle ${1.5 + (i % 6) * 0.5}s ease-in-out ${i * 0.15}s infinite`,
}}
/>
);
})}
{/* Outer ring sparkles */}
{Array.from({ length: 14 }).map((_, i) => {
const angle = (i / 14) * Math.PI * 2 + 0.4;
const radius = 180 + (i % 3) * 45;
const size = 5 + (i % 4) * 2;
return (
<div
key={`outer-${i}`}
className="absolute"
style={{
width: size,
height: size,
left: `calc(50% + ${Math.cos(angle) * radius}px - ${size / 2}px)`,
top: `calc(50% + ${Math.sin(angle) * radius}px - ${size / 2}px)`,
borderRadius: '50%',
background: i % 3 === 0
? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 60%)'
: `radial-gradient(circle, rgba(${r},${g},${b},0.7) 0%, transparent 60%)`,
animation: `onboard-sparkle-twinkle ${2.5 + (i % 5) * 0.7}s ease-in-out ${i * 0.2}s infinite`,
}}
/>
);
})}
{/* Rising light motes */}
{Array.from({ length: 8 }).map((_, i) => {
const x = (Math.sin(i * 2.3) * 0.5 + 0.5) * 70 + 15;
return (
<div
key={`mote-${i}`}
className="absolute"
style={{
width: 5 + (i % 3) * 3,
height: 5 + (i % 3) * 3,
left: `${x}%`,
bottom: '20%',
borderRadius: '50%',
background: `radial-gradient(circle, rgba(${r},${g},${b},0.8) 0%, rgba(${r},${g},${b},0.2) 50%, transparent 100%)`,
animation: `onboard-sparkle-drift ${4 + i * 0.5}s ease-out ${i * 0.4}s infinite`,
}}
/>
);
})}
{/* The adult blobbi */}
<div className={cn(
'relative transition-opacity duration-150',
adultVisible ? 'opacity-100' : 'opacity-0',
)}>
<BlobbiStageVisual
companion={adultCompanion}
size="lg"
animated
className="size-[30rem] sm:size-[36rem] md:size-[44rem]"
/>
</div>
</div>
)}
{/* ── Typewriter text (appears during reveal) ── */}
{showAdult && textVisible && (
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
<div className="relative max-w-md w-full text-center">
{/* Soft feathered backdrop */}
<div
className="absolute -inset-32"
style={{
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.35) 0%, rgba(0,30,50,0.15) 35%, transparent 65%)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
}}
/>
<div className="relative">
<p className="text-base sm:text-lg text-white leading-relaxed font-light min-h-[1.5em]">
{typewriter1.displayed}
{!typewriter1.done && (
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
)}
</p>
{typewriter1.done && (
<p className="text-sm text-white/60 mt-2 font-light min-h-[1.25em]">
{typewriter2.displayed}
{!typewriter2.done && (
<span className="inline-block w-[2px] h-[0.85em] bg-white/30 ml-0.5 animate-pulse align-text-bottom" />
)}
</p>
)}
</div>
</div>
</div>
)}
{/* ── Fade to white on completion ── */}
{fadeOut && (
<div
className="absolute inset-0 bg-white pointer-events-none"
style={{
zIndex: 90,
animation: 'blobbi-fade-to-white 2s ease-in forwards',
}}
/>
)}
</div>
);
}
@@ -13,6 +13,7 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
@@ -24,6 +25,7 @@ import { cn } from '@/lib/utils';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { fetchFreshBlobbonautProfile } from '@/blobbi/core/lib/fetchFreshBlobbonautProfile';
import {
KIND_BLOBBI_STATE,
@@ -44,6 +46,9 @@ import {
type BlobbiEggPreview,
} from '../lib/blobbi-preview';
import { useTypewriter } from '../hooks/useTypewriter';
import { buildRevealGradient } from '../lib/ceremony-colors';
// ─── Dialog Lines ─────────────────────────────────────────────────────────────
const BIRTH_DIALOG: string[] = [
@@ -67,49 +72,6 @@ type CeremonyPhase =
| 'naming'
| 'complete';
// ─── Typewriter Hook ──────────────────────────────────────────────────────────
function useTypewriter(fullText: string, active: boolean, speed = 35) {
const [displayed, setDisplayed] = useState('');
const [done, setDone] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const indexRef = useRef(0);
// Reset when text changes
useEffect(() => {
setDisplayed('');
setDone(false);
indexRef.current = 0;
}, [fullText]);
// Run typewriter
useEffect(() => {
if (!active || done) return;
intervalRef.current = setInterval(() => {
indexRef.current++;
const next = fullText.slice(0, indexRef.current);
setDisplayed(next);
if (indexRef.current >= fullText.length) {
setDone(true);
if (intervalRef.current) clearInterval(intervalRef.current);
}
}, speed);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [active, done, fullText, speed]);
const complete = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
setDisplayed(fullText);
setDone(true);
}, [fullText]);
return { displayed, done, complete };
}
// Module-level guard: prevents duplicate egg creation if the component remounts
// (e.g. React strict mode, parent re-render causing unmount/remount).
// Tracks pubkeys that have already started setup in this browser session.
@@ -146,6 +108,7 @@ export function BlobbiHatchingCeremony({
}: BlobbiHatchingCeremonyProps) {
const isExistingEgg = !!existingCompanion;
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const { data: authorData } = useAuthor(user?.pubkey);
@@ -169,6 +132,7 @@ export function BlobbiHatchingCeremony({
// Refs
const setupAttempted = useRef(false);
const setupStarted = useRef(false);
const profileRef = useRef(profile);
profileRef.current = profile;
const previewRef = useRef(preview);
@@ -177,6 +141,8 @@ export function BlobbiHatchingCeremony({
const eggContainerRef = useRef<HTMLDivElement>(null);
const entrancePlayed = useRef(false);
const eggTagsRef = useRef<string[][] | null>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
// ── Companion visuals ──
const eggCompanion = useMemo(
@@ -193,6 +159,9 @@ export function BlobbiHatchingCeremony({
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
// Derive reveal background from baby's base color
const revealBg = useMemo(() => buildRevealGradient(eggColor), [eggColor]);
// ── Typewriter for current dialog line ──
const currentDialogText = phase === 'dialog' ? (BIRTH_DIALOG[dialogLineIndex] ?? '') : '';
const dialogTypewriter = useTypewriter(currentDialogText, dialogActive);
@@ -243,6 +212,11 @@ export function BlobbiHatchingCeremony({
setupInFlightFor.add(user.pubkey);
const setup = async () => {
// Mark that the async work has begun — cleanup must NOT release the
// module-level guard once this point is reached, because setup() will
// release it in its own finally block when the work completes.
setupStarted.current = true;
try {
const currentProfile = profileRef.current;
let latestProfileTags: string[][] | null = currentProfile?.allTags ?? null;
@@ -250,8 +224,8 @@ export function BlobbiHatchingCeremony({
// 1. Create profile if needed
if (!currentProfile) {
const suggestedName =
authorData?.metadata?.display_name ||
authorData?.metadata?.name ||
authorData?.metadata?.display_name ||
'Blobbonaut';
const baseTags = buildBlobbonautTags(user.pubkey);
@@ -291,19 +265,38 @@ export function BlobbiHatchingCeremony({
// 3. Update profile with has[] entry
if (latestProfileTags) {
const existingHas = latestProfileTags
// If profile already existed (not just created), fetch fresh from relays
// to avoid overwriting content with stale cache data
let baseTags = latestProfileTags;
let baseContent = '';
let prevEvent: NostrEvent | undefined;
if (currentProfile) {
const freshProfile = await fetchFreshBlobbonautProfile(nostr, user.pubkey);
if (freshProfile) {
baseTags = freshProfile.allTags;
baseContent = freshProfile.event.content ?? '';
prevEvent = freshProfile.event;
} else {
baseContent = currentProfile.event.content ?? '';
prevEvent = currentProfile.event;
}
}
const existingHas = baseTags
.filter(([k]) => k === 'has')
.map(([, v]) => v);
const newHas = [...existingHas, eggPreview.d];
const updatedTags = updateBlobbonautTags(latestProfileTags, {
const updatedTags = updateBlobbonautTags(baseTags, {
has: newHas,
});
const updatedProfileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: baseContent,
tags: updatedTags,
prev: prevEvent,
});
updateProfileEvent(updatedProfileEvent);
@@ -331,8 +324,12 @@ export function BlobbiHatchingCeremony({
const timer = setTimeout(setup, 600);
return () => {
clearTimeout(timer);
// If the timer was cleared before setup ran, release the guard
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
// Only release the module-level guard if setup() never started.
// If setup() already began, it owns the guard and will release it
// in its own finally block when the async work completes.
if (!setupStarted.current && user?.pubkey) {
setupInFlightFor.delete(user.pubkey);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.pubkey]);
@@ -341,15 +338,17 @@ export function BlobbiHatchingCeremony({
if (profile) profileRef.current = profile;
}, [profile]);
// eggOnly mode: auto-complete after the egg is shown (skip hatching)
// eggOnly mode: auto-complete after the egg is shown (skip hatching).
// Uses onCompleteRef so the timer isn't reset when the parent re-renders
// with a new inline callback reference.
useEffect(() => {
if (!eggOnly || !eggVisible) return;
const timer = setTimeout(() => {
setPhase('complete');
onComplete?.();
onCompleteRef.current?.();
}, 1500);
return () => clearTimeout(timer);
}, [eggOnly, eggVisible, onComplete]);
}, [eggOnly, eggVisible]);
// Play entrance animation once
useEffect(() => {
@@ -501,14 +500,18 @@ export function BlobbiHatchingCeremony({
// Mark onboarding done
const currentProfile = profileRef.current;
if (currentProfile) {
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
if (currentProfile && user?.pubkey) {
const freshProfile = await fetchFreshBlobbonautProfile(nostr, user.pubkey);
const baseEvent = freshProfile?.event ?? currentProfile.event;
const updatedTags = updateBlobbonautTags(baseEvent.tags, {
blobbi_onboarding_done: 'true',
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: baseEvent.content ?? '',
tags: updatedTags,
prev: baseEvent,
});
updateProfileEvent(profileEvent);
}
@@ -518,7 +521,7 @@ export function BlobbiHatchingCeremony({
} catch (error) {
console.error('[HatchingCeremony] Failed to persist completion:', error);
}
}, [publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
}, [nostr, user?.pubkey, publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
// ── Naming submit ──
const handleNameSubmit = useCallback(async () => {
@@ -588,7 +591,7 @@ export function BlobbiHatchingCeremony({
className="fixed inset-0 z-50 overflow-hidden select-none"
style={{
background: showBaby
? 'radial-gradient(ellipse at 50% 45%, rgb(60,140,180) 0%, rgb(70,160,195) 25%, rgb(85,175,205) 50%, rgb(100,190,210) 75%, rgb(115,195,195) 100%)'
? revealBg
: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)',
transition: 'background 2s ease-out',
}}
@@ -606,6 +609,16 @@ export function BlobbiHatchingCeremony({
/>
)}
{/* ── Vignette shadow for reveal phase — adds depth so blobbi pops ── */}
{showBaby && (
<div
className="absolute inset-0 pointer-events-none"
style={{
background: 'radial-gradient(ellipse at 50% 45%, transparent 30%, rgba(0,0,0,0.12) 70%, rgba(0,0,0,0.25) 100%)',
}}
/>
)}
{/* ── Floating particles (egg phase) ── */}
{isEggPhase && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
@@ -16,12 +16,15 @@
*/
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { fetchFreshBlobbonautProfile } from '@/blobbi/core/lib/fetchFreshBlobbonautProfile';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
@@ -156,6 +159,7 @@ export function useBlobbiOnboarding({
adoptionOnly = false,
}: UseBlobbiOnboardingOptions): UseBlobbiOnboardingResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
// Get kind 0 metadata for name suggestion
@@ -164,7 +168,7 @@ export function useBlobbiOnboarding({
// Suggested name from kind 0: display_name > name > undefined
const suggestedName = useMemo(() => {
if (!authorData?.metadata) return undefined;
return authorData.metadata.display_name || authorData.metadata.name || undefined;
return authorData.metadata.name || authorData.metadata.display_name || undefined;
}, [authorData?.metadata]);
// ─── State ────────────────────────────────────────────────────────────────────
@@ -368,16 +372,21 @@ export function useBlobbiOnboarding({
setActionInProgress('reroll');
try {
// Fetch fresh profile from relays (read-modify-write safety)
const freshProfile = await fetchFreshBlobbonautProfile(nostr, user.pubkey);
const baseEvent = freshProfile?.event ?? profile.event;
// First, deduct coins from profile
const newCoins = coins - BLOBBI_PREVIEW_REROLL_COST;
const updatedTags = updateBlobbonautTags(profile.allTags, {
const updatedTags = updateBlobbonautTags(baseEvent.tags, {
coins: newCoins.toString(),
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: baseEvent.content ?? '',
tags: updatedTags,
prev: baseEvent,
});
updateProfileEvent(profileEvent);
@@ -422,7 +431,7 @@ export function useBlobbiOnboarding({
setActionInProgress(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- preview identity (d/seed/petId) only used for debug logs
}, [user?.pubkey, profile, coins, preview?.name, publishEvent, updateProfileEvent, invalidateProfile]);
}, [user?.pubkey, nostr, profile, coins, preview?.name, publishEvent, updateProfileEvent, invalidateProfile]);
/**
* Adopt the current preview - costs coins and creates the Blobbi event
@@ -462,20 +471,24 @@ export function useBlobbiOnboarding({
// Eggs should never be auto-assigned as the floating companion.
// NOTE: blobbi_onboarding_done is NOT set here — adoption alone does not
// complete onboarding. It is set when the first-hatch tour finishes.
const freshProfile = await fetchFreshBlobbonautProfile(nostr, user.pubkey);
const baseEvent = freshProfile?.event ?? profile.event;
const newCoins = coins - BLOBBI_ADOPTION_COST;
const newHas = [...profile.has, preview.d];
const newHas = [...(freshProfile?.has ?? profile.has), preview.d];
const profileUpdates: Record<string, string | string[]> = {
coins: newCoins.toString(),
has: newHas,
};
const updatedProfileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
const updatedProfileTags = updateBlobbonautTags(baseEvent.tags, profileUpdates);
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: baseEvent.content ?? '',
tags: updatedProfileTags,
prev: baseEvent,
});
updateProfileEvent(profileEvent);
@@ -505,7 +518,7 @@ export function useBlobbiOnboarding({
setIsProcessing(false);
setActionInProgress(null);
}
}, [user?.pubkey, profile, preview, coins, publishEvent, updateCompanionEvent, updateProfileEvent, setStoredSelectedD, invalidateProfile, invalidateCompanion, onComplete]);
}, [user?.pubkey, nostr, profile, preview, coins, publishEvent, updateCompanionEvent, updateProfileEvent, setStoredSelectedD, invalidateProfile, invalidateCompanion, onComplete]);
// ─── Return ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,51 @@
import { useState, useEffect, useRef, useCallback } from 'react';
/**
* Typewriter effect hook — reveals text character-by-character.
*
* Used by both the hatching and evolution ceremonies.
*
* @param fullText The complete string to reveal.
* @param active Start typing when true.
* @param speed Milliseconds between characters (default 35).
*/
export function useTypewriter(fullText: string, active: boolean, speed = 35) {
const [displayed, setDisplayed] = useState('');
const [done, setDone] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const indexRef = useRef(0);
// Reset when text changes
useEffect(() => {
setDisplayed('');
setDone(false);
indexRef.current = 0;
}, [fullText]);
// Run typewriter
useEffect(() => {
if (!active || done) return;
intervalRef.current = setInterval(() => {
indexRef.current++;
const next = fullText.slice(0, indexRef.current);
setDisplayed(next);
if (indexRef.current >= fullText.length) {
setDone(true);
if (intervalRef.current) clearInterval(intervalRef.current);
}
}, speed);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [active, done, fullText, speed]);
const complete = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
setDisplayed(fullText);
setDone(true);
}, [fullText]);
return { displayed, done, complete };
}
@@ -183,6 +183,7 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
// Required but not used for preview rendering
isLegacy: false,
needsSeedIdentitySync: false,
lastInteraction: preview.createdAt,
lastDecayAt: preview.createdAt,
generation: 1,
@@ -201,6 +202,7 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
progressionStartedAt: preview.createdAt,
tasks: [],
tasksCompleted: [],
evolution: [],
// We need allTags for the adapter, but preview has no extra tags
allTags: previewToEventTags(preview),
@@ -0,0 +1,34 @@
/**
* Shared color utilities for ceremony backgrounds.
*
* Used by both BlobbiHatchingCeremony and BlobbiEvolveCeremony to derive
* a soft pastel background from the blobbi's base color.
*/
/** Parse a CSS hex color (#RRGGBB) to its RGB components. */
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
const h = hex.replace('#', '');
return {
r: parseInt(h.substring(0, 2), 16),
g: parseInt(h.substring(2, 4), 16),
b: parseInt(h.substring(4, 6), 16),
};
}
/** Blend a single channel toward white (255) by `amount` (01). */
export function blendToWhite(channel: number, amount: number): number {
return Math.round(channel + (255 - channel) * amount);
}
/**
* Build a radial-gradient string from a hex color, blended toward white
* at increasing intensities for a soft pastel reveal background.
*/
export function buildRevealGradient(hexColor: string): string {
const { r, g, b } = hexToRgb(hexColor);
const stop = (amount: number) =>
`rgb(${blendToWhite(r, amount)},${blendToWhite(g, amount)},${blendToWhite(b, amount)})`;
return `radial-gradient(ellipse at 50% 45%, ${stop(0.65)} 0%, ${stop(0.68)} 25%, ${stop(0.72)} 50%, ${stop(0.76)} 75%, ${stop(0.80)} 100%)`;
}
+83 -33
View File
@@ -6,15 +6,18 @@
* Top padding accounts for the floating room header overlay.
*/
import { useMemo } from 'react';
import { useMemo, type CSSProperties } from 'react';
import {
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
Footprints, Loader2,
} from 'lucide-react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { SegmentedRing } from '@/blobbi/ui/StatIndicator';
import { getVisibleStats } from '@/blobbi/core/lib/blobbi-decay';
import { getBlobbiStatDisplayState } from '@/blobbi/core/lib/blobbi-segments';
import type { CareState } from '@/blobbi/core/lib/blobbi-segments';
import type { BlobbiCompanion, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BlobbiReactionState } from '@/blobbi/actions';
@@ -78,6 +81,8 @@ export interface BlobbiRoomHeroProps {
roomId: BlobbiRoomId;
/** Room order for dot indicators */
roomOrder?: BlobbiRoomId[];
/** Called when the user taps any stat icon to start the guide. */
onGuide?: (stat: keyof BlobbiStats) => void;
className?: string;
}
@@ -100,6 +105,7 @@ export function BlobbiRoomHero({
heroWidth,
roomId,
roomOrder = DEFAULT_ROOM_ORDER,
onGuide,
className,
}: BlobbiRoomHeroProps) {
const roomMeta = ROOM_META[roomId];
@@ -137,15 +143,15 @@ export function BlobbiRoomHero({
)}
>
<div className="relative flex flex-col items-center">
<StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />
<StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} onGuide={onGuide} />
<div
className="relative transition-all duration-500"
className={cn('relative transition-all duration-500', !isEgg && 'pointer-events-none')}
style={!isSleeping ? {
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
} : undefined}
>
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
companion={companion}
size="lg"
@@ -199,18 +205,26 @@ function StatsCrown({
companion,
currentStats,
heroWidth,
onGuide,
}: {
companion: BlobbiCompanion;
currentStats: BlobbiRoomHeroProps['currentStats'];
heroWidth: number;
onGuide?: (stat: keyof BlobbiStats) => void;
}) {
const allStats = useMemo(() =>
getVisibleStats(companion.stage).map(stat => ({
stat,
value: currentStats[stat] ?? 100,
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
color: STAT_COLOR_MAP[stat],
})),
getVisibleStats(companion.stage).map(stat => {
const value = currentStats[stat] ?? 100;
const display = getBlobbiStatDisplayState({ stage: companion.stage, stat: stat as keyof BlobbiStats, value });
return {
stat,
value,
careState: display.careState,
filled: display.filled,
max: display.max,
color: STAT_COLOR_MAP[stat],
};
}),
[companion.stage, currentStats]);
if (allStats.length === 0) return null;
@@ -226,7 +240,7 @@ function StatsCrown({
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
return (
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
<div className="relative z-10 flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40, animation: 'stat-glow-clock 2s linear infinite' }}>
{allStats.map((s, i) => {
const angleDeg = angles[i];
const angleRad = (angleDeg * Math.PI) / 180;
@@ -237,14 +251,15 @@ function StatsCrown({
return (
<div
key={s.stat}
className="absolute transition-all duration-500"
className={cn('absolute transition-all duration-500', onGuide && 'cursor-pointer')}
style={{
transform: 'translate(-50%, 0)',
left: `calc(50% + ${x.toFixed(1)}px)`,
bottom: `${y.toFixed(1)}px`,
}}
onClick={onGuide ? () => onGuide(s.stat as keyof BlobbiStats) : undefined}
>
<StatIndicator stat={s.stat} value={s.value} color={s.color} status={s.status} />
<StatIndicator stat={s.stat} value={s.value} color={s.color} careState={s.careState} filled={s.filled} max={s.max} />
</div>
);
})}
@@ -258,38 +273,73 @@ function StatIndicator({
stat,
value,
color,
status = 'normal',
careState = 'good',
filled,
max,
}: {
stat: string;
value: number | undefined;
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
status?: 'normal' | 'warning' | 'critical';
careState?: CareState;
filled?: number;
max?: number;
}) {
const displayValue = value ?? 0;
const isLow = status === 'warning' || status === 'critical';
const showBadge = careState === 'attention' || careState === 'urgent';
const showPulse = careState === 'urgent';
const badgeColor = careState === 'urgent' ? 'text-red-500' : 'text-amber-500';
const ringHex = STAT_RING_HEX[color];
const IconComponent = STAT_ICON_MAP[stat];
return (
<div className={cn(
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
)}>
const hasSegments = filled !== undefined && max !== undefined;
const isLow = careState === 'attention' || careState === 'urgent';
/* Box-shadow is driven by --stat-glow-intensity (010), animated once
on the StatsCrown parent. All icons read the same inherited value,
giving true phase-lock regardless of individual mount timing. */
const glowStyle: CSSProperties | undefined =
careState === 'attention'
? { boxShadow: '0 0 calc(var(--stat-glow-intensity) * 6px) calc(var(--stat-glow-intensity) * 2px) currentColor' }
: careState === 'urgent'
? { boxShadow: '0 0 calc(var(--stat-glow-intensity) * 10px) calc(var(--stat-glow-intensity) * 3px) currentColor' }
: undefined;
return (
<div
className={cn(
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
STAT_BG_COLORS[color],
isLow && STAT_COLORS[color],
showPulse && 'animate-pulse',
)}
style={glowStyle}
>
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
stroke={ringHex}
strokeDasharray={`${displayValue * 0.94} 100`}
className="transition-all duration-500"
/>
{hasSegments ? (
<SegmentedRing
filled={filled}
max={max}
fillHex={ringHex}
strokeWidth={2.5}
gapDeg={16}
/>
) : (
<>
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
stroke={ringHex}
strokeDasharray={`${displayValue * 0.94} 100`}
className="transition-all duration-500"
/>
</>
)}
</svg>
<div className="relative">
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
{showBadge && (
<AlertTriangle
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
className={cn('absolute -top-1.5 -right-2 size-3', badgeColor)}
strokeWidth={3}
/>
)}
@@ -20,6 +20,7 @@ import {
} from '../lib/room-config';
import {
generateInitialPoops,
addPoop as addPoopInstance,
removePoop,
type PoopInstance,
} from '../lib/poop-system';
@@ -28,9 +29,9 @@ import {
export interface PoopState {
poops: PoopInstance[];
shovelMode: boolean;
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
onRemovePoop: (poopId: string) => void;
/** Spawn a single poop (e.g. from overfeeding). */
addPoop: (source?: PoopInstance['source']) => void;
}
interface BlobbiRoomShellProps {
@@ -55,6 +56,8 @@ interface BlobbiRoomShellProps {
poopStateRef?: React.MutableRefObject<PoopState | null>;
/** Called when a poop is cleaned. Parent handles toast/XP persistence. */
onPoopCleaned?: () => void;
/** When set, the matching room-nav arrow glows to guide the user. */
guideRoomDirection?: 'left' | 'right' | null;
}
// ─── Component ────────────────────────────────────────────────────────────────
@@ -74,6 +77,7 @@ export function BlobbiRoomShell({
lastFeedTimestamp,
poopStateRef,
onPoopCleaned,
guideRoomDirection,
}: BlobbiRoomShellProps) {
const goLeft = useCallback(() => {
onChangeRoom(getPreviousRoom(roomId, roomOrder));
@@ -105,8 +109,6 @@ export function BlobbiRoomShell({
// ─── Poop system (ephemeral) ───
const [poops, setPoops] = useState<PoopInstance[]>([]);
const [shovelMode, setShovelMode] = useState(false);
useEffect(() => {
setPoops(generateInitialPoops(hunger, lastFeedTimestamp));
// Only on mount
@@ -119,14 +121,17 @@ export function BlobbiRoomShell({
if (remaining.length < prev.length) {
onPoopCleaned?.();
}
if (remaining.length === 0) setShovelMode(false);
return remaining;
});
}, [onPoopCleaned]);
const addPoop = useCallback((source: PoopInstance['source'] = 'overfeed') => {
setPoops(prev => addPoopInstance(prev, source));
}, []);
const poopState: PoopState = useMemo(() => ({
poops, shovelMode, setShovelMode, onRemovePoop,
}), [poops, shovelMode, onRemovePoop]);
poops, onRemovePoop, addPoop,
}), [poops, onRemovePoop, addPoop]);
if (poopStateRef) poopStateRef.current = poopState;
@@ -161,13 +166,15 @@ export function BlobbiRoomShell({
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
'cursor-pointer select-none',
guideRoomDirection === 'left' && 'text-primary',
)}
style={guideRoomDirection === 'left' ? { animation: 'guide-glow-slow 1.1s linear infinite' } as CSSProperties : undefined}
aria-label={`Go to ${leftDest.label}`}
>
<ChevronLeft
className="size-7 sm:size-8 shrink-0"
strokeWidth={4}
style={{ animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties}
style={guideRoomDirection !== 'left' ? { animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties : undefined}
/>
</button>
@@ -180,13 +187,15 @@ export function BlobbiRoomShell({
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
'cursor-pointer select-none',
guideRoomDirection === 'right' && 'text-primary',
)}
style={guideRoomDirection === 'right' ? { animation: 'guide-glow-slow 1.1s linear infinite' } as CSSProperties : undefined}
aria-label={`Go to ${rightDest.label}`}
>
<ChevronRight
className="size-7 sm:size-8 shrink-0"
strokeWidth={4}
style={{ animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties}
style={guideRoomDirection !== 'right' ? { animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties : undefined}
/>
</button>
</div>
+53 -5
View File
@@ -5,7 +5,7 @@
* Mobile: focused item only. Desktop: prev/next previews.
*/
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo, type CSSProperties } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -24,7 +24,11 @@ interface ItemCarouselProps {
activeItemId?: string | null;
disabled?: boolean;
onFocusChange?: (entry: CarouselEntry) => void;
/** When set, the carousel visually guides the user toward this item. */
highlightId?: string | null;
className?: string;
/** Seed the initial focused item by id (e.g. from localStorage). Falls back to index 0. */
initialItemId?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
@@ -35,15 +39,38 @@ export function ItemCarousel({
activeItemId,
disabled,
onFocusChange,
highlightId,
className,
initialItemId,
}: ItemCarouselProps) {
const [index, setIndex] = useState(0);
const [index, setIndex] = useState(() => {
if (initialItemId) {
const i = items.findIndex(item => item.id === initialItemId);
if (i !== -1) return i;
}
return 0;
});
const count = items.length;
// Reset index when items change to avoid out-of-bounds access
// Realign when initialItemId changes after mount (e.g. Blobbi switch causes
// useLocalStorage to re-read a different key).
useEffect(() => {
setIndex(0);
}, [items]);
if (!initialItemId) return;
const target = items.findIndex(item => item.id === initialItemId);
if (target !== -1) setIndex(target);
}, [initialItemId]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally omits items to avoid fighting user navigation
// Clamp or preserve index when items change.
// Only reset when the focused item no longer exists or the index is out of
// bounds — not on every reference change (which happens every render if the
// parent rebuilds the array).
useEffect(() => {
setIndex((prev) => {
if (count === 0) return 0;
if (prev < count && items[prev]) return prev; // still valid
return Math.min(prev, count - 1); // clamp to new bounds
});
}, [items, count]);
const prev = useCallback(() => {
setIndex(i => {
@@ -61,6 +88,21 @@ export function ItemCarousel({
});
}, [count, items, onFocusChange]);
// ─── Guide highlight logic ──────────────────────────────────────────────
// Determine if the highlight target is currently focused, or which arrow
// direction leads to it via the shortest path in the circular list.
const highlightArrow = useMemo<'left' | 'right' | null>(() => {
if (!highlightId || count < 2) return null;
const targetIdx = items.findIndex(i => i.id === highlightId);
if (targetIdx === -1 || targetIdx === index) return null;
const rightDist = (targetIdx - index + count) % count;
const leftDist = (index - targetIdx + count) % count;
return rightDist <= leftDist ? 'right' : 'left';
}, [highlightId, items, index, count]);
const isHighlightFocused = !!highlightId && items[index]?.id === highlightId;
if (count === 0) {
return (
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
@@ -85,7 +127,9 @@ export function ItemCarousel({
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
highlightArrow === 'left' && 'text-primary',
)}
style={highlightArrow === 'left' ? { animation: 'guide-glow-slow 1.1s linear infinite' } as CSSProperties : undefined}
aria-label="Previous item"
>
<ChevronLeft className="size-4" />
@@ -109,7 +153,9 @@ export function ItemCarousel({
'hover:bg-accent/20 active:scale-95',
isThisActive && 'bg-accent/40',
disabled && !isThisActive && 'opacity-50 pointer-events-none',
isHighlightFocused && 'ring-2 ring-primary/60',
)}
style={isHighlightFocused ? { animation: 'guide-glow-slow 1.1s linear infinite' } as CSSProperties : undefined}
>
<span className="text-4xl sm:text-5xl leading-none">{current.icon}</span>
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
@@ -134,7 +180,9 @@ export function ItemCarousel({
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
highlightArrow === 'right' && 'text-primary',
)}
style={highlightArrow === 'right' ? { animation: 'guide-glow-slow 1.1s linear infinite' } as CSSProperties : undefined}
aria-label="Next item"
>
<ChevronRight className="size-4" />
@@ -4,6 +4,7 @@
* Responsive: size-14/size-20 circle, size-7/size-9 icons.
*/
import { forwardRef } from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -16,10 +17,17 @@ interface RoomActionButtonProps {
disabled?: boolean;
loading?: boolean;
badge?: React.ReactNode;
/** When true, the button pulses with a guide-glow animation. */
glow?: boolean;
className?: string;
/** Pointer/touch event passthrough for drag interactions. */
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
onTouchStart?: React.TouchEventHandler<HTMLButtonElement>;
onTouchMove?: React.TouchEventHandler<HTMLButtonElement>;
onTouchEnd?: React.TouchEventHandler<HTMLButtonElement>;
}
export function RoomActionButton({
export const RoomActionButton = forwardRef<HTMLButtonElement, RoomActionButtonProps>(function RoomActionButton({
icon,
label,
color,
@@ -28,12 +36,22 @@ export function RoomActionButton({
disabled,
loading,
badge,
glow,
className,
}: RoomActionButtonProps) {
onMouseDown,
onTouchStart,
onTouchMove,
onTouchEnd,
}, ref) {
return (
<button
ref={ref}
onClick={onClick}
disabled={disabled}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
className={cn(
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
'hover:-translate-y-1 hover:scale-110 active:scale-95',
@@ -43,7 +61,11 @@ export function RoomActionButton({
>
<div className="relative">
<div
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
className={cn(
'size-14 sm:size-20 rounded-full flex items-center justify-center',
color,
glow && 'animate-[guide-glow_4s_ease-in-out_infinite]',
)}
style={{
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
}}
@@ -55,4 +77,4 @@ export function RoomActionButton({
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
</button>
);
}
});
@@ -0,0 +1,126 @@
/**
* RoomPoopLayer Poop rendering and shovel button components.
*
* All poops spawn in the kitchen. `PoopOverlay` renders them in every
* room so the mess follows the Blobbi around. Cleaning is only possible
* in the kitchen via `InteractivePoopOverlay` + drag-to-clean shovel.
*
* - `PoopOverlay`: display-only poop emojis, shown in all rooms
* - `InteractivePoopOverlay`: poop emojis with drag hit-test refs (kitchen)
* - `ShovelButton`: draggable shovel action button (kitchen only)
*/
import { Shovel } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from '@/hooks/useToast';
import type { PoopState } from './BlobbiRoomShell';
import type { BlobbiRoomId } from '../lib/room-config';
import { getPoopsInRoom } from '../lib/poop-system';
import { RoomActionButton } from './RoomActionButton';
import type { ShovelDrag } from '../hooks/useShovelDrag';
// ─── PoopOverlay (passive, any room) ──────────────────────────────────────────
/**
* Static poop display. Shows all poops regardless of which room they
* spawned in the mess follows the Blobbi everywhere.
*/
export function PoopOverlay({ poopStateRef }: { poopStateRef: React.MutableRefObject<PoopState | null> }) {
const poopState = poopStateRef.current;
if (!poopState || poopState.poops.length === 0) return null;
const poops = poopState.poops;
return (
<>
{poops.map((poop) => (
<div
key={poop.id}
className="absolute z-10 pointer-events-none select-none"
style={{ bottom: `${poop.position.bottom}%`, left: `${poop.position.left}%` }}
>
<span className="text-2xl sm:text-3xl block">💩</span>
</div>
))}
</>
);
}
// ─── InteractivePoopOverlay (kitchen) ─────────────────────────────────────────
/**
* Interactive poop display. Renders poops assigned to `roomId`,
* registers refs for drag hit-testing, and shows the drag ghost.
*/
export function InteractivePoopOverlay({ drag, poopStateRef, roomId }: { drag: ShovelDrag; poopStateRef: React.MutableRefObject<PoopState | null>; roomId: BlobbiRoomId }) {
const poopState = poopStateRef.current;
const poops = poopState ? getPoopsInRoom(poopState.poops, roomId) : [];
if (poops.length === 0 && !drag.isDragging) return null;
return (
<>
{poops.map((poop) => (
<div
key={poop.id}
ref={(el) => {
if (el) drag.poopRefs.current.set(poop.id, el);
else drag.poopRefs.current.delete(poop.id);
}}
className={cn(
'absolute z-10 transition-transform duration-200 pointer-events-none select-none',
drag.hoveredPoopId === poop.id && drag.isDragging && 'scale-150',
)}
style={{ bottom: `${poop.position.bottom}%`, left: `${poop.position.left}%` }}
>
<span className={cn('text-2xl sm:text-3xl block', drag.isDragging && 'drop-shadow-lg')}>
💩
</span>
</div>
))}
{drag.isDragging && drag.dragPos && (
<div
className="fixed z-[60] pointer-events-none"
style={{ left: drag.dragPos.x, top: drag.dragPos.y, transform: 'translate(-50%, -50%)' }}
>
<div className="size-14 sm:size-20 rounded-full flex items-center justify-center text-amber-600 bg-amber-500/15 ring-2 ring-amber-500/40 shadow-lg">
<Shovel className="size-7 sm:size-9" />
</div>
</div>
)}
</>
);
}
// ─── ShovelButton (kitchen only) ──────────────────────────────────────────────
interface ShovelButtonProps {
drag: ShovelDrag;
guideActionGlow?: string | null;
}
/**
* Draggable shovel action button. Kitchen only.
*/
export function ShovelButton({ drag, guideActionGlow }: ShovelButtonProps) {
return (
<RoomActionButton
ref={drag.shovelRef}
icon={<Shovel className="size-7 sm:size-9" />}
label="Shovel"
color="text-stone-500"
glowHex="#78716c"
onClick={() => {
if (!drag.anyPoop) {
toast({ title: 'Nothing to clean!', description: 'Your Blobbi hasn\'t made a mess.' });
}
}}
onMouseDown={drag.anyPoop ? drag.onMouseDown : undefined}
onTouchStart={drag.anyPoop ? drag.onTouchStart : undefined}
onTouchMove={drag.anyPoop ? drag.onTouchMove : undefined}
onTouchEnd={drag.anyPoop ? drag.onTouchEnd : undefined}
className={cn(drag.anyPoop && 'touch-none', drag.isDragging && 'opacity-30')}
glow={drag.anyPoop && guideActionGlow === 'clean'}
/>
);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* useShovelDrag Drag-to-clean state machine for the shovel interaction.
*
* Encapsulates pointer tracking, poop hit-testing, and cleanup dispatch.
* Works with both mouse (desktop) and touch (mobile).
* Used only in the kitchen where the shovel lives.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import type { PoopState } from '../components/BlobbiRoomShell';
import { hasAnyPoop } from '../lib/poop-system';
export function useShovelDrag(poopState: PoopState | null) {
const anyPoop = poopState ? hasAnyPoop(poopState.poops) : false;
const [isDragging, setIsDragging] = useState(false);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const shovelRef = useRef<HTMLButtonElement>(null);
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
const poopRefs = useRef<Map<string, HTMLElement>>(new Map());
const [hoveredPoopId, setHoveredPoopId] = useState<string | null>(null);
const startDrag = useCallback((clientX: number, clientY: number) => {
if (!anyPoop || !shovelRef.current) return;
const rect = shovelRef.current.getBoundingClientRect();
dragOffsetRef.current = {
x: clientX - (rect.left + rect.width / 2),
y: clientY - (rect.top + rect.height / 2),
};
setDragPos({
x: clientX - dragOffsetRef.current.x,
y: clientY - dragOffsetRef.current.y,
});
setIsDragging(true);
}, [anyPoop]);
const moveDrag = useCallback((clientX: number, clientY: number) => {
if (!isDragging) return;
setDragPos({
x: clientX - dragOffsetRef.current.x,
y: clientY - dragOffsetRef.current.y,
});
let found: string | null = null;
poopRefs.current.forEach((el, id) => {
const r = el.getBoundingClientRect();
if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
found = id;
}
});
setHoveredPoopId(found);
}, [isDragging]);
const endDrag = useCallback(() => {
if (!isDragging) return;
if (hoveredPoopId && poopState) {
poopState.onRemovePoop(hoveredPoopId);
}
setIsDragging(false);
setDragPos(null);
setHoveredPoopId(null);
}, [isDragging, hoveredPoopId, poopState]);
// Mouse: global listeners while dragging
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
startDrag(e.clientX, e.clientY);
}, [startDrag]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => moveDrag(e.clientX, e.clientY);
const onUp = () => endDrag();
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [isDragging, moveDrag, endDrag]);
// Touch: stopPropagation prevents BlobbiRoomShell swipe navigation
const onTouchStart = useCallback((e: React.TouchEvent) => {
e.stopPropagation();
startDrag(e.touches[0].clientX, e.touches[0].clientY);
}, [startDrag]);
const onTouchMove = useCallback((e: React.TouchEvent) => {
e.stopPropagation();
moveDrag(e.touches[0].clientX, e.touches[0].clientY);
}, [moveDrag]);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
e.stopPropagation();
endDrag();
}, [endDrag]);
return {
anyPoop,
isDragging,
dragPos,
hoveredPoopId,
shovelRef,
poopRefs,
onMouseDown,
onTouchStart,
onTouchMove,
onTouchEnd,
};
}
export type ShovelDrag = ReturnType<typeof useShovelDrag>;
+5
View File
@@ -17,7 +17,10 @@ export { ROOM_BOTTOM_BAR_CLASS } from './lib/room-layout';
export {
type PoopInstance,
XP_PER_POOP,
OVERFEED_THRESHOLD,
OVERFEED_CHANCE,
generateInitialPoops,
addPoop,
getPoopsInRoom,
removePoop,
hasAnyPoop,
@@ -27,3 +30,5 @@ export { RoomActionButton } from './components/RoomActionButton';
export { ItemCarousel, type CarouselEntry } from './components/ItemCarousel';
export { BlobbiRoomHero, type BlobbiRoomHeroProps } from './components/BlobbiRoomHero';
export { BlobbiRoomShell, type PoopState } from './components/BlobbiRoomShell';
export { useShovelDrag, type ShovelDrag } from './hooks/useShovelDrag';
export { PoopOverlay, InteractivePoopOverlay, ShovelButton } from './components/RoomPoopLayer';
+26 -7
View File
@@ -2,6 +2,7 @@
* Ephemeral poop system.
*
* Generated on page mount based on hunger + time since last feed.
* Additional poops can be spawned reactively (e.g. overfeeding).
* No persistence -- purely local React state.
*/
@@ -19,11 +20,12 @@ export interface PoopInstance {
// ─── Constants ────────────────────────────────────────────────────────────────
const OVERFEED_THRESHOLD = 95;
export const OVERFEED_THRESHOLD = 95;
/** Probability (0-1) that overfeeding produces a poop. */
export const OVERFEED_CHANCE = 0.4;
const HOURS_PER_POOP = 2;
export const XP_PER_POOP = 5;
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'rest'];
const MAX_POOPS = 3;
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
{ bottom: 22, left: 8 },
@@ -53,7 +55,7 @@ export function generateInitialPoops(
const now = Date.now();
let posIndex = 0;
if (hunger >= OVERFEED_THRESHOLD) {
if (hunger >= OVERFEED_THRESHOLD && Math.random() < OVERFEED_CHANCE) {
poops.push({
id: nextPoopId(),
room: 'kitchen',
@@ -65,12 +67,11 @@ export function generateInitialPoops(
if (lastFeedTimestamp) {
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
const count = Math.min(Math.floor(hoursSinceFeed / HOURS_PER_POOP), 3);
const count = Math.min(Math.floor(hoursSinceFeed / HOURS_PER_POOP), MAX_POOPS - poops.length);
for (let i = 0; i < count; i++) {
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
poops.push({
id: nextPoopId(),
room,
room: 'kitchen',
source: 'time',
createdAt: now - i * 1000,
position: pickPosition(posIndex++),
@@ -81,6 +82,24 @@ export function generateInitialPoops(
return poops;
}
/** Add a single poop in the kitchen (capped at MAX_POOPS). */
export function addPoop(
poops: PoopInstance[],
source: PoopInstance['source'] = 'overfeed',
): PoopInstance[] {
if (poops.length >= MAX_POOPS) return poops;
return [
...poops,
{
id: nextPoopId(),
room: 'kitchen',
source,
createdAt: Date.now(),
position: pickPosition(poops.length),
},
];
}
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
return poops.filter(p => p.room === room);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Stat Guide Configuration mappings and helpers for the stat-guided UX.
*
* Centralises:
* stat target room
* stat target type (item vs action)
* stat first eligible item (catalog order)
* GuideTarget builder
*/
import type { BlobbiStats } from '@/blobbi/core/types/blobbi';
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import type { BlobbiRoomId } from './room-config';
import { DEFAULT_ROOM_ORDER } from './room-config';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Guide target type ────────────────────────────────────────────────────────
export interface GuideTarget {
/** The stat that triggered the guide */
stat: keyof BlobbiStats;
/** Room the user needs to navigate to */
targetRoom: BlobbiRoomId;
/** Whether the guide targets a carousel item or a room action (e.g. sleep) */
targetType: 'item' | 'action';
/** Shop item ID when targetType is 'item' */
targetItemId: string | null;
/** Action name when targetType is 'action' */
targetAction: string | null;
/** Current step: navigate to room, then highlight item or action */
step: 'room' | 'item' | 'action';
}
// ─── Static mappings ──────────────────────────────────────────────────────────
/** Which room the user should visit for each stat. */
export const STAT_ROOM_MAP: Record<keyof BlobbiStats, BlobbiRoomId> = {
health: 'care',
hygiene: 'care',
hunger: 'kitchen',
happiness: 'home',
energy: 'rest',
};
/** Whether the guide targets a carousel item or a room action. */
export const STAT_GUIDE_TYPE: Record<keyof BlobbiStats, 'item' | 'action'> = {
health: 'item',
hygiene: 'item',
hunger: 'item',
happiness: 'item',
energy: 'action',
};
/** Action name for action-type guides. */
export const STAT_GUIDE_ACTION: Partial<Record<keyof BlobbiStats, string>> = {
energy: 'sleep',
};
// ─── Room carousel item constraints ───────────────────────────────────────────
/**
* Which shop item types actually appear in each room's carousel.
* This must stay in sync with the room bar components in BlobbiPage.
*/
const ROOM_CAROUSEL_TYPES: Record<BlobbiRoomId, ShopItemCategory[]> = {
home: ['toy'],
kitchen: ['food'],
care: ['hygiene', 'medicine'],
rest: [],
closet: [],
};
/**
* Item IDs excluded from a room's carousel even if their type matches.
* CareBar excludes hyg_towel from the carousel (it's a side button).
*/
const ROOM_CAROUSEL_EXCLUDED: Partial<Record<BlobbiRoomId, Set<string>>> = {
care: new Set(['hyg_towel']),
};
// ─── First eligible item finder ───────────────────────────────────────────────
/**
* Returns the ID of the first live shop item with a positive effect on `stat`
* that actually appears in the target room's carousel.
*
* Scans in catalog order (matching real carousel display order).
* Returns null for action-type stats (energy) or if no eligible item exists.
*/
function findGuideItemForStat(stat: keyof BlobbiStats): string | null {
if (STAT_GUIDE_TYPE[stat] !== 'item') return null;
const room = STAT_ROOM_MAP[stat];
const allowedTypes = ROOM_CAROUSEL_TYPES[room];
const excluded = ROOM_CAROUSEL_EXCLUDED[room];
const item = getLiveShopItems().find(
(i) =>
i.effect &&
(i.effect[stat] ?? 0) > 0 &&
allowedTypes.includes(i.type) &&
(!excluded || !excluded.has(i.id)),
);
return item?.id ?? null;
}
// ─── Guide target builder ─────────────────────────────────────────────────────
/**
* Build a `GuideTarget` for a stat, automatically resolving the correct
* room, target type, item/action, and initial step.
*
* `currentRoom` determines whether the guide starts at the 'room' step
* (user needs to navigate) or skips directly to 'item'/'action'.
*/
export function buildGuideTarget(
stat: keyof BlobbiStats,
currentRoom: BlobbiRoomId,
): GuideTarget {
const targetRoom = STAT_ROOM_MAP[stat];
const targetType = STAT_GUIDE_TYPE[stat];
const alreadyInRoom = currentRoom === targetRoom;
return {
stat,
targetRoom,
targetType,
targetItemId: targetType === 'item' ? findGuideItemForStat(stat) : null,
targetAction: targetType === 'action' ? (STAT_GUIDE_ACTION[stat] ?? null) : null,
step: alreadyInRoom ? targetType : 'room',
};
}
// ─── Room direction helper ────────────────────────────────────────────────────
/**
* Compute the shortest navigation direction from `current` to `target`
* within the circular room order. Returns 'left' or 'right', or null
* if already at the target room.
*
* When equidistant (only possible with even-length order), prefers 'right'.
*/
export function getGuideRoomDirection(
current: BlobbiRoomId,
target: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): 'left' | 'right' | null {
if (current === target) return null;
const ci = order.indexOf(current);
const ti = order.indexOf(target);
if (ci === -1 || ti === -1) return null;
const len = order.length;
// Distance going right (next, next, …)
const rightDist = (ti - ci + len) % len;
// Distance going left (prev, prev, …)
const leftDist = (ci - ti + len) % len;
if (rightDist <= leftDist) return 'right';
return 'left';
}
@@ -1,239 +0,0 @@
import { useMemo } from 'react';
import { Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { ShopItem } from '../types/shop.types';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn } from '@/lib/utils';
import { ItemEffectDisplay } from './ItemEffectDisplay';
interface BlobbiInventoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
profile: BlobbonautProfile | null;
/** The current companion (needed for stage-based restrictions) */
companion: BlobbiCompanion | null;
/** Called when user wants to use an item. Always uses once. */
onUseItem?: (itemId: string) => void;
/** Whether an item is currently being used */
isUsingItem?: boolean;
}
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
canUse: boolean;
reason?: string;
}
// ── Shared items content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
export function BlobbiInventoryContent({
profile: _profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryContentProps) {
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
const stage = companion?.stage ?? 'egg';
const allItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const item of allItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [companion?.stage]);
const isEmpty = inventoryItems.length === 0;
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
onUseItem(item.itemId);
};
return (
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for your Blobbi's current stage.
</p>
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
</div>
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleUseItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
{isUsingItem ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{item.type}
</Badge>
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ── Standalone Inventory Modal (kept for backwards compatibility) ──
export function BlobbiInventoryModal({
open,
onOpenChange,
profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</div>
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto">
<BlobbiInventoryContent
profile={profile}
companion={companion}
onUseItem={onUseItem}
isUsingItem={isUsingItem}
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,182 +0,0 @@
import { useState, useMemo } from 'react';
import { Loader2, Minus, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import type { ShopItem } from '../types/shop.types';
import { formatCompactNumber } from '@/lib/utils';
interface BlobbiPurchaseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ShopItem;
availableCoins: number;
onPurchase: (quantity: number) => void;
isPurchasing: boolean;
}
export function BlobbiPurchaseDialog({
open,
onOpenChange,
item,
availableCoins,
onPurchase,
isPurchasing,
}: BlobbiPurchaseDialogProps) {
const [quantity, setQuantity] = useState(1);
// Calculate max affordable quantity
const maxAffordable = useMemo(() => {
return Math.min(Math.floor(availableCoins / item.price), 999);
}, [availableCoins, item.price]);
// Calculate total cost
const totalCost = useMemo(() => {
return item.price * quantity;
}, [item.price, quantity]);
// Check if current selection is affordable
const isAffordable = totalCost <= availableCoins;
// Handle quantity changes
const handleIncrease = () => {
setQuantity(prev => Math.min(prev + 1, maxAffordable));
};
const handleDecrease = () => {
setQuantity(prev => Math.max(prev - 1, 1));
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxAffordable));
}
};
const handlePurchase = () => {
onPurchase(quantity);
// Reset quantity after purchase
setQuantity(1);
};
// Reset quantity when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setQuantity(1);
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>Purchase Item</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 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-4xl sm:text-5xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base sm:text-lg truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{formatCompactNumber(item.price)} coins each
</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: {maxAffordable}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleDecrease}
disabled={quantity <= 1 || isPurchasing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxAffordable}
value={quantity}
onChange={handleInputChange}
disabled={isPurchasing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={handleIncrease}
disabled={quantity >= maxAffordable || isPurchasing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Total Cost */}
<div className="p-3 sm:p-4 rounded-lg bg-gradient-to-r from-yellow-500/10 to-amber-500/10 border border-yellow-500/20">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">Total Cost</span>
<span className="text-base sm:text-lg font-bold whitespace-nowrap">
{formatCompactNumber(totalCost)} coins
</span>
</div>
{!isAffordable && (
<p className="text-xs text-destructive mt-2">
Insufficient coins! You need {formatCompactNumber(totalCost - availableCoins)} more.
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPurchasing}
>
Cancel
</Button>
<Button
onClick={handlePurchase}
disabled={!isAffordable || isPurchasing}
className="min-w-32"
>
{isPurchasing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Purchasing...
</>
) : !isAffordable ? (
'Insufficient Coins'
) : (
`Purchase (×${quantity})`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

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