Compare commits

...

566 Commits

Author SHA1 Message Date
Alex Gleason 4c201cc2d3 release: v2.2.8 2026-04-01 18:39:05 -05:00
Alex Gleason c30a6a7bcd Merge branch 'remove-egg-task' into 'main'
Remove egg maintain_stats task and hide companion button for eggs

See merge request soapbox-pub/ditto!151
2026-04-01 23:15:11 +00:00
Alex Gleason c4354774ad Add Color Moments and Geocaching to community NIP specs, sort table by kind number 2026-04-01 18:05:52 -05:00
Alex Gleason 8a44f77fb1 Add community NIP specs to NIP.md (Letters, Weather, Blobbi)
Add a Community Kinds section to the overview table and a Community
NIP Specifications section with summaries and links to the full specs
maintained by Chad Curtis, Sam Thomson, and Danifra.
2026-04-01 17:56:50 -05:00
Chad Curtis 9ebd9a304f Fix notification dot not clearing after marking as read
setQueryData requires an exact query key match, but the unread
notifications query uses a 4-element key (prefix, pubkey, kindsKey,
authorsKey). The markAsRead callback was calling setQueryData with only
2 elements, silently missing the cache entry. Switch to setQueriesData
which uses prefix matching, correctly hitting the real cache entry.
2026-04-01 16:24:23 -05:00
filemon b223a9c1f2 Remove egg maintain_stats task and hide companion button for eggs
Egg stats no longer decay, so the 'Keep Egg Healthy' dynamic task is
unnecessary and misleading. Remove it along with HATCH_STAT_THRESHOLD.
The baby/adult 'Peak Condition' evolve task is unchanged.

Also hide the 'Set as Companion' button entirely for eggs instead of
rendering it as disabled.
2026-04-01 15:03:29 -03:00
Alex Gleason 6be49ec14a Merge branch 'hide-all-status' into 'main'
Centralize stat visibility threshold (<70) across all Blobbi stages

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

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

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

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

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

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

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

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

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

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

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

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

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

Fix: split the single wrapper into two nested divs:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three root causes fixed:

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

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

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

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

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

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

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

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

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

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

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

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

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

The key insight is that sleepy has two visual effects:
1. Eye position animation (CSS transform) - now disabled for external gaze
2. Eyelid closing animation (SMIL clip-path) - preserved for drowsy look
2026-03-30 19:06:49 -03:00
Alex Gleason cf59b6d0da Fix crash on /notifications from malformed badge award a-tags
Validate that the pubkey extracted from a kind 8 badge award event's
a-tag is a valid 64-char hex string before passing it to
nip19.naddrEncode(). Malformed pubkeys (from permissionless Nostr
events) caused hexToBytes() to throw 'Invalid byte sequence'.
2026-03-30 16:57:25 -05:00
filemon d836b1f068 fix(blobbi): use RAF loop in useExternalEyeOffset to prevent stuck eyes
The previous useEffect-based approach only applied eye transforms when the
externalEyeOffset prop changed. This caused eyes to get stuck in the center
when the companion was idle because:

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

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

Dragging previously 'fixed' the stuck eyes because isDragging changes
caused guaranteed re-renders that triggered the old useEffect.
2026-03-30 18:38:23 -03:00
Alex Gleason 6071a28dd9 Include all badge awards in deletion request when deleting a badge definition 2026-03-30 16:28:07 -05:00
filemon 03fa16ded2 fix(blobbi): fix companion motion/state desync and gaze RAF instability
BUGS FIXED:

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

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

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

ARCHITECTURE CHANGES:
- useBlobbiCompanion: Creates shared motionRef, passes to state and motion hooks
- useBlobbiCompanionMotion: Accepts optional sharedMotionRef, syncs motion to it
- useBlobbiCompanionState: Receives motionRef instead of motion object
- useBlobbiCompanionGaze: Uses refs for position/target values, stable RAF loop
2026-03-30 18:14:14 -03:00
Alex Gleason b8eb0a8549 Filter out malformed custom emoji reactions missing the emoji tag 2026-03-30 15:49:25 -05:00
filemon 5dac0214ea fix(blobbi): improve companion reaction animations and remove duplicate hooks
- Fix reaction logic in BlobbiCompanionVisual: now uses 'swaying' when walking
  instead of always returning 'idle' (previously dead code)
- Remove duplicate useBlobbiCompanion hooks from src/hooks/ and src/blobbi/core/hooks/
  (orphaned files, not imported anywhere)
- Verified lookMode='forward' does NOT block externalEyeOffset - eye tracking
  system correctly uses external offset when disableTracking is true
2026-03-30 16:57:01 -03:00
filemon 3ddb7c8ceb Merge branch 'main' into implement-reactions-clean 2026-03-30 16:35:29 -03:00
Alex Gleason 03d4b6c4f2 Include both 'e' and 'a' tags in deletion events for addressable events
NIP-09 deletion events for addressable events (kinds 30000-39999) now
include both an 'e' tag (event ID) and an 'a' tag (event coordinate)
to ensure deletion works on relays that only support one or the other.

- useDeleteEvent: accept optional pubkey/dTag params, auto-add 'a' tag
  for addressable kinds
- NoteMoreMenu: pass event pubkey and d-tag to useDeleteEvent
- BadgesPage: add missing 'e' tag to badge definition deletion
- useUserLists: add missing 'e' tag to list deletion
2026-03-30 13:47:05 -05:00
filemon 55b551f214 fix(blobbi): companion now updates reactively when stats change
ROOT CAUSE:
The companion layer was not updating live because:
1. It used a separate query ('companion-blobbi') that wasn't optimistically updated
2. It didn't use projected state, so it showed raw relay data without decay
3. Item use only invalidated queries without optimistic updates, causing relay latency

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

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

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

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

NO REMOUNT KEY NEEDED:
The fix works purely through React's normal reactivity:
- Object references change through the memo chain
- useStatusReaction's effect detects stat changes via reference comparison
- No forced remounts required
2026-03-30 15:38:53 -03:00
Alex Gleason 6fe17c1cfd Fix notifications collapsing multiple profile reactions from the same user
Profile reactions (kind 7 on kind 0) are intentionally allowed to be
multiple, unlike post reactions. Treat each profile reaction as a
standalone notification instead of grouping by referenced event ID,
which was causing only the latest reaction per user to be shown.
2026-03-30 13:19:00 -05:00
filemon 93ccb572e5 fix(blobbi): alternating spiral winding, body-aware food icon positioning
- Fix egg sick spiral winding: Inner 3 now uses clockwise=false for
  proper alternation across all 7 spirals (4 outer + 3 inner)
- Add body-aware food icon positioning for adults using detectBodyPath()
- Food icon now placed at upper-left relative to detected body bounds
- Update FoodIconConfig type to accept bodyPath for shape-aware placement
- Import detectBodyPath in recipe.ts for food icon positioning
2026-03-30 15:15:34 -03:00
Alex Gleason 03aa1e6dbc Fix oversized reaction emoji in comment context header 2026-03-30 13:03:25 -05:00
filemon 059fb67d26 fix(blobbi): layered egg spirals, natural adult dirt distribution, adult food icon
## Egg spiral layering:
- Added 7 spirals total (4 outer + 3 inner) for magical/dizzy effect
- Outer spirals: float around egg shell at varying distances
- Inner spirals: subtle spirals across the egg body itself
- Mixed colors: gray (#4b5563, #6b7280, #9ca3af) + white accents
- Varying sizes (0.45em to 1.1em), speeds (2s to 4s), directions
- All use true Archimedean spiral paths matching Blobbi dizzy eyes
- Counter-clockwise on left side, clockwise on right for visual balance

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

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

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

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

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

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

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

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

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

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

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

## Adult Form Dirt Placement

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

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

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

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

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

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

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

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

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

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

No priority changes needed — current rules work well.
2026-03-30 13:32:03 -03:00
Alex Gleason ec24c4cfae Add zap option to profile 3-dots menu
Move zap functionality into the ProfileMoreMenu so users with a
lightning address can still be zapped. The menu row opens the ZapDialog
after the more menu closes via a hidden trigger ref.
2026-03-30 11:25:03 -05:00
filemon 002461e7cb refactor(blobbi): refine expression quality, severity behavior, and drool positioning
## Hunger Progression
- warning: hopeful/asking (small round 'ooh' mouth, mild pleading brows)
- high: needy (bigger round mouth, more worried brows)
- critical: weak/desperate (droopy pleading mouth, very worried brows)

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

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

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

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

## Priority Order (unchanged)
Eyes: health(critical) > energy > happiness > hunger > hygiene
Mouth: energy > health > happiness > hunger > hygiene
Eyebrows: health > hunger > happiness > hygiene > energy
2026-03-30 13:21:47 -03:00
Alex Gleason d12e75ae5c Replace zap button with emoji reaction button on user profiles
Add ProfileReactionButton component that opens an emoji picker to send
kind 7 reactions to a user's profile with a, e, and p tags. Update
notifications to display 'reacted to your profile' for profile reactions
and skip rendering the referenced card for kind 0 events.
2026-03-30 11:06:32 -05:00
filemon a480379fa5 refactor(blobbi): refine emotion presets and part contributions for natural pet-like expressions
- Update EMOTION_RECIPES with better documentation and design principles
- Add design comments explaining each preset's purpose and feeling
- Refine hungry preset: pleading/hopeful (not sad) with round anticipating mouth
- Refine dirty preset: uncomfortable/irritated grimace with furrowed brows
- Refine dizzy preset: add distressed raised brows for urgency
- Update ENERGY_PARTS: emphasize relaxed drowsiness, no forced brows
- Update HEALTH_PARTS: weak/unwell feeling distinct from sadness
- Update HUNGER_PARTS: pleading expression with open 'ooh food?' mouth
- Update HYGIENE_PARTS: irritated grimace, slightly furrowed brows
- Update HAPPINESS_PARTS: genuine emotional sadness with progressive tears

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

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

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

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

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

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

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

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

- Remove unused BodyEffectConfig import from recipe.ts (pre-existing
  eslint error).
2026-03-30 12:14:40 -03:00
Alex Gleason 67e8c23020 release: v2.2.3 2026-03-30 09:59:34 -05:00
Alex Gleason 94f0c8308d Show all sidebar items to logged-out users and update sidebar order
Stop filtering requiresAuth items from navigation. Pages already render
their own LoginArea when the user is not logged in, so hiding the items
from the sidebar, mobile drawer, and search prevented feature discovery
without providing any benefit.

Also update the default sidebar order: remove bookmarks and profile,
add letters.
2026-03-30 09:56:02 -05:00
filemon c77b68eed2 fix(blobbi): eliminate body effects duplication, fix stat recovery, improve merged label timing
- Remove bodyEffects from StatusRecipeResult and useStatusReaction output.
  Body effects are folded into recipe.bodyEffects by resolveStatusRecipe()
  and applied once by applyVisualRecipe(). No separate channel needed.

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

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

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

- Update doc comments across visual components to clarify that the
  bodyEffects prop is for manual/external use only, not for status
  reaction data.
2026-03-30 11:52:00 -03:00
Chad Curtis 80820ae9c4 Expand compose textarea smoothly as you type 2026-03-30 07:36:16 -05:00
Chad Curtis 5288b7a718 Letters: overflow menu, reply button, grid layout, and UX polish
- EnvelopeCard: add ... overflow menu trigger using NoteMoreMenu (no duplicated logic)
- NoteMoreMenu: detect NIP-44 ciphertext by content shape, show 'Encrypted content'
- LetterDetailSheet: reply button (InkPenIcon, primary colors); gift above card; letter centered in viewport; click outside closes
- LettersPage: reply pre-fills sender npub; default stationery falls back to parchment when no custom theme; 2-col mobile / 4-col desktop grid
- NotificationsPage: reply button next to 'View all letters' in letter notifications
- ComposeLetterSheet: auto-focus textarea on open; fix To field overflow on narrow screens
- InkPenIcon: new custom icon replacing PenLine on FAB and reply buttons
2026-03-30 06:21:50 -05:00
Chad Curtis 4643830512 Sync letter improvements from lief
- Make LetterContent.body optional; a letter requires a non-empty body
  or at least one sticker
- Replace colors:[]/flatMode with event-stripping: flat color moment =
  event field stripped from Stationery, removing the colors? field
- Remove edgeScale: stickers render at their stored scale value with no
  edge-proximity size reduction, matching lief and the NIP
- Fix sticker shrinking near card edges: add max-width:none to override
  Tailwind preflight max-width:100% on img/svg elements
2026-03-30 04:30:00 -05:00
Chad Curtis ef04de67c0 Improve notification rendering for badges and letters
- Add hideKindHeader prop to NoteCard, used by ReferencedNoteCard to
  suppress redundant action headers in repost/reaction/zap notifications
- Redesign badge award notification: show full BadgeContent showcase card
  with prominent rounded-pill Accept Badge button below
- Redesign letter notification: larger centered envelope (minimal mode
  hides name/timestamp), click opens LetterDetailSheet inline, View All
  Letters button below
2026-03-30 00:13:01 -05:00
Chad Curtis 5847cceba6 Auto-shrink stickers near card edges and clip overflow at rounded boundary
Stickers now scale down proportionally as their center approaches any
edge of the letter card, preventing them from overflowing the rounded
corners. A 5% drag buffer keeps sticker centers away from the very
edge. The sticker overlay uses pointer-events-none so the textarea
remains clickable underneath.
2026-03-29 23:53:20 -05:00
Chad Curtis f62b86027c Remove unused eslint-disable directive in LayoutContext 2026-03-29 22:59:41 -05:00
Chad Curtis 8e5018d3b2 Fix 3 letter bugs: allow drawing-only sends, fix sticker drag bounds, preserve theme events
- Allow sending letters with only stickers/drawings (no body text required)
- Fix sticker drag positioning by using cardRef instead of page container
  ref, so percentage coordinates map correctly to the card boundaries
- Preserve stationery source events (color moments/themes) in preferences
  so they embed as gift attachments in sent letters
- Accept sticker-only letters during decryption validation
2026-03-29 22:41:03 -05:00
Chad Curtis 8df17f5ae7 Fix top bar arc flash by deferring layout cleanup past Suspense boundary 2026-03-29 22:23:23 -05:00
Alex Gleason dd31ce681f Add Blobbi to default sidebar order after Badges 2026-03-29 22:05:31 -05:00
filemon fd9a963b27 refactor(blobbi): complete recipe-first architecture, remove secondaryEmotion
Finish the migration from emotion-name composition to final visual
recipe resolution throughout the rendering pipeline.

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

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

Key changes:
- New recipe.ts with BlobbiVisualRecipe types, EMOTION_RECIPES, and
  applyVisualRecipe() rendering pipeline
- emotions.ts becomes a thin public API delegating to recipe.ts
- Remove base/overlay emotion stacking model from status-reactions.ts
  and useStatusReaction.ts in favor of single emotion + secondaryEmotion
  for recipe-level merging
- Visual components (Adult, Baby, Stage) now resolve and merge recipes
  in a single pass instead of calling applyEmotion() twice
- Companion components updated to use secondaryEmotion prop
- All existing emotion presets preserved with identical visual output
- Backward-compatible: applyEmotion() API unchanged, legacy type aliases
  provided for EmotionConfig and EMOTION_CONFIGS
2026-03-29 23:16:25 -03:00
filemon bc4e00520e feat(blobbi): use stable idPrefix for body effect SVG element IDs
- applyEmotion now accepts optional instanceId parameter (5th arg)
- instanceId is passed through to applyBodyEffects as idPrefix
- BlobbiAdultVisual and BlobbiBabyVisual now pass blobbi.id as instanceId
- Anger-rise clip paths and gradients now use blobbi.id for stable IDs
  (e.g., blobbi-anger-clip-abc123 instead of random suffix)
- Random fallback still exists when instanceId is not provided
- Same Blobbi instance now produces deterministic SVG output
2026-03-29 22:03:00 -03:00
filemon d777d1bc98 refactor(blobbi): make bodyEffects/apply.ts the single entry point for body effects
- emotions.ts now delegates all body effects to applyBodyEffects()
- Removed direct imports of detectBodyPath, generateAngerRiseEffect,
  generateDirtMarks, generateStinkClouds from emotions.ts
- emotions.ts now only imports applyBodyEffects and BodyEffectsSpec
- Added unique ID generation for anger-rise clip paths and gradients
  (prevents collisions when multiple Blobbis render on same page)
- Body effects are applied after face overlays via single applyBodyEffects call
- Anger-rise overlay is still inserted right after body path for z-ordering
2026-03-29 21:53:30 -03:00
filemon 4cd97124da refactor(eyebrows): centralize class names and form offsets
- Add EYEBROW_CLASSES constant with all CSS class names
- Add FORM_EYEBROW_OFFSETS map for owli/froggi adjustments
- Rename keyframe from 'eyebrow-bounce' to 'blobbi-eyebrow-bounce'
- Export both constants from index.ts
- No behavior change, same public API
2026-03-29 21:08:42 -03:00
Alex Gleason 74345fdb2f Fix feed gaps when replies are disabled by over-fetching from relay
When followsFeedShowReplies is false, the relay limit was PAGE_SIZE (15)
but client-side reply filtering could discard most events, leaving only
a few visible posts per page with large time gaps between them.

Apply the same over-fetch pattern already used by useProfileFeed:
- Request PAGE_SIZE * 3 events when reply filtering is active
- Use rawCount (pre-filter) for pagination termination so pages where
  all items are replies don't prematurely stop pagination
2026-03-29 18:51:20 -05:00
filemon 7e7abdee3d simplify sleepy mouth to direct replacement
- Sleepy mouth is now clearly documented as a canonical standalone shape
- Direct replacement: no morph, transition, or interpolation between mouth states
- Keeps MouthAnchor architecture for stable positioning
- Updated docs across mouth/, types, and emotions.ts to be consistent
- Removed any wording suggesting transitions or morphing
2026-03-29 20:49:42 -03:00
filemon 9ed2127494 Introduce MouthAnchor for stable sleepy mouth positioning
applySleepyMouth no longer calls detectMouthPosition internally.
Instead, the orchestrator derives a MouthAnchor from the original
neutral SVG during the detection phase and passes it through.

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

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

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

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

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

What changed:

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

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

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

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

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

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

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

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

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

emotions.ts (2135 → 628 lines):
- Now a pure orchestrator: recipe definitions + composition logic
- Imports all implementations from subsystem modules
- Contains only: BlobbiEmotion type, EMOTION_CONFIGS recipes,
  applyEmotion() orchestrator, tear generation (cross-cutting overlay),
  sleepy animation coordination (cross-cutting, touches eyes + mouth),
  and deprecated re-exports for backward compatibility
- No more detection internals, SVG geometry generators, or effect
  implementations
2026-03-29 19:55:27 -03:00
Alex Gleason 02231ea1f9 Fix avatar shape flash by computing mask URL synchronously
The Avatar component was initializing maskUrl as '' and loading it in a
useEffect. Since hasCustomShape was true immediately, rounded-full was
removed on the first render, but the mask wasn't applied until after the
effect fired — causing a visible square flash for one frame.

getAvatarMaskUrl is already synchronous (renders emoji to canvas, caches
the data-URL), so compute it inline during render instead of deferring
to an effect. The mask is now applied on the very first paint.
2026-03-29 17:47:57 -05:00
Alex Gleason effc704613 Fix 'Cannot update component while rendering' warning in useLayoutOptions
useLayoutOptions was calling store.setOptions() synchronously during
render, which triggered useSyncExternalStore listeners in MobileBottomNav
(and MainLayout) while Index was still rendering.

Move the store update into useLayoutEffect, which fires synchronously
after commit but before browser paint — same visual result without
violating React's setState-during-render rule.
2026-03-29 17:43:55 -05:00
Alex Gleason 51fb1fd1cb Give comments (kind 1111) and generic reposts (kind 16) independent feed toggles
Previously comments shared feedKey 'feedIncludePosts' with kind 1, and
generic reposts shared 'feedIncludeReposts' with kind 6. This made it
impossible to toggle them independently in settings.

Add feedIncludeComments and feedIncludeGenericReposts to FeedSettings
and wire them to their respective EXTRA_KINDS entries.
2026-03-29 17:33:56 -05:00
Alex Gleason 1988e1b849 Fix duplicate React keys in content settings
Multiple ExtraKindDef entries share the same feedKey (e.g. posts/comments
both use feedIncludePosts) and multiple subKinds share the same showKey
(e.g. both video sub-kinds use showVideos). Using these as React keys
caused 'duplicate key' warnings.

Use def.id (always unique) for ContentTypeRow keys and sub.feedKey
(unique per sub-kind) for SubKindRow keys.
2026-03-29 17:29:45 -05:00
Alex Gleason 1b782f65d1 Display QR code in terminal for NIP-46 auth script 2026-03-29 16:58:22 -05:00
Alex Gleason 6c4eddece7 Add NIP-46 client-initiated auth script for Zapstore CI signing 2026-03-29 16:47:36 -05:00
filemon cf0524a211 Extract composable visual modules and decouple dirty from face emotions
Foundation for migrating the monolithic emotion system toward a composable
architecture where each visual area (eyes, mouth, eyebrows, body effects)
is handled independently.

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

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

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

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

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

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

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

Companion system:
- CompanionData now carries full BlobbiStats and state.
- BlobbiCompanionLayer runs its own useStatusReaction to drive the
  companion's emotions from stats, including item-use action overrides.
- BlobbiCompanion and BlobbiCompanionVisual accept baseEmotion + emotion
  props and forward them to the underlying visual components.
2026-03-29 17:27:17 -03:00
Alex Gleason f6c08f8afa Add badge list recovery dialog for kind 10008 events
Allow users to browse and restore previous versions of their accepted
badges (profile badges) from relay history, matching the existing
mute list recovery pattern in /settings/content.
2026-03-29 15:22:51 -05:00
Alex Gleason 3197c53fcc Fix PageSkeleton to match actual page layout structure
The lazy-loading skeleton was missing center column borders
(sidebar:border-l/r) and the right sidebar widget backgrounds
(bg-background/85 rounded-xl). Updated to mirror the real Outlet
wrapper classes and RightSidebar widget card styling with three
distinct skeleton sections (Trends, Hot Posts, New Accounts).
2026-03-29 15:14:09 -05:00
Alex Gleason 7b793149b3 Reserve grid cell for +N overflow indicator in badge lists
When a badge list overflows PREVIEW_LIMIT, show one fewer badge to
make room for the +N button on the same row instead of widowing it.
The loading skeleton now also includes a placeholder for the overflow
cell when applicable.
2026-03-29 15:08:48 -05:00
Alex Gleason 24c938728a Replace spinner with skeleton grid for badge list loading state
Shows placeholder skeletons matching the badge grid layout (48px
rounded squares + name bars) instead of a centered spinner while
badge definitions are being fetched.
2026-03-29 15:06:33 -05:00
Alex Gleason 1c358a3c79 Add compact badge row preview for embedded profile badges events
Kind 10008/30008 profile badges events now render a compact card with
author info, a row of up to 6 badge thumbnails, and a badge count
when embedded in quotes or reply context. Works in both EmbeddedNote
(nevent references) and EmbeddedNaddr (naddr references).
2026-03-29 15:04:58 -05:00
Alex Gleason 3bbed8875c Remove inline compose box from profile badges detail view
The badge collection detail page (kind 10008/30008) no longer shows
a ComposeBox between the badge grid and the comments section.
2026-03-29 15:02:12 -05:00
filemon a3e6ff34db Add boring and dirty emotions to dev emotion tester panel
- Added boring emotion (😑) - low-energy, unamused expression
- Added dirty emotion (💩) - hygiene-specific with dirt/stink visuals
- Maintains existing emotion order with new emotions near the top
2026-03-29 17:01:48 -03:00
Alex Gleason 0f7fc673eb Make +N overflow indicator clickable to expand full badge grid
Clicking the '+2' (or similar) button at the end of a truncated badge
grid now reveals all badges instead of being a static indicator.
2026-03-29 15:01:17 -05:00
Alex Gleason 646c95a86f Show kind action header on threaded ancestor NoteCards
The KIND_HEADER_MAP action header (e.g. 'updated their badges',
'created a badge', 'shared a photo') was only rendered in the normal
NoteCard layout. Now it also appears in the threaded layout, so parent
events shown as ancestors in reply threads display their kind context.
2026-03-29 14:56:55 -05:00
Alex Gleason 87a8974c8c Show parent event as threaded NoteCard for kind 1111 comments
When viewing a NIP-22 comment (kind 1111) that references its parent
via an 'a' tag (addr coordinates) rather than an 'e' tag (event ID),
the parent event is now rendered as a full threaded NoteCard with a
connector line — matching how kind 1 reply threads display ancestors.

Previously these showed a compact AddressableEventPreview banner.
Now the parent badges list (or any other addr-referenced event) renders
inline in the thread, giving proper visual context for the comment.
2026-03-29 14:55:48 -05:00
Alex Gleason 9b5df28b93 Fix naddr routing for replaceable events (kind 10000-19999)
Replaceable events have no d-tag, so useAddrEvent must omit the #d
filter for kinds in the 10000-19999 range. Without this, querying
for a kind 10008 profile badges event via naddr would include
'#d': [''] in the filter, which fails to match events without a d-tag.
2026-03-29 14:47:52 -05:00
Alex Gleason e876e290da Encode replaceable events (kind 10000-19999) as naddr URLs
Replaceable events should use naddr encoding (kind + pubkey + empty
identifier) rather than nevent (event ID), since they are identified by
their coordinates. This fixes kind 10008 profile badge events linking
to /nevent1... instead of /naddr1... from the feed.
2026-03-29 14:45:45 -05:00
Alex Gleason f26f033b14 Support viewing kind 10008/30008 profile badges via nevent URLs
PostDetailPage (used for nevent1 identifiers) was missing the kind
10008/30008 branch, so profile badge events fell through to the generic
PostDetailContent. AddrPostDetailPage already had this handling via
ProfileBadgesDetailView — now PostDetailPage shares the same code path
for both badge definitions (30009) and profile badges (10008/30008).
2026-03-29 14:41:24 -05:00
Alex Gleason 565f323179 Add mouse-only 3D tilt to BadgeThumbnail with visible effect
Build the tilt directly into BadgeThumbnail instead of a separate
wrapper. Use aggressive parameters (35deg max tilt, 1.15x scale,
perspective = size*3) so the effect is clearly visible on small
28-48px thumbnails. Add a perspective parameter to useCardTilt.

Remove old group-hover:scale-110 from all badge grid call sites
(BadgeShowcaseGrid, ProfileBadgesContent, ProfilePage,
ProfileHoverCard) since the tilt+scale is now built into the
thumbnail itself.
2026-03-29 14:37:46 -05:00
filemon 82b2aeb294 Refactor Blobbi visual state system: separate base face from overlay animations
- Add 'boring' persistent face: low-energy, unamused expression (replaces sad as generic fallback)
  - Droopy mouth with shallow curve, flat eyebrows
  - Used for non-critical bad stats (health, happiness)

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

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

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

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

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

Architecture notes:
- Base emotions (boring, dirty, dizzy, sad, happy, etc.): replace face completely
- Overlay emotions (sleepy): animate on top without replacing base
- Critical fix: Blobbi no longer visually resets to happy during sleepy cycle when in bad state
2026-03-29 16:36:16 -03:00
Alex Gleason 6b59658f00 Add mouse-only 3D tilt effect to badge images in feed cards
Reuse useCardTilt for the badge image in BadgeContent feed cards, but
only respond to mouse/pen pointer events. Touch events are explicitly
ignored and touch-action is set back to auto so tapping through to the
badge detail view and normal scrolling are unaffected. Includes the
specular glare overlay masked to the badge image shape.
2026-03-29 14:28:33 -05:00
Alex Gleason 2e1e4416b3 Add touch support to badge 3D tilt effect
Update useCardTilt to handle touch inputs via PointerEvent. Touch
interactions use a press-and-drag gesture: the tilt follows the finger
while down, then holds for 600ms after release before smoothly
resetting. touch-action: none prevents the browser from intercepting
the gesture for scrolling. Mouse behavior is unchanged.

Update BadgeHero glare overlay to match: glare follows touch position
during the drag and fades after the same linger delay on release.
2026-03-29 14:26:02 -05:00
Alex Gleason e92a2c571c Remove 'Badge' pill from badge detail issuer row
Remove the secondary Badge UI element showing '<Award icon> Badge' next to
the issuer name on the badge detail page. The context is already clear from
the page layout and hero image.
2026-03-29 14:20:52 -05:00
Alex Gleason d3a418b5ee Remove redundant header from profile badges feed card
Remove the 'Name's Badges (N badges)' header line from ProfileBadgesContent
to reduce visual clutter. The badge grid and NoteCard's KIND_HEADER_MAP
already provide sufficient context.
2026-03-29 14:18:37 -05:00
Alex Gleason c9945107e9 Replace Photos and Videos with Badges in default sidebar order 2026-03-29 14:14:55 -05:00
Alex Gleason 4c70133ca9 Clarify EmbeddedNote vs NoteCard compact prop in AGENTS.md
Rename the checklist item from 'Inline embeds / quote posts' to
'Embedded note cards' with explicit file paths, explain that kinds
with tag-based media may need attachment indicator updates, and add
a note distinguishing EmbeddedNote components from the NoteCard
compact prop to prevent confusion.
2026-03-29 14:11:51 -05:00
Alex Gleason 0df942cb9d Display kind 20 photo events in detail view and add Photo indicator
- Add isPhoto detection and PhotoDetailContent in PostDetailPage so kind 20
  events render their image gallery when viewed directly via nevent links
- Add parsePhotoUrls helper and ImageGallery import to PostDetailPage
- Add 'Photo' shell title for kind 20 loading state
- Add KIND_HEADER_MAP entry for kind 20 ('shared a photo') in NoteCard
- Add Photo attachment indicator in EmbeddedNote for kind 20 events in
  quote posts and reply context
2026-03-29 14:09:39 -05:00
Alex Gleason 37a63f068b Make version tag and date clickable links, remove external link icon 2026-03-29 13:48:07 -05:00
Alex Gleason cc8638e8b2 Show build date instead of commit hash in pre-release banner, compare to current commit 2026-03-29 13:46:26 -05:00
filemon fd20081ce8 fix: prevent reaction animations from being cut off by status recomputation
Refactored useStatusReaction hook to be more stateful and animation-aware:

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

This ensures:
- Sleepy animation completes full cycle including slow eye opening
- Crying/sad reactions don't reset before tear cycle completes
- Dizzy animation doesn't keep resetting its visual motion
- Eyebrow/face reactions don't flicker from repeated reapplication
2026-03-29 15:17:33 -03:00
Alex Gleason cf9d409166 Migrate profile badges from kind 30008 to kind 10008
Profile badges should be a replaceable event (kind 10008), not an
addressable event (kind 30008) with a fixed d-tag. This follows the
same deprecation pattern used by NIP-51 lists.

All writes now publish kind 10008. All reads query both 10008 and
legacy 30008, picking whichever is newest, for backwards compatibility
during the transition period.
2026-03-29 13:10:22 -05:00
Chad Curtis 7d8ac49fe2 Document fetchFreshEvent pattern in AGENTS.md 2026-03-29 10:08:44 -05:00
Chad Curtis dc3be6564b Fix stale-cache overwrites in replaceable event mutations
Extract shared fetchFreshEvent() utility that fetches the freshest
version of a replaceable/addressable event directly from relays before
every mutation. This prevents data loss when the TanStack Query cache
is stale (cross-device edits, rapid sequential operations).

Previously only useFollowActions and useMuteList had this safety
pattern. Now all list-type hooks use the same shared primitive:
useAcceptBadge, useRemoveBadge, useBookmarks, usePinnedNotes,
useInterests, and useUserLists.
2026-03-29 10:05:58 -05:00
Chad Curtis 337e27f2b5 Add multi-select badge awarding with already-sent indicators 2026-03-29 09:47:05 -05:00
Chad Curtis c400437662 Add kinds 10015, 10030, 10063 to NostrBatcher REPLACEABLE_KINDS
Interests, custom emojis, and Blossom server list queries now batch
with profile/follow/mute queries instead of firing separate REQs,
reducing ~3 REQs on feed load.
2026-03-29 08:51:41 -05:00
Chad Curtis e8941e8ef6 Fix search page 'N new posts' pill showing unfiltered count
The stream buffer count was reported raw without applying client-side
filters (search query, media type, replies, protocol, mute list, etc.),
so the pill would show e.g. '10 new posts' when only 3 matched the
active search criteria. Extract the filtering predicate into a shared
matchesFilters callback and derive the pill count from filtered buffer
contents instead of the raw streamBufferCount.
2026-03-29 08:00:29 -05:00
Chad Curtis 169823980f Add arc overhang spacer to Photos and Videos pages
Both pages have a SubHeaderBar but were missing the ARC_OVERHANG_PX
spacer div that prevents content from sitting behind the arc background.
Every other page with tabs already includes this spacer.
2026-03-29 07:58:26 -05:00
Chad Curtis aa7376b357 Add pull-to-refresh to all feed pages via usePageRefresh hook
Missing pull-to-refresh on Photos, Videos, Trends, Search, Bookmarks,
TagFeed (#t/#g), DomainFeed pages meant Android users had no way to
refresh content without navigating away.

- Create usePageRefresh hook that wraps queryClient.invalidateQueries
  with a referentially-stable callback (ref-based) for PullToRefresh
- Wrap scrollable content in PullToRefresh on all affected pages
- Fix Feed.tsx: HashtagFeedContent, GeotagFeedContent, and
  SavedFeedContent tabs now include PullToRefresh (were outside wrapper)
- Refactor Events, Books, Themes pages to use usePageRefresh for
  consistency and reduced boilerplate
2026-03-29 06:54:58 -05:00
Chad Curtis 6a0e88cbf1 Fix gap between arc and top nav on relay feed page mobile 2026-03-29 06:36:14 -05:00
Chad Curtis 01b7e1cea2 Stop click propagation on delete confirmation AlertDialog content
Clicks inside the portaled AlertDialog bubble through React's synthetic
event tree to the NoteCard article, triggering post detail navigation.
Adding stopPropagation on AlertDialogContent prevents any click inside
the delete confirmation from reaching the card handler.
2026-03-29 06:23:01 -05:00
Chad Curtis 910f43e0a5 Stop click propagation in NoteMoreMenu items to prevent click-through
MenuItem button clicks inside the Radix Dialog portal bubble through
React's synthetic event system to the parent article's handleCardClick,
causing navigation to post details when selecting menu actions like
delete. Adding stopPropagation prevents this.
2026-03-29 06:21:15 -05:00
Chad Curtis 6bf630bb40 Fix delete post dialog freezing feed on desktop
Move the delete confirmation AlertDialog out of NoteMoreMenuContent
(where it was nested inside the more-menu Dialog) and into the parent
NoteMoreMenu component. The nested Radix dialogs caused overlapping
overlays and focus traps that left the page uninteractable after
confirming deletion. Now follows the same close-then-open pattern used
by Report, Mention, AddToList, and EventJson dialogs.
2026-03-29 06:16:23 -05:00
Chad Curtis 5e71b3f44a Merge branch 'fix/emphasize-custom-emojis' into 'main'
Custom emoji improvements & reaction fixes

See merge request soapbox-pub/ditto!140
2026-03-29 11:08:38 +00:00
filemon 5ffab157d7 Unify eye system contract and fix animation conflicts
- Standardize data attributes: data-cx/cy → data-eye-cx/cy with legacy fallback
- Replace hardcoded eye selectors with EYE_CLASSES constants from eyes/types
- Remove unused side-specific clip rect class variants from EYE_CLASSES
- Fix sleepy animation: skip JS blink when SMIL animations present
- Fix companion reaction support: skip eye transforms when CSS animations active
- Update detection.ts to try new attribute format first, fall back to legacy
- Update useBlobbiEyes and useExternalEyeOffset to respect CSS animations
2026-03-29 05:16:51 -03:00
filemon c6e791d18f Implement reactions 2026-03-29 04:41:42 -03:00
Lemon 5a30376f2c Fix emoji shortcode autocomplete text and highlight colors
The shortcode label used text-muted-foreground making it hard to read.
Changed to inherit text-popover-foreground so it matches the theme's
normal text color. The selection/hover highlight now uses bg-secondary/60,
matching the subtle hover shade used by the NoteMoreMenu items.
2026-03-28 23:07:21 -07:00
Lemon 373219ecfa Reorder emoji picker: Recent, Custom, then standard categories
Move the Custom (NIP-30) emoji tab from last position to second, right
after Recent, so users can quickly access their custom emoji packs.
2026-03-28 23:07:21 -07:00
Lemon 1ef1400699 Track custom emoji usage so they appear in quick-react bar
Custom emojis were explicitly excluded from usage tracking, so they could
never accumulate enough count to appear in the quick-react popover. The
bar already handles displaying and filtering custom emojis correctly
(including removing stale ones from deleted packs), so the only missing
piece was the tracking call.
2026-03-28 23:07:21 -07:00
Lemon 7966d07158 Fix custom emoji SVGs not rendering in emoji-mart picker
emoji-mart renders custom emoji <img> tags with only max-width/max-height
inline styles but no explicit width/height. SVG files that lack intrinsic
width/height attributes (even with a viewBox) collapse to 0x0 because
max-* constraints alone can't force dimensions on a dimensionless image.

Inject a CSS rule into emoji-mart's shadow DOM that gives custom emoji
images explicit 1em x 1em dimensions with object-fit: contain. The img
lives inside span.emoji-mart-emoji, not a button with data-emoji-set.
2026-03-28 23:07:21 -07:00
Lemon 9ffab3d2dd Fix double-tap reaction not showing emoji on the post
The double-click handler was setting the user-reaction query cache to a
plain string instead of a ResolvedEmoji object. RenderResolvedEmoji
expects { content: '❤️' }, not just '❤️'.
2026-03-28 23:07:21 -07:00
Alex Gleason dbcbd8928b Merge branch 'remove-music-files' into 'main'
Refactor Blobbi music system to use remote Blossom URLs

See merge request soapbox-pub/ditto!137
2026-03-29 03:24:16 +00:00
Chad Curtis a659611897 Fix profile skeleton flicker for new users with no kind:0 metadata 2026-03-28 22:09:53 -05:00
filemon 78b4716a2a Refactor Blobbi music system to use remote Blossom URLs
- Replace local audio files with remote Blossom server URLs
- Remove upload functionality from PlayMusicModal
- Rename BuiltInTrack → BlobbiTrack, AudioSource → SelectedTrack
- Rename blobbi-builtin-tracks.ts → blobbi-track-catalog.ts
- Delete ~4.7MB of local audio files from public/blobbi/audio/
- Fix import path for STAT_MIN/STAT_MAX constants
2026-03-29 00:06:11 -03:00
Chad Curtis 08e26e28d0 Fix key download path: save to Download/ instead of Documents/ 2026-03-28 21:58:53 -05:00
Chad Curtis b1c61a7888 Fix Amber login on Android: retry nostrconnect subscription on foreground resume 2026-03-28 21:51:17 -05:00
Chad Curtis e951a3b00a Fix key download on Android: save to Documents instead of share sheet 2026-03-28 21:47:47 -05:00
Chad Curtis 62b5aab753 Disable DM handler background processing 2026-03-28 21:34:06 -05:00
Alex Gleason 7b307ffe22 Replace window.open() calls with Capacitor-aware openUrl()
window.open() and target="_blank" silently fail inside WKWebView on
iOS. Replace all programmatic window.open() calls with the openUrl()
utility from src/lib/downloadFile.ts, which uses the native share sheet
on Capacitor and falls back to window.open() on web.

Fixed in: ZapDialog, TasksPanel, HatchTasksPanel, PullRequestCard,
CustomNipCard, GitRepoCard, PatchCard.

The middle-click handler in useOpenPost is left as-is since middle-click
is a web-only interaction with no equivalent on mobile.
2026-03-28 21:25:58 -05:00
Alex Gleason edee9f7030 Add iOS privacy manifest and usage description strings
- Add PrivacyInfo.xcprivacy declaring UserDefaults, file timestamp, and
  disk space API usage reasons, plus collected data types for crash
  reporting (Sentry) and analytics (Plausible)
- Add NSPhotoLibraryUsageDescription and NSMicrophoneUsageDescription to
  Info.plist for image uploads and voice message recording

Both are required for App Store submission.
2026-03-28 21:20:32 -05:00
Mary Kate 71949890da Merge branch 'photo-compose-modal' into 'main'
Add dedicated photo upload flow for NIP-68 kind 20 events

See merge request soapbox-pub/ditto!135
2026-03-28 22:03:58 +00:00
Mary Kate Fain 5ae233ff62 Add dedicated photo upload flow for NIP-68 kind 20 events
Enable the floating compose button on the photos page with a camera
icon. Clicking it opens a new PhotoComposeModal with image upload,
title, caption, alt text, and content warning support. Publishes
kind 20 picture events per the NIP-68 specification.
2026-03-28 16:30:21 -05:00
Mary Kate 19400a78e5 Merge branch 'fix/profile-tab-form-reset' into 'main'
Fix custom profile tab form retaining fields from previous tab

Closes #196

See merge request soapbox-pub/ditto!134
2026-03-28 21:08:25 +00:00
Mary Kate Fain 497d6979d0 Fix custom profile tab form retaining fields from previous tab
The ProfileTabEditModal reset form state inside a handleOpenChange
callback, but Radix Dialog does not fire onOpenChange when opened
programmatically via the `open` prop. This meant that when a parent
component set `open={true}` (e.g. after clicking 'Add custom tab'),
the reset logic never ran and the form kept stale values from the
last edit session.

Replace the handleOpenChange reset with a useEffect that triggers
whenever `open` transitions to true, ensuring the form always
initializes from the current `tab` prop (or clean defaults for a
new tab).

Closes #196
2026-03-28 16:01:17 -05:00
Mary Kate 59eab8afea Merge branch 'fix/badge-notification-click' into 'main'
Fix badge notifications not being clickable

Closes #201

See merge request soapbox-pub/ditto!133
2026-03-28 20:56:52 +00:00
Mary Kate Fain 74b84eb5ac Fix badge notifications not being clickable
Wrap badge cards in BadgeAwardNotification and BadgeAwardNotificationGroup
with Link components that navigate to the badge detail page via naddr1
encoded URLs. Both single and grouped badge notifications now link to
the badge definition page when clicked.

Closes #201
2026-03-28 13:38:11 -05:00
Mary Kate bfc864cc7c Merge branch 'rename-vines-to-divines' into 'main'
Rename "Vines" to "Divines" in all user-facing strings

Closes #194

See merge request soapbox-pub/ditto!132
2026-03-28 18:12:28 +00:00
Mary Kate Fain 6c067a3ae6 Rename "Vines" to "Divines" in all user-facing strings
Update sidebar label, page title, feed empty states, search filter
labels, notification kind nouns, comment context labels, and
documentation to use "Divines" instead of "Vines".

Closes #194
2026-03-28 13:05:08 -05:00
Mary Kate 503fed5fdb Merge branch 'changelog-footer-link' into 'main'
Add changelog link to footer section

Closes #203

See merge request soapbox-pub/ditto!131
2026-03-28 17:57:31 +00:00
Mary Kate Fain 32cb3eeba3 Add changelog link to footer section
Closes #203
2026-03-28 12:51:42 -05:00
Mary Kate 7e49e85495 Merge branch 'fix/geocache-treasure-terminology' into 'main'
Fix inconsistent use of 'geocache' vs 'treasures' terminology

Closes #198

See merge request soapbox-pub/ditto!130
2026-03-28 17:49:44 +00:00
Mary Kate Fain c3d7984d7a Fix inconsistent use of 'geocache' vs 'treasures' terminology
Closes https://gitlab.com/soapbox-pub/ditto/-/work_items/198
2026-03-28 12:44:28 -05:00
Mary Kate b024518f5e Merge branch 'fix/double-line-profile-tabs-editing' into 'main'
Fix double line under profile tabs in edit mode

Closes #197

See merge request soapbox-pub/ditto!129
2026-03-28 17:36:53 +00:00
Mary Kate Fain 83c1e9aa6c Fix double line under profile tabs in edit mode
Replace the flat h-1 indicator bar in SortableTabChip with the
SubHeaderBar arc-based active indicator, matching how TabButton works.
The flat bar was overlapping the ArcBackground border stroke, creating
a visible double line when editing profile tabs.
2026-03-28 12:29:38 -05:00
Chad Curtis 8a6cb02dc0 Convert blobbi audio from MP3 to M4A (AAC-LC 32kbps mono), enable R8 shrinking
Replace 36 MB of MP3 files with 4.6 MB of M4A (AAC-LC) files encoded
at 32kbps mono. M4A is required for iOS/Safari compatibility in
Capacitor's WKWebView.

Enable R8 minification and resource shrinking in the Android release
build to further reduce APK size. Add ProGuard rules to keep Capacitor
and OkHttp classes.
2026-03-28 11:35:49 -05:00
Alex Gleason 91237c252c Sanitize Blobbi SVG output with DOMPurify before DOM injection
Add defense-in-depth sanitization at the output boundary of the Blobbi
SVG rendering pipeline. The upstream pipeline validates user inputs
(normalizeHexColor, instanceId regex), but 3000+ lines of regex-based
SVG string manipulation feed directly into dangerouslySetInnerHTML with
no structural guarantee that the output is safe.

sanitizeBlobbiSvg() uses DOMPurify with an allowlist tuned for the
Blobbi pipeline (gradients, clip paths, animations, @keyframes) while
blocking scripts, event handlers, foreignObject, href, and other
dangerous constructs.
2026-03-28 11:33:10 -05:00
Chad Curtis f7821451c7 release: v2.2.1 2026-03-28 10:15:52 -05:00
Chad Curtis 9056b43696 Highlight flushed posts, fix new-posts pill positioning, fix desktop tab gap
- New posts flushed from the stream buffer now briefly highlight with a
  primary-tinted fade animation so users can see what appeared
- New-posts pill uses responsive CSS (new-posts-pill utility) so it sits
  correctly below the SubHeaderBar on both mobile and desktop
- SubHeaderBar desktop padding moved inside the inner wrapper so the arc
  background extends to the viewport edge, eliminating the gap above tabs
2026-03-28 10:12:11 -05:00
Chad Curtis cdf54e9eff Fix 'new posts' button on search page not loading posts
The button only called window.scrollTo() and relied on the scroll event
listener to auto-flush the stream buffer. This failed when smooth
scrolling didn't fire reliable scroll events (especially on mobile/
Capacitor WebView). Now explicitly calls flushStreamBuffer() on click.
2026-03-28 09:51:30 -05:00
Chad Curtis 7a49e9646c Bypass nudge for encrypt and decrypt, remove phase-transition toast
Encrypt and decrypt operations now call the signer directly without
nudge/timeout/retry wrapping. The sign operation already provides
the user-facing nudge when approval is needed, so the encrypt nudge
was redundant noise. Phase-transition toast and related constants
removed as dead code.
2026-03-28 09:43:43 -05:00
Chad Curtis 2189f5e7c4 Fix MobileTopBar double-opacity: remove bg from header, use dedicated safe-area fill
The header had bg-background/85 plus the ArcBackground SVG fill-background/85
stacking to ~98% opacity. Now the safe-area padding zone gets a single-layer
bg-background/85 fill div (same pattern as pinned SubHeaderBar), and the
ArcBackground provides the only fill for the content area.
2026-03-28 09:39:09 -05:00
Chad Curtis 2822b4c159 Remove decrypt nudge, fix pinned tab safe area fill
- Decrypt operations now bypass signerWithNudge entirely (no nudge toast)
- Pinned SubHeaderBar safe area uses a separate fill div matching
  MobileTopBar's bg-background/85, avoiding double-opacity stacking
  with the ArcBackground SVG below
2026-03-28 09:37:28 -05:00
Chad Curtis 3dd2591709 Fix pinned tab arc, signer toast spam, new-posts pill position, toast swipe
- Pinned SubHeaderBar uses safe-area-inset-top (top offset) instead of
  safe-area-top (padding) so the arc stays flush with the tab content
- Throttle signer nudge toasts (8s cooldown) to prevent rapid-fire storm
  when relay connection is unstable
- New posts pill fades out when nav hides instead of translating, avoiding
  it floating in the safe area zone
- Signer toasts use finite duration (120s) so Radix swipe-to-dismiss works
- Lower toast swipe threshold from 50px to 30px for easier dismissal
2026-03-28 09:32:08 -05:00
Chad Curtis ab2145ffe9 Fix mobile UX: sticky safe area, page padding, stream buffering, media CW
- SubHeaderBar pinned mode adds safe-area-top padding when nav is hidden
- Increase signer nudge delay to 10s for decrypt ops (reduces false triggers on Amber)
- Add arc overhang spacer to Search, Notifications, and Profile pages
- Add PageHeader to Search page for consistent top-level layout
- Buffer streamed posts when user is scrolled down to prevent scroll jumps
- Show 'N new posts' pill that tracks SubHeaderBar position and nav state
- MediaCollage respects NIP-36 content warnings (blur/hide/show policy)
- Normalize main element classes across Profile and Notifications pages
2026-03-28 09:16:00 -05:00
Chad Curtis 3ee880d1dd release: v2.2.0 2026-03-28 08:00:41 -05:00
Chad Curtis 586e103161 Merge branch 'fix/zap-primal-users' into 'main'
Fixing zap Primal users without error

See merge request soapbox-pub/ditto!110
2026-03-28 12:55:40 +00:00
Chad Curtis 5776bf2a51 Merge branch 'fix/remote-signer-ux-improvements' into 'main'
feat: remote signer UX improvements for Amber/NIP-46 users on Android

See merge request soapbox-pub/ditto!128
2026-03-28 12:51:15 +00:00
Chad Curtis ceb442ebf1 Merge remote-tracking branch 'origin/main' into fix/remote-signer-ux-improvements
# Conflicts:
#	src/hooks/useCurrentUser.ts
#	src/index.css
2026-03-28 07:47:26 -05:00
Chad Curtis e6a2bdc65f Clean up nsec paste guard toast wording for clarity 2026-03-28 07:37:59 -05:00
Chad Curtis c9205adbab Merge branch 'fix/nsec-paste-guard' into 'main'
feat: block nsec private key paste with warning

See merge request soapbox-pub/ditto!101
2026-03-28 12:37:28 +00:00
Chad Curtis 29d56daab3 fix: relay page arc header with inline NIP-11 info panel
- Use PageHeader + SubHeaderBar arc format matching other feed pages
- Add arc overhang spacer for consistent feed padding
- Move NIP-11 relay info into an inline expanding panel (maxHeight transition)
- Info toggle button in PageHeader top-right corner
- Accept string | undefined in useRelayInfo hook signature
- Register useLayoutOptions({ hasSubHeader: true }) for mobile nav
2026-03-28 07:28:32 -05:00
Chad Curtis 4d7ac5e619 Merge branch 'feat/relay-info' into 'main'
Adding NIP-11 relay information on network settings page

See merge request soapbox-pub/ditto!106
2026-03-28 12:11:44 +00:00
Chad Curtis fb3686fef4 Merge branch 'fix/embedded-note-link-preview' into 'main'
Show link preview cards in quoted posts

See merge request soapbox-pub/ditto!107
2026-03-28 12:05:15 +00:00
Chad Curtis ed14ef0cd9 Merge branch 'fix/notifications-136' into 'main'
Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts

Closes #136

See merge request soapbox-pub/ditto!102
2026-03-28 12:04:20 +00:00
Chad Curtis fd90f90cbb Restore client-side reply count seeding with correct nip85-event-stats cache key 2026-03-28 06:59:01 -05:00
Chad Curtis b6cee104b9 Filter Mentions tab by literal nostr: URI mentions in content 2026-03-28 06:49:19 -05:00
Chad Curtis 5c6df95734 Fix review issues: remove duplicate subscription, fix zapAmount regression, remove dead cache seeding 2026-03-28 06:40:58 -05:00
Chad Curtis 8941aca968 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/notifications-136
# Conflicts:
#	src/hooks/useHasUnreadNotifications.ts
#	src/hooks/useTrending.ts
#	src/pages/NotificationsPage.tsx
2026-03-28 06:32:45 -05:00
Chad Curtis e1348f782e refactor: deduplicate theme dark/light detection into colorUtils
Extract getBackgroundThemeMode() and getBackgroundHex() into colorUtils.ts,
replacing duplicated CSS variable reading and luminance calculations in
EmojiPicker, main.tsx status bar, and TweetEmbed. Also fixes TweetEmbed
incorrectly treating custom themes as always light.
2026-03-28 06:27:21 -05:00
Chad Curtis b652976784 Merge branch 'fix/emoji-picker-autofocus' into 'main'
Fix: Auto-focus search box when emoji picker opens

Closes #162

See merge request soapbox-pub/ditto!114
2026-03-28 11:08:30 +00:00
Chad Curtis 530c0681d0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/emoji-picker-autofocus
# Conflicts:
#	src/components/EmojiPicker.tsx
2026-03-28 06:01:21 -05:00
Chad Curtis e38f57f823 Merge branch 'fix/hashtag-query-eq' into 'main'
Fixing query case variants for tag feed parity with search

See merge request soapbox-pub/ditto!111
2026-03-28 10:57:46 +00:00
Chad Curtis f3b9eb9f73 Merge branch 'fix/disable-notifications' into 'main'
Fix notification preferences not filtering based on settings

See merge request soapbox-pub/ditto!93
2026-03-28 10:55:25 +00:00
Chad Curtis bd7be9590a Merge branch 'fix/feed-header-ordering' into 'main'
Fix: Consistent feed header ordering — title above tabs on all feed pages

Closes #153, #155, #156, #157, #158, and #159

See merge request soapbox-pub/ditto!112
2026-03-28 10:51:29 +00:00
Chad Curtis e1fa43c9f0 fix: add arc overhang spacer to BadgesPage to match other feeds 2026-03-28 05:48:07 -05:00
Chad Curtis ccb0d9ec71 fix: add background to PageSkeleton so it matches feed appearance 2026-03-28 05:46:41 -05:00
Chad Curtis eca4a5ba77 fix: PageHeader padding, opacity, and BooksPage search bar overlap
- Equal vertical padding (py-4) on PageHeader for balanced spacing
- Add bg-background/85 to PageHeader to match SubHeaderBar opacity
- Add top padding to BookSearchBar so it clears the arc overhang
2026-03-28 05:39:48 -05:00
Chad Curtis 6dd29c571f refactor: extract useInfiniteScroll hook and deduplicateEvents utility
Consolidate duplicated infinite-scroll boilerplate (auto-fetch page 2,
IntersectionObserver, scroll trigger) into a shared useInfiniteScroll
hook, and extract the flatten+dedup pattern into deduplicateEvents.

Also migrate BadgesPage and ThemesPage to use useFeedTab for
consistent tab persistence across all feed pages.
2026-03-28 05:31:49 -05:00
Chad Curtis 28f1e2b517 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering 2026-03-28 05:14:26 -05:00
Chad Curtis 21374b2cb4 Make vines desktop immersive like mobile, with floating tab bar and back button 2026-03-28 05:13:21 -05:00
Chad Curtis ada87468cc Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering
# Conflicts:
#	src/pages/BadgesPage.tsx
2026-03-28 05:00:19 -05:00
Chad Curtis 0a4b488d69 Merge branch 'fix/vines-feed-header' into 'main'
Fix: Add Vines feed section header with info link to divine.video

Closes #154

See merge request soapbox-pub/ditto!113
2026-03-28 09:57:38 +00:00
Chad Curtis aa257b34ec Fix bottom nav flashing during vine swipe transitions
Remove the activeVinePlaying reset on index change. The old card's
onPlayingChange is already undefined after re-render, and the new
card's autoplay fires onPlay directly, so the state stays consistent
through transitions without a brief false→true flash.
2026-03-28 04:43:52 -05:00
Chad Curtis c48e6c7123 Add safe area insets to vine card overlay UI
Offset the bottom info strip, action sidebar, and mute button by
env(safe-area-inset-bottom) so they clear the home indicator on
notch/island devices. Applied to both the live UI and loading
skeleton.
2026-03-28 04:43:14 -05:00
Chad Curtis 827bc4b836 Show bottom nav when vine is paused, hide during playback
Lift playing state from VineCard to VinesFeedPage via onPlayingChange
callback. hideBottomNav is now driven by whether the active vine is
playing, so users can navigate when paused. Reset playing state on
swipe so the nav briefly appears while the next vine loads. Remove
noArcs so the bottom nav renders with its normal arc appearance.
2026-03-28 04:41:40 -05:00
Chad Curtis c7d115f873 Make vines page fully immersive on mobile
Hide the mobile top bar and bottom nav entirely on the vines page,
replacing them with a floating TikTok-style tab bar that overlays
directly on the video. The menu button (hamburger) is embedded in
the floating bar so users can still access navigation.

- Add hideTopBar and hideBottomNav layout options
- Add DrawerContext so pages can open the mobile drawer directly
- Move floating tab bar outside the scroll container to fix
  IntersectionObserver index tracking (autoplay on next video)
- Simplify vine-slide-height CSS to use full 100dvh
2026-03-28 04:36:41 -05:00
Alex Gleason 45c585a27d fix: add Vines feed section header with info link to divine.video
Fixes #154
2026-03-28 04:20:30 -05:00
Alex Gleason dc3fe02767 Upgrade Radix UI packages to fix infinite render loop with React 19
@radix-ui/react-popper 1.2.4 had a useEffect with no dependency array
that called setState (onAnchorChange) on every render, causing an
infinite loop. Fixed in 1.2.8 by tracking the previous anchor value
in a ref and only calling setState when it changes.

Upgraded all Radix packages that depend on react-popper:
- react-tooltip 1.2.4 -> 1.2.8
- react-popover 1.1.11 -> 1.1.15
- react-dropdown-menu 2.1.12 -> 2.1.16
- react-context-menu 2.2.12 -> 2.2.16
- react-hover-card 1.1.11 -> 1.1.15
- react-select 2.2.2 -> 2.2.6
- react-menubar 1.1.12 -> 1.1.16
- react-navigation-menu 1.2.10 -> 1.2.14
2026-03-28 02:29:17 -05:00
Lemon a1ef06510e Remove stale DEFAULT_KINDS fallback, skip polling until JS configures kinds 2026-03-28 00:20:21 -07:00
Alex Gleason 56002c68ca Add eslint rule to prohibit import.meta.glob usage 2026-03-28 02:19:55 -05:00
Alex Gleason 30bd73f8f9 Replace import.meta.glob with inlined SVG constants for Shakespeare compatibility
import.meta.glob is Vite-only and crashes in Shakespeare's esbuild bundler.
Generated baby-svg-data.ts and adult-svg-data.ts with SVG content as template
literal constants, keeping the existing resolver API unchanged.
2026-03-28 02:18:26 -05:00
Lemon 9d8a30f678 Add badge awards push notification template and pref key mapping 2026-03-28 00:13:21 -07:00
Lemon 2b0b99d598 Fix letters push toggle not propagating to nostr-push server 2026-03-28 00:07:28 -07:00
Lemon 8551852c9d Add badges and letters kinds to shared notificationKinds helper 2026-03-28 00:00:20 -07:00
Lemon 7ade0eaeb1 Clean up notification follow-filter code: remove dead ref, fix filter typing 2026-03-27 23:58:31 -07:00
Lemon debdbf770b Respect 'only from people I follow' in web push notifications
Send authors: ['$contacts'] in the nostr-push subscription filter
when onlyFollowing is enabled. The nostr-push server resolves this
macro to the user's kind 3 follow list. Toggling the setting off
removes the authors filter so all notifications are delivered again.
2026-03-27 23:58:31 -07:00
Lemon 01976685e8 Respect 'only from people I follow' in unread notification dot
Apply the same authors filter as useNotifications so the nav dot
won't appear for notifications from non-followed accounts.
2026-03-27 23:58:31 -07:00
Lemon bac5d71480 Respect 'only from people I follow' in native Android notifications
Pass followed pubkeys through the Capacitor plugin to the native
polling service. When onlyFollowing is enabled, the relay query
includes an authors filter so only events from followed accounts
trigger native Android notifications.
2026-03-27 23:58:31 -07:00
Lemon 0ad655d1cf Fix notification preferences not filtering push notifications or unread dot
Disabled notification types (e.g. reactions) still triggered push
notifications and showed the unread dot indicator, even though the
notification tab correctly filtered them out.

Three root causes fixed:
- useHasUnreadNotifications now uses getEnabledNotificationKinds to
  only query for enabled types, preventing phantom unread dots
- NotificationSettings now syncs type preference changes to the
  nostr-push server via updateSubscription (is_active toggle)
- Native Android poller now receives enabled kinds from the JS layer
  and uses them in the relay filter instead of hardcoded kinds
2026-03-27 23:58:31 -07:00
Chad Curtis a526e301da Fix MobileTopBar arc lingering when hidden on pages without tabs
The hide transform was missing the 20px arc overhang, so the bottom
curve remained visible after the bar slid up. Match the nav-hidden-slide
approach used by SubHeaderBar.
2026-03-28 00:46:29 -05:00
Chad Curtis 9c16c6df40 Fix MobileTopBar arc position with safe-area inset on native
The ArcBackground was positioned over the entire header including the
safe-area padding, causing the arc curve to sit too high on native apps.
Wrap content in a relative container so the arc only covers the content
area, and add bg-background/85 on the header to fill the safe-area region.
2026-03-28 00:46:29 -05:00
Chad Curtis 3220e9482b Fix profile and compose letter tabs hiding with mobile top bar
Add 'pinned' prop to SubHeaderBar that transitions top to 0 instead
of sliding off-screen when the nav hides. Applied to ProfilePage tabs
and ComposeLetterSheet toolbar.
2026-03-28 00:46:29 -05:00
Alex Gleason 9a033d7f91 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-28 00:38:33 -05:00
Chad Curtis 2329458a84 Fix settings letters section, drawer background/z-index, and top nav scroll hide
- Remove letters section from settings page (accessible from letters page)
- Add background to letter editor drawer panel
- Fix drawer z-index so letter content doesn't bleed through
- Fix compose sheet SubHeaderBar top offset in overlay context
- Hide top bar and sub-header tabs together on scroll down
2026-03-28 00:36:07 -05:00
Alex Gleason c613a7aedd npm audit fix 2026-03-28 00:30:35 -05:00
Alex Gleason d4d502f418 Loosen engines constraint to node >=22 2026-03-28 00:30:14 -05:00
Alex Gleason 7f37f16c7b Upgrade React from 18.3 to 19.2
- Upgrade react, react-dom to ^19.2.4 and @types/react, @types/react-dom to v19
- Upgrade @nostrify/react to 0.4.0 (peer deps fix for React 19)
- Upgrade vaul to 1.1.2 and react-day-picker to 9.14.0 for React 19 compatibility
- Fix useRef() calls to pass explicit initial values (required in React 19)
- Update RefObject types to include null (React 19 type change)
- Rewrite Calendar component for react-day-picker v9 classNames API
- Add npm overrides for @emoji-mart/react (only remaining React 18 holdout)
2026-03-28 00:29:37 -05:00
Chad Curtis a67d007435 Merge branch 'fix/badge-profile-and-give-badge' into 'main'
Add top badges to profile and 'Give badge' to profile menu

Closes #189 and #185

See merge request soapbox-pub/ditto!121
2026-03-28 05:19:02 +00:00
Chad Curtis a5c6645d2d Fix GiveBadgeDialog bugs and DRY up ProfileMoreMenu handlers 2026-03-28 00:16:18 -05:00
Alex Gleason cbe50a0232 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-28 00:01:19 -05:00
Alex Gleason a48ac48202 Trim Sentry bundle by tree-shaking unused replay/feedback/canvas modules
Use named imports in the dynamic import of @sentry/react so the bundler
can drop re-exported modules we never reference (replay 207K, feedback 67K,
replay-canvas 25K). Also set defaultIntegrations: undefined to prevent
Sentry from pulling those modules at runtime.

Sentry chunk: 431K → 128K (-70%).
2026-03-28 00:00:21 -05:00
Mary Kate Fain 0a7aaca6e5 Move badges from sidebar to profile bio section
Display badge preview inline in the profile bio area (after the
about text) as a horizontal row of thumbnails, matching the style
used in the profile hover card. This works on both desktop and
mobile since it's part of the main profile content.
2026-03-27 23:47:01 -05:00
Mary Kate Fain 6e41ea3b42 Add badges to profile sidebar and 'Give badge' to profile menu
- Show accepted badges in the profile right sidebar (fixes #189)
- Add 'Give badge' option to the profile 3-dot overflow menu,
  allowing users to award their created badges directly from
  a user's profile (fixes #185)
2026-03-27 23:47:01 -05:00
Chad Curtis 75ada621d9 Merge branch 'fix/font-picker-click-through' into 'main'
Fix: Prevent font picker clicks from passing through to theme selector

Closes #171

See merge request soapbox-pub/ditto!115
2026-03-28 04:44:53 +00:00
Alex Gleason 12d578ff57 Improve bundle chunking: lazy-load emoji picker, markdown, and remove runtime tailwind config
- Hardcode md breakpoint in useIsMobile and toaster to eliminate runtime
  import of tailwind.config (was pulling in tailwindcss, postcss-selector-parser,
  and plugin code ~100KB)
- Lazy-load EmojiPicker in ComposeBox (emoji-mart + data ~500KB deferred)
- Dynamic import webxdcMeta.ts (smol-toml + fflate only loaded for .xdc uploads)
- Lazy-load ArticleContent, PullRequestCard, CustomNipCard in NoteCard and
  PostDetailPage (react-markdown + unified pipeline ~147KB deferred)
- Consolidate 60+ lucide-react icon micro-chunks into a single chunk via
  manualChunks, reducing HTTP request overhead
- ReplyComposeModal chunk: 808KB -> 296KB (-63%)
- JS file count: 226 -> 181 (-45 files)
2026-03-27 23:44:20 -05:00
Chad Curtis 3486b7f503 Merge remote-tracking branch 'origin/main' into fix/font-picker-click-through
# Conflicts:
#	src/components/FontPicker.tsx
2026-03-27 23:43:46 -05:00
Chad Curtis 0aed5e0f31 Merge branch 'fix/emoji-picker-contrast' into 'main'
Fix: Improve contrast on focused emoji category text in picker

Closes #174

See merge request soapbox-pub/ditto!116
2026-03-28 04:41:26 +00:00
Alex Gleason 34c40980e3 Merge branch 'remove-dev-buttons' into 'main'
Removing dev buttons

See merge request soapbox-pub/ditto!127
2026-03-28 03:09:08 +00:00
Alex Gleason b13eb6012c Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:59:00 -05:00
Alex Gleason ecc3284a94 Improve code splitting: reduce index chunk from 1021K to 494K
- Lazy-load BlobbiCompanionLayer (~450K blobbi code off critical path)
- Split BlobbiActionsProvider into lightweight file to avoid pulling
  heavy blobbi action system into the index chunk
- Fix HomePage eagerly importing all 18 page components; use lazy()
  so only the configured homepage's chunk is loaded
- Lazy-load ReplyComposeModal in AppRouter and FloatingComposeButton
  to defer emoji-mart (~620K) until compose is opened
- Fix barrel import in App.tsx pulling BlobbiDevEditor into index;
  use direct import from EmotionDevContext instead
2026-03-27 20:56:36 -05:00
filemon b10c8ff182 Hide Blobbi dev controls on deployed environments
Add isLocalhostDev() helper that checks both import.meta.env.DEV AND
hostname (localhost/127.0.0.1/0.0.0.0). This ensures dev buttons only
appear during local development, never on deployed apps.

Dev controls now hidden in production:
- Dev Hatch/Evolve instant transition buttons
- Dev State Editor button
- Dev Emotion Tester button
2026-03-27 22:40:17 -03:00
Alex Gleason d96e222a15 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:40:02 -05:00
filemon ec63533108 Merge branch 'main' into remove-dev-buttons 2026-03-27 22:32:47 -03:00
Alex Gleason 6f74366dd9 Restore code splitting with React.lazy for all non-critical page routes
Convert 44 page imports from static to React.lazy() with dynamic imports.
Only HomePage, Index, and NotFound remain eagerly loaded as critical-path
pages. The existing Suspense boundary in MainLayout (with PageSkeleton
fallback) already wraps the content area, so lazy pages show a skeleton
while loading without affecting the sidebar or navigation.
2026-03-27 20:30:32 -05:00
filemon 4b5825790a Fix daily missions UI not updating after reroll/claim
Replace useLocalStorage with direct localStorage reads that re-trigger
when the 'daily-missions-updated' event fires. The previous approach
cached state internally and didn't see same-tab localStorage writes.

Now when mutations write to localStorage and dispatch the event:
- Version counter bumps
- useMemo re-reads from localStorage
- UI updates immediately without page refresh
2026-03-27 22:29:20 -03:00
Alex Gleason c257e61fa7 Add bundle analyzer (rollup-plugin-visualizer) to the build 2026-03-27 20:26:21 -05:00
filemon f7391c0e0b Rebalance daily mission rewards to match shop economy
- Increase easy mission rewards to 25-30 coins
- Increase medium mission rewards to 45-50 coins
- Increase high-effort missions (photo, multiple feeds) to 55-70 coins
- Increase medicine missions to 60-70 coins (since medicine costs coins)
- Increase Daily Champion bonus from 50 to 80 coins

Target daily economy: 130-180 coins normal day, 210-250 with bonus
2026-03-27 22:24:48 -03:00
Alex Gleason dce3d5b411 Merge branch 'feat-blobbi' into 'main'
Blobbi: add core pet system, interactions, missions, etc

See merge request soapbox-pub/ditto!104
2026-03-28 01:23:11 +00:00
Alex Gleason a2490da3b4 Add badge kinds (30009, 30008) to the Ditto tab feed 2026-03-27 20:22:02 -05:00
Alex Gleason 0bd4877dd3 release: v2.1.1 2026-03-27 20:22:00 -05:00
filemon 1aacc0073f Fix egg-compatible missions and add medicine missions for reroll
Mission pool changes:
- Add 'medicine' action type for giving medicine to Blobbi
- Add medicine_1 (30 coins) and medicine_2 (50 coins) missions
- Mark clean, sing, play_music, medicine as available for ALL stages (egg+baby+adult)
- Keep interact, feed, sleep, take_photo as baby/adult only

Egg users now have 8 valid missions:
- clean_1, clean_2
- sing_1, sing_2
- play_music_1, play_music_2
- medicine_1, medicine_2

This ensures egg-only users always have alternatives when rerolling
(need at least 4 missions for 3 daily + 1 reroll target)
2026-03-27 22:14:40 -03:00
Alex Gleason 6195ae6901 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:10:58 -05:00
filemon 6f2d80b99e Fix mission reroll - expand pool and add state migration
- Expand DAILY_MISSION_POOL from 7 to 15 missions with more variety
  - Multiple difficulty tiers for feed, clean, interact, sing, play_music, take_photo
  - Ensures always having alternatives when rerolling
- Fix selectReplacementMission to properly exclude only active missions
- Add state migration for rerollsRemaining in both hooks
  - Old localStorage states without rerollsRemaining now get 3 rerolls
- Improve getRerollsRemaining to handle undefined/null values
- Better error message when pool is exhausted
2026-03-27 22:10:40 -03:00
filemon a8e7901eac Add mission reroll feature for daily missions
- Add rerollsRemaining to DailyMissionsState (max 3 per day, resets daily)
- Add rerollMission() function with stage-aware replacement selection
- Replacement avoids duplicates and the mission being replaced
- Add useRerollMission hook for mutation with toast feedback
- Add reroll button (RefreshCw icon) to incomplete missions
- Show remaining rerolls count at top of mission list
- Disable reroll for completed/claimed missions
- Bonus mission still works correctly after rerolling
2026-03-27 22:01:50 -03:00
filemon d4ae9d9611 Add stage-based daily mission filtering and bonus mission reward
- Add requiredStages property to mission definitions (all current missions require baby/adult)
- Update selectDailyMissions() to filter by user's available Blobbi stages
- Show 'Hatch Your Blobbi First' message when user only has eggs
- Add 50-coin 'Daily Champion' bonus mission after completing all regular missions
- Bonus mission appears locked until all regular missions are completed
- Update useClaimMissionReward hook to support claiming bonus rewards
- Pass availableStages through modal props for proper filtering
2026-03-27 21:56:41 -03:00
filemon d82a3cffe8 Allow empty name input during Blobbi egg adoption editing
Previously, clearing the name input would immediately restore it to 'Egg',
which made for a frustrating UX when trying to fully clear and retype.

Now:
- Name input can be fully cleared while editing
- Validation error shows when name is empty
- Adopt button is disabled when name is empty/whitespace
- Only validate on submit, not on every keystroke
2026-03-27 21:43:06 -03:00
filemon 05e189b938 Fix SVG ID prefix to use full Blobbi ID instead of first 8 chars
The previous fix used instanceId.slice(0, 8) for the prefix, but since
Blobbi IDs have the format 'blobbi-{pubkeyPrefix12}-{petId10}', the first
8 characters are always 'blobbi-' for all Blobbis owned by the same user.

This caused gradient ID collisions between different Blobbis.

Now using the full sanitized instanceId as the prefix:
  b_blobbi-abc123456789-xyz1234567

This ensures each Blobbi gets truly unique SVG IDs.
2026-03-27 21:39:01 -03:00
filemon d32d0b17d0 Fix gradient ID collisions causing wrong colors in Blobbi modal list
When multiple Blobbis are rendered on the same page (like in the selector modal),
they all shared the same SVG gradient IDs (e.g., cattiBody3D, blobbiBodyGradient).
The browser only uses the first definition of each ID, so all subsequent Blobbis
would use the first one's colors instead of their own.

Fixed by:
- Adding uniquifySvgIds() function to both adult and baby SVG customizers
- Generating unique prefixes from each Blobbi's ID (first 8 characters)
- Prefixing all SVG IDs and updating all references (url(), href, xlink:href)
2026-03-27 21:28:04 -03:00
Alex Gleason 3a8282255c Revert "Hide top nav on scroll and unify top padding across pages"
This reverts commit 488ce5750d.

Restores sticky (non-fixed) mobile top bar, removes scroll-based
hide/show behavior, reverts pt-mobile-bar back to -mt-mobile-bar
negative margin approach, and removes ARC_OVERHANG_PX spacers from
NotificationsPage and ProfilePage.
2026-03-27 19:26:14 -05:00
Mary Kate 79e97fae09 Merge branch 'fix/toast-swipe' into 'main'
Fix toast swipe direction on mobile

See merge request soapbox-pub/ditto!120
2026-03-28 00:18:25 +00:00
Mary Kate Fain 9b93881663 Merge remote-tracking branch 'origin/main' into fix/toast-swipe
# Conflicts:
#	src/components/ui/toast.tsx
#	src/components/ui/toaster.tsx
2026-03-27 19:14:08 -05:00
Mary Kate 21df47eccb Merge branch 'fix/compose-preview-overflow' into 'main'
Fix compose preview overflow not being scrollable in modal

Closes #179

See merge request soapbox-pub/ditto!126
2026-03-28 00:07:25 +00:00
filemon 1d87315426 Adjust eyebrow position higher for owli and froggi forms 2026-03-27 21:01:17 -03:00
Alex Gleason 4a1e21e820 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 19:01:02 -05:00
Alex Gleason a8181c45d0 Regenerate iOS app icon 2026-03-27 18:59:59 -05:00
Alex Gleason 84ca17ebc4 Add iOS icon generation to generate-icons.sh 2026-03-27 18:54:51 -05:00
filemon 4db0e8870d Fix clipPath ID collision causing eyes to disappear with multiple Blobbis 2026-03-27 20:54:06 -03:00
Mary Kate Fain 310993d57c Fix compose preview overflow not being scrollable in modal 2026-03-27 18:51:14 -05:00
Mary Kate d624b93d8c Merge branch 'fix/mobile-emoji-picker-close' into 'main'
Fix compose modal closing when dismissing emoji picker on mobile

See merge request soapbox-pub/ditto!125
2026-03-27 23:46:40 +00:00
Mary Kate Fain 872d319220 Fix compose modal closing when dismissing emoji picker on mobile
Prevent the compose modal from being accidentally dismissed when the user
taps the emoji/GIF picker overlay to close it.  On mobile this was very
easy to trigger, causing the draft to be lost.

Add onInteractOutside and onEscapeKeyDown handlers to the compose modal's
DialogContent that detect when a nested dialog (emoji picker) is open and
prevent the dismiss event from propagating to the parent modal.
2026-03-27 18:31:58 -05:00
Mary Kate cd44ae6bc0 Merge branch 'fix/notification-kind-labels' into 'main'
Use kind-specific labels in notification action text instead of generic 'post'

See merge request soapbox-pub/ditto!124
2026-03-27 23:25:35 +00:00
filemon deb59b314b Fix adult eye layering by excluding EyeBase gradients from eye white detection
The isEyeWhiteElement function was incorrectly matching colored eye rim gradients
like froggiEyeBase3D (green frog eye bulge) because it matched any gradient with
'Eye' in the name. This caused the eyelid to be placed behind the eye base layer,
making only the eyelid visible.

Now the detection:
- EXCLUDES EyeBase patterns (colored eye rims)
- INCLUDES EyeWhite patterns (actual white of eye)
- INCLUDES generic Eye gradients without 'Base' (baby Blobbi, etc.)
2026-03-27 20:25:32 -03:00
filemon 4c75d4f559 Add missing form-specific color customizers for adult Blobbi SVGs
Complete the color mapping system by adding customizers for:
- breezy: body, inner, veins, arms, legs, floating leaves
- bloomi: all 6 petals with color variations, center, pollen
- cacti: body, arms (pot keeps original red)
- cloudi: body, highlights, raindrops
- crysti: body, inner (facets keep colorful nature)
- owli: body, ears, wings (beak keeps yellow/orange)

Pandi intentionally excluded as it's a panda with black/white coloring by design.
2026-03-27 20:17:26 -03:00
Mary Kate Fain c5bc900212 Use kind-specific labels in notification action text instead of generic 'post'
Notifications now say 'reacted to your badge', 'reposted your theme',
'commented on your nsite', etc. instead of always saying 'your post'
or 'your note'. Uses the referenced event's kind to look up a
human-readable noun from a comprehensive kind-to-label map.
2026-03-27 18:14:18 -05:00
filemon 8bd2bca879 Add form-specific color customizers for adult Blobbi SVGs
Implement comprehensive gradient replacement for each adult form to ensure
Blobbi custom colors are properly applied to all visual elements (body, ears,
tail, arms, legs, petals, etc.) while preserving 3D shading gradients.

Forms with full color mapping: catti, droppi, flammi, froggi, leafy, mushie,
rocky, rosey, starri. Forms owli/pandi keep original colors by design.
2026-03-27 20:12:01 -03:00
Mary Kate 27283384bf Merge branch 'fix/zap-emoji-support' into 'main'
Add emoji picker and shortcode autocomplete to zap comment box

Closes #176

See merge request soapbox-pub/ditto!123
2026-03-27 23:02:55 +00:00
Mary Kate Fain 9901635008 Add zap button to badge detail view action bar
The BadgeDetailContent action bar had reactions, reposts, and comments
but was missing the zap button that NoteCard renders for the same
events in the feed.
2026-03-27 17:52:52 -05:00
Mary Kate Fain 1f5ce2546c Add emoji picker and shortcode autocomplete to zap comment box
Extract shared useInsertText hook to DRY up the duplicated text
insertion logic across ComposeBox, DMChatArea, and ZapDialog.
Add EmojiPicker (GUI) and EmojiShortcodeAutocomplete (:shortcode
typing) to the zap comment textarea, and also add shortcode
autocomplete to the DM chat input which was previously missing it.

Closes #176
2026-03-27 17:46:16 -05:00
Mary Kate 2f0adcce7c Merge branch 'fix/theme-description-expand' into 'main'
Show full theme description on theme detail page

Closes #124

See merge request soapbox-pub/ditto!122
2026-03-27 22:34:08 +00:00
Lemon 7bfab65042 Fix toast swipe direction to match entry direction on mobile
On mobile, toasts enter from the top but previously could only be swiped
right to dismiss. Now swipe direction is responsive: swipe up on mobile
(top-positioned), swipe right on desktop (bottom-right positioned). Exit
animations also match the swipe direction at each breakpoint.
2026-03-27 15:13:43 -07:00
Mary Kate Fain b59eeeca81 Show theme description on 'updated their theme' (kind 16767) posts
Kind 16767 events previously hardcoded description to undefined, so the
theme description never appeared on 'updated their theme' posts in the
feed or detail view.

Three changes:
- buildActiveThemeTags now accepts and includes a description tag, so
  future kind 16767 events carry the description directly
- setActiveTheme accepts description to thread it through publishing
- ThemeContent extracts the description tag from kind 16767 events, and
  for older events without one, falls back to querying the source theme
  definition via the a-tag reference
2026-03-27 17:09:08 -05:00
Mary Kate Fain 81e42f24c8 Exclude Capacitor native-only plugins from Vite dep optimization
@capacitor/filesystem and @capacitor/share are dynamically imported
behind a Capacitor.isNativePlatform() guard, but Vite's import analysis
plugin still tries to resolve them at transform time in dev mode. This
causes a 'Failed to resolve import' error when running the dev server.

Excluding them from optimizeDeps prevents Vite from pre-bundling these
packages, letting the dynamic imports resolve naturally at runtime.
2026-03-27 16:59:24 -05:00
Mary Kate Fain d4a928b682 Show full theme description on post detail page
On the feed, theme descriptions are truncated to a single line. On the
post detail page, the full description is now displayed so users can
read long descriptions that don't fit in the thumbnail card.

Closes #124
2026-03-27 16:52:20 -05:00
Alex Gleason da27054a9b Fix file downloads and URL opening on Capacitor iOS
The <a download> and <a target="_blank"> patterns don't work in
WKWebView. Add downloadTextFile() and openUrl() utilities in
src/lib/downloadFile.ts that use @capacitor/filesystem and
@capacitor/share on native platforms, falling back to standard
browser behavior on web.

Update all call sites: onboarding key download (InitialSyncGate,
SignupDialog), image lightbox buttons (ImageGallery, ProfilePage).

Document Capacitor compatibility constraints in AGENTS.md.
2026-03-27 16:42:36 -05:00
Alex Gleason 3c54cd27fe Fix package-lock name 2026-03-27 15:41:39 -05:00
Alex Gleason 8fe8525b06 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 15:40:40 -05:00
Mary Kate 9169cd5d1f Merge branch 'fix/letter-notifications' into 'main'
Fix: add letter (kind 8211) notifications

Closes #188

See merge request soapbox-pub/ditto!118
2026-03-27 20:30:42 +00:00
Mary Kate 490b8554e2 Merge branch 'badge-notification-preview' into 'main'
Show badge thumbnail preview in badge award notifications

Closes #186

See merge request soapbox-pub/ditto!119
2026-03-27 20:29:16 +00:00
filemon 97748dfd34 Remove page entry from streak triggers - only real interactions count
Streak now only updates from actual care interactions:
- Direct actions (play_music, sing)
- Inventory item use (feed, clean, treat, etc.)
- Stage transitions (hatch, evolve)
- Rest action (sleep/wake toggle)

Page visits and app opens no longer count toward streak.
2026-03-27 17:08:27 -03:00
filemon 0d8b320f31 Implement improved Blobbi care streak system with day-based tracking
Add new streak tags to kind 31124 events:
- care_streak: Consecutive days of care (starts at 1, resets to 1 if 2+ days missed)
- care_streak_last_at: Unix timestamp of last streak update
- care_streak_last_day: Local calendar day (YYYY-MM-DD) of last update

Streak validation rules:
- Initialize to 1 on first activity
- Increment when activity occurs on the next local day
- Same-day activity does not increment (at most once per day)
- Missing 2+ days resets streak to 1

Files added:
- blobbi-streak.ts: Centralized streak calculation logic
- useBlobbiCareActivity.ts: Hook for registering care activity

Streak integration points:
- Blobbi page entry (automatic check-in)
- Direct actions (play_music, sing)
- Inventory item use
- Stage transitions (hatch, evolve)
- Rest action (sleep/wake)
- Companion item use (outside BlobbiPage)
2026-03-27 17:01:35 -03:00
Alex Gleason 32b0cef65d Change toast swipe direction from right to up for mobile dismissal 2026-03-27 14:57:51 -05:00
Mary Kate Fain abcb51c0e2 Show badge thumbnail preview in badge award notifications
Closes #186. Badge award notifications now display a visual preview
card with the badge image, name, and description for both single and
grouped badge notifications.
2026-03-27 14:44:33 -05:00
Mary Kate Fain 8d02645e26 Enhance letter notification with envelope card preview
Show the sender's profile pic via the EnvelopeCard component (the same
Wii-Mail-inspired envelope tile used in the letters inbox). The card
auto-decrypts to display stationery colors and the sender's avatar as a
wax seal. Clicking the envelope navigates to /letters.
2026-03-27 14:31:05 -05:00
filemon 6ab05471b2 Migrate Blobbonaut profile from kind 31125 to 11125 with auto-onboarding
- Change KIND_BLOBBONAUT_PROFILE constant from 31125 to 11125
- Add KIND_BLOBBONAUT_PROFILE_LEGACY (31125) for migration support
- Add BLOBBONAUT_PROFILE_KINDS array to query both kinds
- Update useBlobbonautProfile to prefer 11125 over 31125
- Add needsKindMigration flag for legacy profile detection
- Extend useBlobbonautProfileNormalization to auto-migrate legacy kinds
- Refactor onboarding to auto-create profile using kind 0 name
- Remove manual name entry step from onboarding flow
- Delete unused BlobbiProfileOnboarding component
- Update all documentation comments to reference kind 11125
2026-03-27 16:27:26 -03:00
Mary Kate Fain f82adab05d Fix: add letter (kind 8211) notifications
Letters were completely absent from the notification pipeline — users had
to visit the Letters page to discover incoming letters. This integrates
kind 8211 into every layer of the notification system:

- useNotifications: query, grouping, and referenced-event exclusion
- useHasUnreadNotifications: unread dot indicator
- NotificationsPage: LetterNotification component with link to /letters
- NotificationSettings: toggleable Letters row
- notificationTemplates: web push template
- Android NotificationRelayService + NostrPoller: native push support
- EncryptedSettings + schema: letters preference field

Closes #188
2026-03-27 14:15:50 -05:00
filemon 1291a0e932 Fix: ensure t and client tags are removed from Blobbi events
The previous commit removed these tags from event building functions, but
the tag validation/repair system in blobbi-tag-schema.ts was re-adding them:

1. BLOBBI_TAG_SCHEMA had 't' marked as required:true with defaultValue:'blobbi'
2. RECOVERABLE_SYSTEM_TAGS had both 't' and 'client' with default values
3. DEPRECATED_TAG_SCHEMA did not include 't' or 'client'

Fixed by:
- Removing 't' and 'client' from BLOBBI_TAG_SCHEMA (no longer required)
- Removing 't' and 'client' from RECOVERABLE_SYSTEM_TAGS
- Adding 't' and 'client' to DEPRECATED_TAG_SCHEMA

Now the validateAndRepairBlobbiTags function will properly filter out
these tags during any republish/migration/update flow.
2026-03-27 15:58:44 -03:00
filemon a2d40c5cbf Remove Blobbi shape system and clean up t/client tags
- Remove BLOBBI_TOPIC_TAG and BLOBBI_CLIENT_TAG from event building
- Add t and client to DEPRECATED_BLOBBI_TAG_NAMES for migration cleanup
- Update validation functions to not require t tag
- Delete blobbiShapes.ts and BlobbiShapePicker.tsx entirely
- Simplify avatarShape.ts to only support emoji shapes
- Remove blobbi_shape task from useEvolveTasks
- Remove change_shape task from useHatchTasks
- Remove change_shape mission from daily-missions
- Clean up ProfileCard shape picker to only show emoji picker

The app's useNostrPublish hook already adds client tags automatically,
making the explicit client tag redundant. Old events with these tags
will have them stripped on next save.
2026-03-27 15:54:20 -03:00
Alex Gleason 17954e0504 Fix badges page showing infinite skeleton when logged out
The useBadgeFeed hook required a logged-in user before enabling the query,
causing the follows tab to show loading skeletons forever when logged out.
Now fetches the Team Soapbox follow pack (kind 39089) and uses its members
as the authors filter, giving logged-out users a curated badge feed.
2026-03-27 13:15:44 -05:00
filemon 2e5e6c9ad3 feat: add 'hungry' emotion for Blobbi
New emotion that conveys low energy + wanting food:
- Watery eyes (like sad) but WITHOUT blue water fill - longing, not crying
- Worried/sad eyebrows for that wanting/longing look
- Droopy mouth - less curved than sad frown, softer and more tired
- Small drool drop from corner of mouth with subtle wobble animation
- Fork & knife icon above head (subtle, 65% opacity)

Also adds new config types:
- DroolConfig: drool drop effect
- FoodIconConfig: utensils/plate icon above head
- DroopyMouthConfig: weak/tired frown with adjustable width and curve
2026-03-27 12:56:45 -03:00
filemon c1e9143483 fix: add missing pupil colors for adult Blobbi eye detection
Added #1e1b4b (dark indigo) for starri/crysti and #0891b2 (cyan) for
droppi to the PUPIL_COLORS array. Without these colors, the eye
animation system couldn't detect pupils in these adult forms, causing
eyes to not render properly (only showing eyelids).
2026-03-27 12:25:59 -03:00
filemon 774f7d2dbe Add sparkles around Blobbi and create excited variation B
Sparkles:
- Move sparkles from around eyes to around entire Blobbi body
- 11 sparkles distributed around the perimeter (top, sides, bottom)
- Subtle fade/twinkle animation with staggered timing
- Soft opacity (0.7 max) for gentle effect

Excited variations:
- Excited A (original): star eyes + big smile
- Excited B (new): star eyes + round 'O' mouth (like curious)
- Both include sparkles around the Blobbi
- Added excitedB to emotion tester panel for comparison
2026-03-27 12:16:25 -03:00
filemon de9eab1e4e Finalize emotion visual tweaks
Sleepy closed-eye lines:
- Increase curve depth (0.5x radius) to match eye curvature
- Position slightly lower (0.75x radius offset)
- Disappear immediately at 63% (as eyes start opening)

Excited star eyes:
- Reduce star scale from 1.4 to 0.9 for cuter look
- Insert stars INTO blobbi-eye groups so they track with eye movement
- Stars now follow mouse cursor like normal pupils

Excited sparkles:
- Add 4 animated sparkles around each eye
- Small 4-pointed star shapes that twinkle
- Staggered animations (0.3s delay between each)
- Positioned at orbit around the eyes
2026-03-27 12:07:27 -03:00
filemon 9b1925d6de Refine excited, sleepy, and adoring emotions
Excited eyes:
- Keep white eye circle visible behind the star
- Only hide pupils (.blobbi-eye), not entire blink group

Sleepy animation:
- Update to use new clip-path blink system
- Add SMIL animations to clip-path rects for eye closing
- Remove old scaleY CSS animation
- Eyes now close with natural eyelid-down effect

Adoring eyes:
- Add includeWaterFill option to PupilModification
- Adoring uses watery highlights but no blue fill
- Sad retains the blue watery semicircle
2026-03-27 12:02:24 -03:00
filemon a6ad1bdcdb Refine blink animation and eyelid system
Blink behavior:
- Change from scaleY to clip-path mask approach
- Eye keeps original size, visible area cropped from top to bottom
- Creates natural eyelid-closing effect revealing eyelid layer behind
- Add clipPath definitions and animate rect Y/height

Eyelid color:
- Reduce darken amount from 15% to 8%
- Subtle contrast that reads as eyelid, not shadow

Sleepy closed-eye lines:
- Lower position by 70% of eye radius
- Aligns with final closed eye position in clip-path system
2026-03-27 11:53:25 -03:00
filemon f9c8bbc4cc Add eyelid background layer and update excited emotion with star eyes
Eyelid layer:
- Add blobbi-eyelid ellipse behind each eye white
- Derive color from base body color (darkened by 15%)
- Pass baseColor to addEyeAnimation from visual components
- Ready for future blink animation integration

Excited emotion:
- Replace eyes with 5-pointed golden stars
- Add big smile (30% wider, 40% deeper curve)
- Hide normal eyes when stars are active
- Clean, readable across baby and adult variants
2026-03-27 11:44:30 -03:00
filemon 3634440c8b Update excited emotion and add adoring emotion
- excited: use normal eyes, keep animated eyebrows, use curious round mouth
- adoring: watery eyes (like sad), no eyebrows, curious round mouth
- Add adoring to emotion tester panel
2026-03-27 11:37:18 -03:00
Chad Curtis 488ce5750d Hide top nav on scroll and unify top padding across pages
- MobileTopBar: changed from sticky to fixed positioning with scroll-hide
  transform animation (mirrors bottom nav behavior via useScrollDirection)
- MainLayout: replaced -mt-mobile-bar overlap trick with pt-mobile-bar
  padding since the top bar is now fixed; added data-nav-hidden attribute
  to drive CSS transitions on sticky sub-headers
- SubHeaderBar/top-mobile-bar: sticky top offset transitions to 0 when
  the top bar hides, keeping sub-headers flush with the viewport top
- NotificationsPage, ProfilePage: added arc overhang spacer after
  SubHeaderBar to match Feed's spacing
2026-03-27 02:07:12 -05:00
filemon c963673a19 feat(blobbi): add dizzy, excited, mischievous emotions with animated effects
- Add dizzy emotion with rotating spiral eyes (counter-clockwise)
- Add excited emotion with watery eyes, bouncing sad-style eyebrows, and smile
- Add mischievous emotion with bouncing angry-style eyebrows and small smug smile
- Wrap eyebrows in groups to preserve rotation while CSS animates translateY
- Add new emotions to DEV emotion panel for testing
2026-03-27 02:17:49 -03:00
filemon 0602c1b59d refine(blobbi): adjust sleepy animation timing and transitions
Three targeted refinements to sleepy emotion:

1. Mouth transition simplified:
   - Now goes directly: smile → U-shaped → smile
   - Removed intermediate flat line phase
   - Smoother, more natural transition

2. Eyes fully hidden when closed:
   - Changed scaleY from 0.05 to 0 when fully closed
   - Original eye completely disappears
   - Only curved closed-eye line visible during sleep

3. Zzz appears from the beginning:
   - Starts at 0% with opacity 0
   - Fades in softly: 10% → 0.2, 20% → 0.4
   - Full opacity by 35% (during sleep)
   - Creates 'getting sleepy' feel from the start
2026-03-27 01:33:04 -03:00
filemon d8a81879b1 feat(blobbi): redesign sleepy emotion as micro-sleep animation
Complete rewrite of sleepy emotion to be a 'micro-sleep' cycle:

Eye Animation:
- Slow eye closing over ~3 seconds (not partial blinks)
- Eyes fully close with scaleY near 0
- Curved closed-eye lines appear when eyes shut (like eyelids)
- Wake-up includes quick right-left glance before settling

Mouth Animation:
- Smile → flat line → small round mouth (while falling asleep)
- Small round mouth holds during sleep
- Reverses back: round → flat → smile (while waking)
- Uses SMIL path animation for smooth morphing

Sleep State:
- Zzz text appears above head during sleep
- Zzz fades in/out and floats upward
- Sleep holds for ~1 second before waking

Full 8-second cycle timeline:
- 0-10%: Awake, normal smile
- 10-35%: Getting sleepy, eyes closing, mouth flattening
- 35-50%: Eyes closed, mouth becomes round
- 50-62%: Asleep (hold with Zzz)
- 62-75%: Waking, eyes opening
- 75-90%: Quick right-left glance
- 90-100%: Return to normal, cycle repeats
2026-03-27 01:27:46 -03:00
filemon c054bc7bc7 Merge branch 'main' into feat-blobbi 2026-03-27 01:25:27 -03:00
filemon daf35f6e41 feat(blobbi): add curious asymmetric eyebrow and sleepy animation
Curious emotion:
- Right eyebrow now raised slightly more than left for questioning look
- Added per-eye override support in EyebrowConfig (leftEyeOverride/rightEyeOverride)

Sleepy emotion:
- Implemented 3-stage tired blink animation cycle:
  1. Small blink (~25% closed)
  2. Medium blink (~55% closed)
  3. Heavy blink (~80% closed)
- Mouth animates from smile → flat → smile in sync with blinks
- Uses CSS keyframe animation for smooth, slow transitions
- 8-second cycle duration for natural tired feel
- Added SleepyAnimationConfig type for configuration
2026-03-27 01:17:05 -03:00
filemon d947a951ad feat(blobbi): add curious/surprised emotions and adjust baby eyebrows
- Add 'curious' emotion: small round mouth, soft curved eyebrows
- Add 'surprised' emotion: larger round mouth, raised curved eyebrows
- Add round mouth generation (replaces default mouth like sad/angry)
- Add curved eyebrow support via new 'curve' config option
- Move baby eyebrows slightly farther from eyes (-2px offset)
- Add variant parameter to applyEmotion for baby/adult differences
- Add both new emotions to DEV emotion panel
2026-03-27 01:00:01 -03:00
filemon 273cf1094d fix(blobbi): fix sad eye highlights and add angry body animation
- Fix invalid SVG generated by sad highlight injection regex
- Refactor water fill to insert inside blink groups (below pupil, above eye white)
- Add anger-rise body effect that animates red color from bottom to top
- Uses clipPath to constrain effect to body shape
2026-03-27 00:51:32 -03:00
filemon c907779b3c Fix sad eye highlight injection with robust parsing and debug logs
- Replace fragile regex with index-based string manipulation
- Find opening tag first, then locate closing tag by position
- Add DEV-only debug logging for eye detection and injection
- Log whether blobbi-eye groups are found and matched
- Log eye positions and water fill generation
- Should fix sad highlights not appearing in blobbi-eye groups
2026-03-27 00:35:24 -03:00
filemon 2d9ff34ded Fix sad eye tracking/blinking integration and slow down tears
- Hide original highlights by adding opacity=0 inside blobbi-eye groups
- Inject sad highlights INTO blobbi-eye groups so they track with pupil movement
- Sad highlights now move with eye tracking and participate in blinking
- Blue water fill stays as overlay (on eye white, doesn't need tracking)
- Slow tears: duration 6s with 3s pause between cycles
- Alternating tear mode: tears switch sides each cycle (no flickering)
- Only affects SAD emotion, angry/neutral unchanged
2026-03-26 20:32:18 -03:00
filemon d1a85659ba Fix sad eye visuals: reposition water shape and highlights
- Position blue water shape relative to eye white (not pupil center)
- Water now sits at bottom of eye white like pooled tears
- Reposition highlights: upper (larger) and lower (smaller) with clear separation
- Upper highlight at cy - radius*0.55, lower at cy + radius*0.35
- Only affects SAD emotion (generateSadEyeEffects), angry unchanged
2026-03-26 20:28:44 -03:00
filemon d832e6e364 Fix Blobbi sad emotion visuals and dev panel labeling
- Lower sad mouth position by adding Y offset based on curve amount
- Fix sad eye highlights: larger top-left, smaller right-side
- Change blue watery fill to proper lower 1/3 semicircle shape using path
- Fix baby eye detection to match gradient fills (url(#...Pupil...))
- Swap sad/angry eyebrow angles: sad now worried (/\), angry now aggressive (\/)
- Replace mouth safely using regex-only approach (no section slicing)
- Update dev panel to label default as 'Default' with happy emoji
2026-03-26 20:24:05 -03:00
filemon 66cfe9ee45 Add Blobbi emotion system with DEV testing panel
- Add EmotionDevContext and BlobbiEmotionPanel for dev-only emotion testing
- Create emotions.ts with configurable emotion overlays (sad, happy, angry, surprised, sleepy)
- Use deterministic tear selection (hash-based) to prevent flickering
- Add marker-based SVG detection with regex fallback for mouth/eye elements
- Update visual components to pass emotion prop through hierarchy
- Add SVG comment markers to all Blobbi base SVGs for reliable element detection
2026-03-26 19:47:36 -03:00
Alex Gleason e530e38721 fix: prevent font picker clicks from passing through to theme selector
Fixes #171
2026-03-25 18:35:25 -05:00
Alex Gleason d98ae9cdbf fix: consistent feed header ordering — title above tabs on all feed pages
Fixes #153, #155, #156, #157, #158, #159
2026-03-25 18:29:52 -05:00
Alex Gleason 5100b76ad3 fix: improve contrast on focused emoji category text in picker
Increases the opacity of emoji-mart nav button text from 0.65 to 0.85
by injecting CSS overrides into the shadow DOM. This improves readability
and meets WCAG contrast requirements for the category navigation icons.

Fixes #174
2026-03-25 18:28:45 -05:00
Alex Gleason 8328af802f fix: auto-focus search box when emoji picker opens
Fixes #162
2026-03-25 18:27:43 -05:00
The Daniel 87914291c6 ci: retrigger pipeline 2026-03-25 15:27:53 -04:00
filemon 0c7daef65e Add DEV MODE Blobbi state editor for testing
- Create BlobbiDevEditor modal component for direct state editing
- Add useBlobbiDevUpdate hook using standard update/publish flow
- Support editing: stage, state, adult form, all stats
- Support editing: experience, care streak, generation, breeding ready, visibility
- Add stat presets: Max Stats, Starving, Exhausted, Dirty, Sad, Critical Health, etc.
- Add wrench icon button to hero section (DEV only)
- Wire to existing updateBlobbiTags pipeline for consistent Nostr events
- Only renders in development mode (import.meta.env.DEV)
2026-03-25 12:42:44 -03:00
filemon 09da778d3b Add need-based Blobbi reactions when items land
- Create centralized need detection system with configurable thresholds
- Add continuous gravity for items dropped mid-air (fall to ground)
- Blobbi now glances at items it doesn't need (brief look)
- Blobbi shows interest in items it needs (longer attention)
- Add ItemLandedData interface with position info for reactions
- Create useCompanionItemReaction hook for need-based behavior
- Expose triggerAttention from useBlobbiCompanion hook
2026-03-25 12:23:13 -03:00
filemon 2ad64bbca7 Filter egg-only items from companion interaction system
Since companions can only be baby or adult (not egg), egg-only items
like Shell Repair Kit should never appear in the companion flow.

Changes:
- Update resolveItemsForAction to use centralized canUseItemForStage
- Add item-stage validation in useBlobbiItemUse mutation
- Egg-only items are now filtered at both display and use layers

The filtering is now enforced by:
1. resolveItemsForAction - items won't appear in hanging items menu
2. useBlobbiItemUse - validation prevents use even if somehow displayed
2026-03-25 11:33:05 -03:00
filemon 247b94f3b3 Fix item effect display consistency across Blobbi UIs
- Create shared ItemEffectDisplay component as single source for effect rendering
- Update BlobbiInventoryModal to show ALL effects (was truncated to 2)
- Update BlobbiShopItemRow to show ALL effects (was truncated to 3)
- Update BlobbiPurchaseDialog to use shared component
- Use canonical stat display order: hunger, happiness, energy, hygiene, health
- Deprecate formatEffectSummary in favor of ItemEffectDisplay component

The root cause was effect display truncation in the UI, not inconsistent data.
All item definitions remain in blobbi-shop-items.ts (single source of truth).
2026-03-25 11:24:36 -03:00
filemon 8078ad5609 Refactor hanging items to support multiple dropped instances
- Replace releasedItemIds (Set) with releasedCountByItemId (Map) to track
  how many instances of each item type have been released
- Generate unique instanceId for each dropped item (format: itemId-timestamp-counter)
- Hanging items now show remaining quantity (quantity - releasedCount)
- Multiple instances of the same item type can exist on the ground simultaneously
- Each dropped instance tracks independently via instanceId
- Update all callbacks and state tracking to use instanceId instead of item.id
- When item is used successfully, decrement releasedCount for that item type

This enables the desired UX where clicking a hanging item immediately shows
a new copy in the hanging slot (if quantity > 1), while the released instance
falls independently.
2026-03-25 11:07:54 -03:00
filemon 7fd70ac0d9 fix(blobbi): make item-use work reliably from companion system
Major architectural fix for Blobbi companion item-use system:

1. Created shared useBlobbiItemUse hook
   - Works standalone outside of BlobbiPage
   - Uses same real item-use logic as BlobbiPage
   - Built-in per-item cooldown tracking
   - Fetches companion/profile data on-demand when needed

2. Refactored BlobbiActionsContext
   - Now has built-in fallback using useBlobbiItemUse
   - Item use works from ANY page, not just /blobbi
   - BlobbiPage registration is optional (provides better cache access)
   - No more 'canUseItems = false' when BlobbiPage not mounted

3. Fixed retry/flood issues in HangingItems
   - Added per-item cooldown (3s on failure, 0.5s on success)
   - Implemented zone ENTRY detection (not continuous overlap)
   - Items only trigger auto-use when ENTERING the Blobbi zone
   - Items must leave zone before re-triggering
   - Multiple protection layers prevent spam

4. Fixed useBlobbonautProfile side-effect
   - Moved setBootCache from useMemo to useEffect
   - Added ref-based signature tracking to prevent loops
   - Proper cleanup and stable dependencies

Files changed:
- NEW: src/blobbi/companion/interaction/useBlobbiItemUse.ts
- src/blobbi/companion/interaction/BlobbiActionsContext.tsx
- src/blobbi/companion/interaction/HangingItems.tsx
- src/blobbi/companion/interaction/index.ts
- src/blobbi/companion/components/BlobbiCompanionLayer.tsx
- src/hooks/useBlobbonautProfile.ts
2026-03-25 06:38:37 -03:00
filemon dbdaff2ada fix(blobbi): resolve performance issues in falling-item drag-drop system
Root cause analysis and fixes:

1. Drag-to-use freeze/loop (HangingItems):
   - Problem: When dropping on Blobbi, item position was set ON Blobbi,
     triggering contact detection to also call attemptUseItem, creating a loop
   - Problem: attemptUseItem had itemsBeingUsed as a dependency, so when it
     changed (inside the callback), the callback identity changed, re-triggering
     the contact detection effect
   - Fix: Changed itemsBeingUsed from state to ref to avoid callback recreation
   - Fix: When dropping on Blobbi, reset item to ORIGINAL position before
     attempting use (prevents contact detection from firing)
   - Fix: Made attemptUseItem have no dependencies (uses refs for everything)
   - Fix: Gated all console.log calls behind import.meta.env.DEV

2. useBlobbonautProfile console flood:
   - Problem: Unconditional console.log at line 63 ran on EVERY render
   - Problem: queryFn had multiple console.logs that ran on every query
   - Fix: Removed/commented out all console.logs in the hook
   - Analysis: The hook itself was NOT causing extra renders - it was just
     exposing the render frequency with its logging

3. BlobbiActionsContext registration instability:
   - Problem: Registration used useState which triggered re-renders on every
     update, and the registration effect depended on useItem identity
   - Fix: Refactored to use refs instead of state for registration data
   - Fix: Added subscription pattern for manual notification only when
     canUseItems actually changes (major state change)
   - Fix: Consumer hook's useItem callback is now stable (reads from ref)
   - Fix: Provider context value is now stable (never changes identity)

Guards now preventing repeated item-use attempts:
- itemsBeingUsedRef.current check at start of attemptUseItem
- Contact detection skips items in itemsBeingUsedRef
- Drag-drop resets item position BEFORE calling attemptUseItem
- attemptUseItem has no dependencies that could trigger recreation
2026-03-24 22:20:04 -03:00
filemon 3e53e368a4 fix(blobbi): wire BlobbiActionsContext correctly and add drag-drop for items
Part 1: Context Wiring Fix
- Refactored BlobbiActionsContext to use registration pattern
- BlobbiActionsProvider now mounted at app level in AppRouter (wraps BlobbiCompanionLayer)
- BlobbiPage registers its item-use function via useBlobbiActionsRegistration hook
- BlobbiCompanionLayer now receives real context with canUseItems: true
- Added debug logs to confirm context state changes

Part 2: Drag-and-Drop for Released Items
- Added 'dragging' state to ReleasedItemState
- Implemented pointer event handlers for drag detection (threshold-based)
- Items can be dragged after landing on the ground
- Visual feedback: items scale up when over Blobbi, glow effect on Blobbi
- Drop-on-Blobbi triggers real item use flow
- Drop elsewhere leaves item at drop position

All three item use paths (contact, click, drag-drop) use the same
real onItemUse callback, ensuring consistent behavior and proper
kind 31124 event publishing.
2026-03-24 22:05:23 -03:00
filemon 6a7c037ea8 feat(blobbi): implement real item use system for companion falling items
- Add BlobbiActionsContext to bridge companion UI with item use functionality
- Create useCompanionItemUse hook with category-to-action mapping (food→feed, toy→play, etc.)
- Update HangingItems with async onItemUse callback and success/failure handling
- Wire BlobbiCompanionLayer to use context-provided item actions
- Provide BlobbiActionsProvider in BlobbiPage so items actually update stats
- Items only disappear after successful use, stay on screen if use fails
2026-03-24 21:57:56 -03:00
filemon 2414441efa Merge branch 'main' into feat-blobbi 2026-03-24 21:32:47 -03:00
filemon 14deb86a7a Fix stuck rescue handoff: eliminate visual flash and improve gravity
Visual flash fix:
- Pass wasResolvedFromStuck flag through to BlobbiCompanion
- When entry was resolved from stuck and phase is 'complete', skip entry
  animation position and use motion.position directly
- This prevents the one-frame flash where 'complete' phase returns
  groundPosition before acknowledgeCompletion() runs

Gravity fix:
- Increase gravity from 800 to 3500 px/s² to match entry animation feel
- Previous value caused slow floaty descent after drag release
- New value creates responsive, natural-feeling fall that matches the
  scripted entry fall animation

The handoff now works cleanly:
1. User drags Blobbi while stuck_permanent
2. User releases → isDragging=false, motion.position=drag release point
3. resolvePermanentStuck() sets wasResolvedFromStuck=true, phase='complete'
4. BlobbiCompanion sees wasResolvedFromStuck+complete → uses motion.position
5. Physics system applies gravity from the exact release position
6. acknowledgeCompletion() runs → phase='idle' → normal motion continues
2026-03-24 21:24:38 -03:00
filemon 572c3b082e Fix stuck rescue handoff: preserve drag release position instead of snapping to groundPosition
- Add wasResolvedFromStuck flag to track whether entry completed via
  stuck rescue vs natural animation completion
- Skip setPosition(groundPosition) when entry was resolved from stuck
  rescue, since motion.position already has the correct drag release
  position from the user's drag interaction
- Motion system now continues naturally from the drag release position,
  handling gravity/falling as expected
2026-03-24 21:15:06 -03:00
filemon d2b466df93 Fix stuck_permanent visibility: increase chance to 40%, add wiggle animation, and prevent auto-resolve
- Increase trulyStuckChance from 20% to 40% for more visible stuck behavior
- Add wiggle/struggle animation when Blobbi is truly stuck at ceiling
- Fix bug where stuck_permanent would auto-resolve after 50ms because
  isDragging starts as false - now tracks whether user has actually
  started dragging before allowing resolve on release
2026-03-24 21:12:17 -03:00
filemon 3f11465a7e Fix shadow to be a floor shadow that only appears near ground
Ground Proximity Detection:
- BlobbiCompanion now calculates distanceFromGround from actual Y position
- isOnGround = not entering, not dragging, and within 5px of ground position
- Both values passed to BlobbiCompanionVisual as new props

Shadow Visibility Rules:
- Shadow only shows when isOnGround is true
- Shadow hidden during: dragging, entry animations (fall/rise), falling
- Shadow fades smoothly over SHADOW_FADE_DISTANCE (30px)
- Additional subtle fade during float animation for breathing effect

Shadow Visual Changes:
- Position: bottom -12px → -20px (farther from body, feels like floor)
- Width: 55% → 50% of size (slightly narrower)
- Height: 10% → 8% of size (thinner, more subtle)
- Blur: 3px → 4px (softer edge)
- Max opacity: 0.4 → 0.35 (more subtle)
- Added CSS opacity transition for smooth fade in/out

States Where Shadow Is Hidden:
- Being dragged (isDragging)
- Fall entry animation (isEntering, entryType='fall')
- Rise entry animation (isEntering, entryType='rise')
- Any state where distanceFromGround >= 30px
- Any off-ground position (y < groundPosition.y - 5)

States Where Shadow Is Visible:
- Idle on ground
- Walking on ground
- Floating (with subtle fade based on float offset)
2026-03-24 20:47:03 -03:00
filemon ece9a37af4 Increase Blobbi size 35% and move shadow farther away
Size Changes:
- Companion size: 80px → 108px (+35%)
- Changed in DEFAULT_COMPANION_CONFIG.size (central config)
- All derived calculations automatically use new size:
  - Entry animations
  - Drag behavior
  - Ground positioning
  - Movement bounds
  - Gaze calculations

Padding Adjustments:
- Left padding: 80px → 100px (more room for larger Blobbi)
- Right padding: 20px → 24px
- Bottom padding: 20px → 24px

Shadow Improvements:
- Position: bottom -4px → -12px (3x farther from body)
- Width: 60% → 55% of size (slightly narrower)
- Height: 12% → 10% of size (slightly thinner)
- Blur: 2px → 3px (softer edge)
- Gradient adjusted for cleaner fade

Action Menu Adjustment:
- Radius: 70px → 85px (maintains visual spacing from larger Blobbi)

HangingItems Default:
- Updated companionSize default: 80 → 108 to match config

All systems continue to work correctly:
- Entry/exit animations scale with size
- Drag centering uses config.size
- Contact detection uses passed companionSize
- Action menu follows rendered position
2026-03-24 20:41:17 -03:00
filemon 7f4cf8bdcd Fix first-click animation bug, reduce hanging/released item sizes
Animation Bug Fix:
- Root cause: useEffect with releasedItems dependency only ran AFTER
  state update, but animation check happened once at effect start
- Fix: Use refs to track animation state and latest releasedItems
- runAnimationLoop() is now a stable callback that reads from refs
- handleItemClick calls runAnimationLoop via setTimeout(0) to ensure
  state update is processed first
- isAnimatingRef prevents duplicate animation loops
- Animation now starts immediately on first item click

Size Reductions (Hanging Items):
- Circle size: 72px → 56px
- Emoji size: 2.25rem → 1.75rem
- Item spacing: 100px → 80px
- Line length: 120px → 100px
- Badge size: 24px → 20px

Size Reductions (Released/Landed Items):
- Falling emoji: 2.5rem → 1.875rem
- Landed hitbox: 48px → 40px
- Contact radius: 60px → 50px
- Fall duration: 700ms → 600ms

Preserved Behavior:
- Hanging items use line + circle
- Only emoji falls after release (no circle)
- Continuous object from falling to landed
- Landed items remain on ground
- Contact detection still removes items
2026-03-24 20:35:12 -03:00
filemon d6538aac50 Continuous item lifecycle: emoji-only fall, no respawn, Blobbi contact detection
Falling Visual - Only Emoji Falls:
- When clicked, the hanging circle/container disappears immediately
- Only the emoji itself falls (no enclosing circle, no badge)
- ReleasedItem component renders just the emoji with drop shadow
- Slightly larger emoji size for falling/landed state (2.5rem)

Continuous Object Lifecycle:
- Single ReleasedItemData tracks item through entire lifecycle
- States: hanging → falling → landed (same object throughout)
- Position animated via requestAnimationFrame (not CSS animation)
- No disappear-and-respawn: emoji smoothly transitions from fall to ground
- Fall uses eased animation: accelerates then slows near ground

Contact Detection with Blobbi:
- Receives companionPosition and companionSize from parent
- Checks distance between Blobbi center and each landed item
- Contact threshold: companionSize/2 + 60px radius
- On contact: item removed, onItemCollected callback fired
- Works both ways: Blobbi walks into item OR item lands near Blobbi
- Manual pickup also supported (clicking landed item)

State Model:
- releasedItemIds: Set<string> - tracks which items left hanging state
- releasedItems: Map<string, ReleasedItemData> - full lifecycle data
- ReleasedItemData contains: item, state, x, y, startY, targetY, fallStartTime

Future-Ready Structure:
- onItemCollected callback ready for effects/reactions
- Position data available for drag implementation
- State model supports attraction behavior
- Clean separation: hanging container vs released items
2026-03-24 20:25:25 -03:00
filemon 3a47fccf16 Improve hanging items: larger size, slide animations, landed items persist
Size Improvements:
- Circle size: 52px → 72px (38% larger)
- Emoji size: text-2xl → text-4xl (2.25rem)
- Item spacing: 80px → 100px (center to center)
- Line length: 100px → 120px
- Badge size: 20px → 24px

Open/Close Slide Animation:
- Container states: hidden → opening → open → closing → hidden
- Items descend from above when opening (slide down animation)
- Items ascend when closing (slide up animation)
- 350ms slide animation duration
- Items no longer abruptly appear/disappear

Landed Items System:
- Released items fall all the way to the ground (calculated from viewport)
- Items remain visible on ground after landing
- Landed items render as separate LandedItem components
- Landed items persist even after menu closes
- Landed items are clickable (placeholder for future pickup)

State Model:
- ContainerState: hidden | opening | open | closing
- ItemState: hanging | falling | landed
- ItemStateData tracks: id, state, fallStartX, landedY
- landedItems Map persists item positions for ground rendering

Future-Ready Structure:
- onPickup callback ready for landed items
- Separate LandedItem component for ground items
- State model supports drag, attraction, consumption
- CSS variables for dynamic fall distance
2026-03-24 20:16:26 -03:00
filemon c659eaeead Replace item bubbles with hanging items, fix action menu tracking
Action Menu Fixes:
- Remove useMemo for position calculations to avoid stale values
- Calculate button positions directly each render
- Menu now follows Blobbi continuously during all states
  (idle, walking, floating, dragging, settling)

Hanging Items System:
- New HangingItems component replaces CompanionItemBubbles
- Items appear as circles hanging from vertical lines at top of screen
- Wider horizontal spacing (80px between items)
- Playful, spatial presentation instead of modal-like container

Click-to-Fall Animation:
- Clicking an item releases it from the hanger
- Line and quantity badge disappear instantly
- Item falls with rotation animation (800ms)
- Structured for future extension (drag, attraction, reactions)

Pointer Events:
- Container uses pointer-events-none
- Individual items use pointer-events-auto
- All items are now properly clickable

Removed:
- CompanionItemBubbles component (deleted)
- Modal-like container presentation
- Close button (no longer needed)
2026-03-24 20:07:16 -03:00
filemon 37c7a37bdf Fix companion menu positioning and pointer-events
- Expose rendered position from BlobbiCompanion via onPositionUpdate callback
- Track actual visual position in BlobbiCompanionLayer (includes entry animation + float offset)
- Pass rendered position to CompanionActionMenu instead of logical motion.position
- Add pointer-events-auto to action menu backdrop, buttons, and item bubbles
- Fix pointer events hierarchy (parent layer has pointer-events-none)
- Fix unused variable lint errors (entryProgress, isPermanentlyStuck, etc.)
2026-03-24 19:56:34 -03:00
filemon d3f23544cc Add companion action menu and item bubbles interaction layer
Implement the first interaction layer for the Blobbi companion:

Click vs Drag detection:
- Created useClickDetection hook to distinguish clicks from drags
- Movement threshold: 5px (beyond = drag)
- Time threshold: 300ms (beyond = not a click)
- Updated BlobbiCompanion pointer/touch handlers to use detection

Action Menu (CompanionActionMenu):
- Radial/arc layout centered above Blobbi
- 5 actions: feed, play, medicine, clean, sleep
- Stage-aware: eggs only see medicine and clean
- Smooth fade-in + zoom animation with stagger
- Click outside closes menu
- Route change closes menu

Item Bubbles (CompanionItemBubbles):
- Horizontal row of item bubbles near top of screen
- Shows emoji + quantity badge for each item
- Resolves real inventory items for selected action
- Empty state message when no items available
- Staggered appearance animation

Architecture:
- /interaction/types.ts - Type definitions and config
- /interaction/useCompanionActionMenu.ts - Menu state hook
- /interaction/useClickDetection.ts - Click/drag detection
- /interaction/CompanionActionMenu.tsx - Radial menu component
- /interaction/CompanionItemBubbles.tsx - Item display component
- /interaction/index.ts - Module exports

Action to item category mapping:
- feed -> food items
- play -> toy items
- medicine -> medicine items
- clean -> hygiene items
- sleep -> (no items, direct action)

Future-ready for:
- Item falling animation
- Drag item to Blobbi
- Blobbi walking toward items
- Item consumption logic
- Per-item Blobbi reactions
2026-03-24 19:44:48 -03:00
filemon 9d5ea22806 Upgrade typing attention to caret-aware targeting with 4s timeout
Caret tracking implementation (priority order):
1. contenteditable: window.getSelection() + Range.getBoundingClientRect()
2. input/textarea: selectionStart + mirrored text measurement
3. Fallback: right-side typing region (where new text appears)
4. Last resort: field center

Changes from field-center version:
- Added getContentEditableCaretPosition() using Selection API
- Added getInputCaretPosition() with text width measurement via temp span
- Added getRightTypingRegion() as smart fallback (better than center)
- computeCaretPosition() tries each method in priority order

Event handling (event-driven, no polling):
- keydown: detect typing, update caret after DOM updates via rAF
- input: catch paste, autocomplete, non-keydown text changes
- selectionchange: update when caret moves via arrow keys or click
- focusin/focusout: track element changes, clean up on blur

Timing:
- Increased idle timeout from 2s to 4s for more stable observation
- Timeout resets on any typing event

Priority:
- Typing attention now uses 'high' priority
- Overrides generic modal attention while typing
- Keeps Blobbi focused on caret, not just modal center

Handles edge cases:
- Focus leaving field clears typing attention
- Modal close clears via overlay detection
- Switching fields updates target to new field's caret
- Graceful fallback when exact caret rect unavailable
- Caret position clamped to element bounds
2026-03-24 12:32:08 -03:00
filemon 1388d0e514 Add typing attention behavior for Blobbi in modal text fields
When user types in a text input inside a modal/dialog, Blobbi now:
- Detects focus on text inputs (input, textarea, contenteditable, role=textbox)
- Only activates when the field is inside an overlay (modal, dialog, sheet, drawer)
- Locks attention to the focused field's center while typing continues
- Releases attention after 2s idle timeout (no typing)
- Properly cleans up when focus changes or modal closes

Implementation:
- New useTypingAttention hook handles focus/blur/keydown events
- Integrated into useBlobbiAttention with priority below 'high' but above 'low'
- Typing attention overrides random gaze and mouse-follow during active typing
- Lightweight: uses event listeners, not polling; targets field center, not caret

Config:
- Added typingIdleTimeout (2000ms) to attention config

Edge cases handled:
- Focus moving between text fields updates attention target
- Focus leaving text inputs clears typing attention
- Modal close clears typing attention via overlay detection
- Only typing keys (chars, backspace, delete, enter) reset idle timer
2026-03-24 12:24:20 -03:00
filemon e3412fac46 Merge branch 'main' into feat-blobbi 2026-03-24 12:20:40 -03:00
filemon e43c0b1e2e Fix reactive companion visibility and add BlobbiPage duplicate prevention
Root cause: useBlobbiCompanionData had a separate query key (['companion-profile'])
from useBlobbonautProfile (['blobbonaut-profile']). When companion was
selected/removed via BlobbiPage, only the main profile query was invalidated,
leaving the companion layer with stale data.

Fix:
- Rewrote useBlobbiCompanionData to use useBlobbonautProfile instead of
  duplicating the profile query
- Now shares the same query cache, so profile updates (including currentCompanion
  changes) immediately propagate to the companion layer
- Added explicit null return when currentCompanionD is undefined for reactive removal

BlobbiPage duplicate prevention:
- Added check for active floating companion (isActiveFloatingCompanion)
- When the displayed Blobbi is the same as the floating companion, show a
  friendly message: '{name} is out exploring right now.' with Footprints icon
- Prevents seeing two identical Blobbis (one floating, one in-page)

This makes companion selection/removal/replacement fully reactive without
requiring page refresh.
2026-03-24 12:19:00 -03:00
filemon b13a5ae1ae Add reactive companion visibility with companionId tracking
- Track companionId in useBlobbiEntryAnimation to detect companion changes
- When companion is selected/changed, trigger new entry animation immediately
- Random entry direction (fall or rise) for new companion appearances
- No transition delay for companion changes (unlike route changes)
- Companion removal hides Blobbi via existing isVisible logic
2026-03-24 12:09:12 -03:00
filemon 8c52848212 Fix intermittent snap-to-ground bug in FALL entry animation
Root cause:
When entry animation completed, there was a race condition:
1. entryState.phase becomes 'complete'
2. Effect calls completeEntry() which sets isEntering = false
3. Component checks 'if (isEntering)' and switches to motion.position
4. But motion.position wasn't synced to groundPosition yet
5. Result: Blobbi snaps to old position instead of falling smoothly

The fix uses a two-phase handoff:

1. Component now checks 'isEntering || entryState.phase !== idle'
   - Keeps using entry animation position during 'complete' phase
   - The 'complete' phase returns groundPosition, so rendering is correct

2. Added acknowledgeCompletion() function
   - Called after position is synced AND rendered (via requestAnimationFrame)
   - Resets phase to 'idle' to allow normal motion to take over
   - Ensures no frame shows wrong position

3. Position sync effect now:
   - Detects phase === 'complete' (not just !isEntering)
   - Syncs position to groundPosition
   - Waits one frame with requestAnimationFrame
   - Then calls acknowledgeCompletion() to handoff to motion

Files changed:
- useBlobbiEntryAnimation.ts: Add acknowledgeCompletion()
- useBlobbiCompanion.ts: Use acknowledgeCompletion after sync
- BlobbiCompanion.tsx: Check phase !== 'idle' for entry position
2026-03-24 12:05:28 -03:00
filemon 053007e7ea Fix route change: hide Blobbi immediately, delay only new entry
Before: Blobbi stayed visible during 1s delay, then disappeared and restarted
After: Blobbi disappears immediately, 1s clean delay, then new entry starts

Changes:
- Add isHiddenForTransition state to entry animation hook
- Set true immediately on route change (before delay)
- Clear when new entry actually starts
- useBlobbiCompanion now uses shouldBeVisible = isVisible && !isHiddenForTransition

Flow:
1. Route changes
2. Blobbi hidden immediately (isHiddenForTransition = true)
3. Wait 1 second (screen is clean)
4. Start new entry (isHiddenForTransition = false, entry begins)
2026-03-24 11:57:08 -03:00
filemon 9889cb07d4 Simplify FALL entry to 2 vertical pulls, add rare permanent stuck behavior
FALL entry changes:
- Simplified to 2 pull attempts (was wiggle-based)
- Each pull: quick down, slower up (purely vertical, no diagonal)
- Normal flow (~80%): stuck -> pull_1 -> pause_1 -> pull_2 -> pause_2 -> fall -> land
- Rare flow (~20%): stuck -> pull_1 -> pause_1 -> pull_2 -> stuck_permanent

Permanent stuck behavior:
- 20% chance on FALL entry (configurable via trulyStuckChance)
- Blobbi hangs at top, won't fall automatically
- User must drag and release to rescue
- Resolves when drag ends (after any movement away from stuck position)

Route change handling:
- Cancels current entry immediately (no continuation)
- Waits 1 second after new page appears
- Then restarts entry animation for the new route

Timing (total ~1470ms for normal fall):
- stuck: 200ms (15% visible)
- pull_1: 280ms (10% drop)
- pause_1: 140ms
- pull_2: 300ms (14% drop)
- pause_2: 100ms
- fall: 450ms
- land: 200ms

Files changed:
- companion.types.ts: New phases (pulling_1/2, pause_1/2, stuck_permanent), isTrulyStuck flag
- companionConfig.ts: New timing config for 2-pull system
- animation.ts: Simplified vertical pull calculations
- useBlobbiEntryAnimation.ts: New state machine with 20% stuck chance
- useBlobbiCompanion.ts: Pass isDragging to entry hook
- BlobbiCompanion.tsx: Updated config mapping
2026-03-24 11:46:52 -03:00
filemon 142b144318 Add pause phase to FALL entry, refine butt wiggle motion
- Add 'pause' phase (180ms) between tugging and wiggling
  Creates readable 'hmm... still stuck' beat before wiggle

- Refine wiggle to feel like lower/back part:
  - Side-to-side primary motion (not diagonal whole-body)
  - Small downward pull synced with each wiggle (tugging loose)
  - Minimal rotation (2°) - just organic, not full-body tilt
  - ~1.25 cycles instead of 1.5 (brief and playful)

- Adjusted timing:
  - wiggleIntensity: 5 → 4px
  - wiggleRotation: 3 → 2°
  - wiggleDuration: 350 → 320ms

New sequence with clear beats:
  stuck (250ms) → tugging (300ms) → pause (180ms) →
  wiggling (320ms) → falling (500ms) → landing (200ms)

Total: ~1750ms
2026-03-24 11:28:53 -03:00
filemon a47e53ff2d Refine FALL entry: add tugging phase, reduce visible amount, subtler wiggle
- Add 'tugging' phase: tries to fall but gets stuck (down-up motion)
- Reduce stuckVisibleAmount from 25% to 15% (tiny butt peek)
- Make wiggle subtler: 5px intensity, 3° rotation (was 8px, 6°)
- Wiggle now diagonal movement, not just horizontal shaking
- New sequence: stuck -> tugging -> wiggling -> falling -> landing

Timing: 250ms stuck, 300ms tug, 350ms wiggle, 500ms fall, 200ms land
2026-03-24 11:23:41 -03:00
Sergey B. 25bfe4f0fa fix: query case variants for tag feed parity with search 2026-03-24 17:13:33 +03:00
filemon 8b88cd3cf6 Add stuck and wiggling phases to Blobbi fall entry animation
- Add 'stuck' phase where Blobbi's butt hangs from top edge (25% visible)
- Add 'wiggling' phase with left-right wiggle (±8px, ±6° rotation) to get loose
- Update entry state machine to handle new phases: stuck -> wiggling -> falling -> landing
- Add config values for stuckDuration (300ms) and wiggleDuration (400ms)
- Update animation utils with wiggle offset calculations
2026-03-24 11:12:33 -03:00
filemon 748bf43847 Replace sidebar entry with vertical entry based on navigation direction
- Add vertical entry system: fall from top when navigating DOWN sidebar,
  rise from bottom with inspection when navigating UP sidebar
- Add sidebarNavigation.ts utility to map routes to sidebar order
- Remove old sidebar-based entry (clipping, peeking from left)
- Fix motion sync to use groundPosition (center) instead of old restingPosition
- Entry now ends at center of screen, no teleport on completion
2026-03-24 10:58:12 -03:00
filemon 586c536c46 up animation 2026-03-24 10:16:03 -03:00
filemon 4a84a782db Add tab attention, post-route attention, and improve upward eye movement
Tab switch attention:
- Added TAB_SELECTORS for Radix UI tabs with data-state='active'
- Tab changes trigger brief glance (1.2s) instead of full attention (3s)
- Uses shorter glanceCooldown (0.8s) to allow noticing multiple tabs
- Low priority so overlays can still interrupt
- Skipped tooltips entirely (too noisy)

Post-route attention:
- After entry animation completes, Blobbi looks at main content
- findMainContentPosition() tries common selectors: main, [role=main], .main-content, article
- Falls back to center-top of viewport if no main content found
- Uses postRouteDuration (2.5s) for how long to look
- Small postRouteDelay (200ms) before triggering
- Low priority so modals can immediately interrupt

Improved upward pupil movement:
- Asymmetric vertical scaling in BlobbiBabyVisual and BlobbiAdultVisual
- Looking up: full range (4px/4.5px for baby/adult)
- Looking down: reduced range (2.4px/2.7px, 0.6x) to avoid droopy look
- Updated calculateEyeOffset() with asymmetric maxDistanceY:
  - 350px for looking up (easier to reach full upward gaze)
  - 500px for looking down (normal distance)
- Updated generateRandomScreenGaze() to favor upward looks (-0.6 to +0.5)

New config options:
- attention.glanceDuration: 1200ms for brief tab glances
- attention.glanceCooldown: 800ms between glances
- attention.postRouteDuration: 2500ms for post-route attention
- attention.postRouteDelay: 200ms delay before post-route attention

Files changed:
- companion.types.ts: New config options
- companionConfig.ts: New timing values
- companionMachine.ts: Asymmetric calculateEyeOffset
- useBlobbiAttention.ts: Tab detection, overlay/tab separation
- useBlobbiCompanion.ts: Post-route attention trigger
- useBlobbiCompanionGaze.ts: Improved random gaze range
- BlobbiBabyVisual.tsx: Asymmetric vertical eye movement
- BlobbiAdultVisual.tsx: Asymmetric vertical eye movement
2026-03-24 07:59:40 -03:00
filemon e2dbc0e1cf Improve Blobbi companion behavior: calmer, more observant, reactive to UI
Behavioral rebalancing:
- Reduced walk chance from 75% to 30% for much calmer behavior
- Increased idle time from 2-6s to 4-10s
- Increased random gaze interval from 0.8-2.5s to 1.5-4s for more deliberate observation
- Mouse follow now more frequent (35% vs 25%), longer duration (2.5s vs 1.5s)
- Observation look duration increased from 2-4s to 3-6s

New attention system for UI changes:
- Added useBlobbiAttention hook that watches for new UI elements
- Uses MutationObserver to detect modals, dialogs, sheets, popovers appearing
- Supports Radix UI, Vaul drawer, and common dialog patterns
- New 'attending' state has highest priority, interrupts walking
- Blobbi stops and looks at new UI element for ~3 seconds
- Priority system (low/normal/high) prevents spamming
- Cooldown prevents excessive reactions (1.5s minimum between events)

Architecture for future extensibility:
- AttentionTarget type with id, position, duration, priority, source
- AttentionPriority type for behavior hierarchy
- Exported via module index for external use
- Easy to add video/audio/notification attention types later

Files changed:
- companion.types.ts: Added 'attending' state, 'attend-ui' gaze mode, AttentionTarget type
- companionConfig.ts: Rebalanced all timing values, added attention config
- companionMachine.ts: Reduced walk chance, handle attending in motion
- useBlobbiAttention.ts: New hook for UI attention detection
- useBlobbiCompanionState.ts: Handle attending state, save/restore behavior
- useBlobbiCompanionGaze.ts: Handle attend-ui gaze mode with fast snap
- useBlobbiCompanion.ts: Wire attention system through
- index.ts: Export new hook and types
2026-03-24 07:44:25 -03:00
filemon 2b434de40b Fix Blobbi companion eye behavior system
- Fix competing eye systems: add disableTracking option to useBlobbiEyes
  so external systems can control eye position while keeping blinking
- Add externalEyeOffset prop to BlobbiBabyVisual and BlobbiAdultVisual
  for direct companion control of eye position
- Increase pupil movement visibility (4px for baby, 4.5px for adult)
- Increase movement direction gaze offset (0.85 instead of 0.7)
- Add observation target behavior: Blobbi picks a random screen position,
  walks toward it, then looks at it for 2-4 seconds
- Add new 'observe-target' gaze mode and 'watching' state
- Wire eyeOffset through companion pipeline to visual components
- Clear separation of concerns: useBlobbiEyes handles blinking,
  companion system handles gaze direction
2026-03-24 07:15:19 -03:00
Sergey B. a51c174021 fix: allowing zap Primal users without error 2026-03-24 11:51:02 +03:00
Sergey B. c1373174ca feat: NIP-11 relay information on network settings page 2026-03-24 10:43:25 +03:00
filemon c3f0ecf7d5 Improve companion eye gaze behavior
- When moving: Eyes look in direction Blobbi is going (left/right)
- When idle: Eyes look around randomly, observing the screen
  - Wider gaze range for more noticeable movement
  - Faster gaze changes (0.8-2.5s) feel more alive
- Mouse focus: Brief glances at cursor
  - 25% chance every 2-4 seconds (after 6s cooldown)
  - Only lasts 1.5s, then returns to normal
  - Never gets stuck on mouse
- Smooth transitions between all gaze targets
  - Mouse follow: responsive (0.15 factor)
  - Forward: moderate (0.1 factor)
  - Random: gentle (0.06 factor)
- Cleaner state management using refs to avoid unnecessary re-renders
2026-03-23 22:52:29 -03:00
filemon 2f2fdb1809 Improve companion: restore organic movement, fix eye gaze, fix adult form rendering
- Restore charming float/sway animation with layered sine waves
  - Walking: lively bouncy motion with playful tilt
  - Idle: dreamy calm floating like gentle breathing
- Fix eye gaze behavior:
  - Eyes now move randomly when idle (not stuck)
  - Occasional brief mouse following that properly times out
  - Look in movement direction when walking
- Fix adult form rendering:
  - Pass adultType and seed to visual component
  - Adults now render their actual form instead of always catti
- Keep ground contact fixes:
  - SVG alignment via translateY compensation
  - SVG fills container with width/height 100%
- Add debug mode infrastructure (disabled by default)
2026-03-23 22:48:06 -03:00
filemon 95a123532b Fix Blobbi ground contact - compensate for SVG padding in ground calculation
Root cause: The Blobbi SVG has ~12% empty space at the bottom of its
viewBox (body ends at Y=88 in a 0-100 viewBox). This caused the visual
body to appear elevated above the ground.

Fix: Added SVG padding compensation in the ground position calculation
rather than trying to hack it in the visual component.

Changes:
- calculateGroundY(): Add svgBottomPadding (12% of size) to push
  container down so Blobbi's actual body touches ground
- calculateMovementBounds(): Same adjustment for maxY bound
- Removed visual margin hacks (marginBottom, items-end) that were
  trying to compensate in the wrong place

The ground Y calculation now accounts for the SVG's internal padding,
so the container is positioned lower and Blobbi's body correctly
touches the ground level.
2026-03-23 22:26:57 -03:00
filemon 2cc7c7bcaf Fix Blobbi ground contact by compensating for SVG padding
The SVG viewBox has empty space at the bottom (~12% padding).
This was causing Blobbi to appear floating above the ground.

Fix:
- Changed inner container to use 'items-end' for bottom alignment
- Added negative marginBottom (-10% of size) to pull Blobbi down
- This compensates for the SVG's internal bottom padding

Shadow restored:
- Back to bottom: -4 (was 0)
- Size: 60% width, 12% height
- Blur: 2px
- Better visual separation from Blobbi

Result: Blobbi now visually sits on the ground with proper
shadow placement underneath.
2026-03-23 22:20:16 -03:00
filemon b314b98dd6 Fix Blobbi ground contact - now properly touches ground
Float animation fix:
- Changed from abs(sin) to (1-cos)/2 wave formula
- This creates a 0-to-1 range that regularly returns to zero (ground)
- Y offset now goes from 0 (ground contact) to -3/-4.5 (slight lift)
- Blobbi settles back to ground between float cycles

Walking animation:
- Faster cycle (~0.5s) for rhythmic bobbing
- Range: 0 to -4.5px lift
- Reduced sway (1.5px) and rotation (1.5°)

Idle animation:
- Slower cycle (~2.5s) for calm breathing
- Range: 0 to -3px lift
- Subtle sway (0.8px) and rotation (0.8°)

Shadow adjustments:
- Moved to bottom: 0 (right at ground level)
- Smaller size (55% width, 10% height) for subtler effect
- Less blur (1px) for sharper ground contact
- Stronger base opacity (0.4) that fades as Blobbi lifts
- Scale shrinks more noticeably when lifted
2026-03-23 22:13:40 -03:00
filemon 5ec79e9612 Fix Blobbi ground alignment and shadow anchoring
- Float animation now only moves Blobbi upward from ground level
  - Uses abs(sin) so Y offset is always negative (up) or zero
  - Base position = on the ground, animation lifts slightly above
- Shadow stays anchored to ground while Blobbi floats above it
  - Shadow doesn't move with float offset
  - Shadow scales/fades based on float height for depth illusion
- Reduced horizontal sway and rotation for subtler effect
  - Walking: 2px sway, 2° rotation (was 3px, 3°)
  - Idle: 1px sway, 1° rotation (was 1.5px, 1.5°)
2026-03-23 22:10:00 -03:00
filemon 9e89972008 Improve companion movement and visual polish
- Simplify entry animation to smooth walking emergence (no stuck/squeeze)
- Add forced initial walk after entry - Blobbi walks right immediately
- Improve walking behavior - 75% walk chance, shorter idle periods
- Remove visual flip when changing direction - Blobbi always faces same way
- Add soft floating/swaying animation with different speeds for walk vs idle
  - Walking: faster rhythmic bobbing (~0.8s cycle)
  - Idle: slower calm breathing (~3s cycle)
- Add soft shadow underneath for depth/floating effect
  - Stronger opacity (0.35), blur, and gradient for better visibility
  - Shadow reacts to float height
- Keep clipping behavior for sidebar emergence on desktop
- Mobile uses simple slide-in from left edge
2026-03-23 22:06:45 -03:00
filemon 05864d001a Improve companion entry animation - emerge from content area with playful squeeze effect
Entry position changes:
- Add layout config with sidebarWidth (300px) and maxContentWidth (1200px)
- Calculate main content left edge accounting for centered layout
- Entry now starts at left edge of main content area, not viewport edge
- Resting position is inside the content area with proper padding

Playful entry animation (2.2 seconds total):
- Phase 1 (0-25%): Emerge diagonally with slight forward lean and squish
- Phase 2 (25-40%): Get 'stuck' halfway with wobble effect
- Phase 3 (40-70%): Tug motions - 3 cycles of forward/back pulls, each stronger
- Phase 4 (70-100%): Break free and walk smoothly to final position

Visual effects during entry:
- Rotation (lean forward/back during tugging)
- ScaleX/ScaleY (squish/stretch for squeeze effect)
- Transform origin at center bottom for natural pivoting

The animation feels like Blobbi is squeezing out from the previous page
into the current one, getting briefly stuck, then breaking free.
2026-03-23 20:56:10 -03:00
filemon f9fc81ce71 Fix companion entry animation - no teleport, spawn from behind sidebar
- Change entry position to start behind the sidebar (padding.left/2 - size)
  instead of off-screen left edge, so companion emerges from sidebar
- Add setPosition function to motion hook for syncing position
- Sync motion position to restingPosition when entry animation completes
  to prevent teleport between animated and physics-controlled movement
- Entry is now continuous: emerges from sidebar -> slides to resting position
2026-03-23 20:48:43 -03:00
filemon 66a23cc99b Implement Blobbi companion system with modular architecture
Add a complete companion module under blobbi/companion with:

Core architecture:
- types/companion.types.ts - Type definitions for companion state, motion, gaze
- core/companionConfig.ts - Configuration constants and helpers
- core/companionMachine.ts - State machine for behavior transitions

Hooks:
- useBlobbiCompanionData - Fetches current_companion from kind 31125 profile
- useBlobbiCompanionState - Manages idle/walking/watching behavior
- useBlobbiCompanionMotion - Handles physics, walking, gravity, drag
- useBlobbiCompanionGaze - Eye movement, random observation, mouse following
- useBlobbiCompanion - Main hook combining all systems

Components:
- BlobbiCompanionVisual - Renders baby/adult with external eye control
- BlobbiCompanion - Interactive component with drag support
- BlobbiCompanionLayer - Global overlay layer

Utils:
- movement.ts - Position calculations, bounds, interpolation
- animation.ts - Bob, bounce, easing functions

Features:
- Fetches companion from user's Blobbonaut profile on app load
- Entry animation from behind left sidebar on route changes
- Autonomous walking with energy-based speed
- Random gaze observation when idle
- Mouse cursor following at random intervals
- Drag and gravity physics
- Bottom viewport roaming area
2026-03-23 20:19:31 -03:00
filemon aa2d724a13 Merge branch 'main' into feat-blobbi 2026-03-23 20:10:15 -03:00
filemon 67f840c0ec Add stage validation for companion - only baby/adult allowed
- Add canBeCompanion check to prevent eggs from being set as companion
- Show toast error if user tries to set egg as companion
- Disable companion button for eggs with helpful tooltip
- Update tooltip to show 'Hatch first to set as companion' for eggs
2026-03-23 20:06:35 -03:00
filemon f2e545ff09 Fix companion tag handling and add indicator in Blobbies modal
- Remove current_companion tag entirely when unsetting (instead of empty string)
- Filter out existing current_companion tags before adding new one
- Add Footprints icon indicator in BlobbiSelectorCard for current companion
- Include tooltip 'Current companion' on the indicator icon
- Pass currentCompanion prop through BlobbiSelectorPage and modal
2026-03-23 19:45:02 -03:00
filemon 11142bc96a Implement Set as Companion toggle in Blobbi Hero section
- Add isCurrentCompanion and isUpdatingCompanion props to BlobbiDashboardFloatingControls
- Implement handleSetAsCompanion to toggle current_companion tag on profile
- Show green icon color when Blobbi is the current companion
- Add disabled state support to FloatingActionDef for loading states
- Pass publishEvent to BlobbiDashboard for profile updates
2026-03-23 19:38:26 -03:00
filemon e3d01bc6aa Make Missions modal sections collapsible
- Daily Missions section is now collapsible with chevron toggle
- Hatch Tasks section is collapsible when active
- Evolve Tasks section is collapsible when active
- All sections expanded by default for easy access
- Header shows progress count (e.g., 2/4) for task sections
- Header shows coins earned for Daily Missions section
- Smooth chevron rotation animation on expand/collapse
2026-03-23 18:13:43 -03:00
filemon d3fc1c602a Add Users icon to Blobbi Selector modal title 2026-03-23 18:08:00 -03:00
The Daniel 926ad380f3 feat: show link preview cards in quoted posts instead of raw URLs 2026-03-23 17:02:43 -04:00
filemon b55a9bae43 Add sticky header and close button to Blobbi Selector modal
Apply the same pattern used in other Blobbi modals:
- Sticky header with bg-background
- Explicit DialogClose button in header
- Scrollable content area with flex-1 min-h-0 overflow-y-auto
2026-03-23 17:29:19 -03:00
filemon f8c46d7a11 Add sticky header to BlobbiInfoModal and fix close buttons in all Blobbi modals
- Add sticky header pattern to BlobbiInfoModal (main Blobbi view)
- Add explicit DialogClose button inside each sticky header
- Hide default DialogContent close button with [&>button:last-child]:hidden
- Close button now stays visible when scrolling in all modals

Updated modals:
- BlobbiActionsModal
- BlobbiActionInventoryModal
- BlobbiInventoryModal
- BlobbiMissionsModal
- BlobbiShopModal
- BlobbiInfoModal
2026-03-23 17:21:36 -03:00
filemon 75ca14c900 Fix modal padding and add sticky headers for Blobbi modals
- Add pr-12 to DialogHeader to account for close button, fixing right padding
- Make DialogHeader sticky with proper background for all Blobbi modals
- Structure modals as flex column with min-h-0 for proper scrolling
- Apply pattern to: BlobbiActionsModal, BlobbiActionInventoryModal,
  BlobbiInventoryModal, BlobbiMissionsModal, BlobbiShopModal
2026-03-23 17:10:56 -03:00
filemon 163712471c Improve Blobbi UI mobile responsiveness and add compact coin formatting
- Add formatCompactNumber utility for compact coin display (1.2K, 15.4K, 1.2M)
- Fix BlobbiActionInventoryModal (medicine items) layout for mobile
- Fix BlobbiBottomBar to prevent overflow on narrow screens
- Fix BlobbiInventoryModal item cards for mobile
- Fix BlobbiMissionsModal horizontal scroll and layout issues
- Fix DailyMissionsPanel and TasksPanel for mobile
- Fix BlobbiShopModal and related dialogs for mobile
- Apply compact number formatting to all coin displays
2026-03-23 17:07:16 -03:00
filemon 974cdcccc9 Merge branch 'main' into feat-blobbi 2026-03-23 10:58:25 -03:00
Derek Ross bbe53a4c69 Filter Mentions tab to only show pure mentions, not replies
The Mentions tab now excludes kind 1 reply events (those with NIP-10
reply/root e-tags), showing only pure mentions where someone tagged the
user in a new post. Kind 1111 comments continue to appear in both tabs.
2026-03-22 15:54:41 -04:00
Derek Ross 88d9e783b8 Distinguish replies from mentions in notifications
Kind 1 events that are replies (have NIP-10 reply/root e-tags) now show
a reply icon with 'replied to your note' instead of the '@' icon with
'mentioned you'. Only pure mentions (no reply threading) use the mention
label.
2026-03-22 15:50:11 -04:00
Derek Ross 854f9aca23 Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts
- Fix silent notification drops: reactions, reposts, and zaps were being
  discarded when the referenced event couldn't be fetched from relays.
  Now keeps notifications with missing context instead of hiding them.
  Zaps no longer require the author-ownership check since the #p filter
  already confirms the user is the recipient. (fixes tabs appearing
  identical and missing zap notifications)

- Add real-time WebSocket subscriptions for instant notification updates
  instead of relying solely on 60-second polling. Both the full
  notification list and the unread dot indicator now react immediately
  when new events arrive.

- Wire up zap amounts from NIP-85 stats (zap_amount tag on kind 30383)
  through to the NoteCard action bar, replacing the hardcoded 0.

- Seed client-side reply counts into the event-stats cache from the
  loaded comment tree in PostDetailPage, ensuring sub-comment counts
  are visible even when NIP-85 stats are unavailable for kind 1111.

Closes #136
2026-03-22 15:42:45 -04:00
filemon 69dde41d9c fix(blobbi): rebalance daily mission rewards and persist to kind 31125
- Rebalance rewards: interact_6=30, feed_2=20, clean_1=20, sing_1=25,
  play_music_1=25, sleep_1=20, change_shape_1=40, take_photo_1=35
- Total daily reward now 75-105 coins (was 240-330)
- Add useClaimMissionReward hook to persist coins to Blobbonaut profile
- Claim flow now updates kind 31125 event, not just localStorage
- Add idempotency check to prevent double-crediting
- Update query cache and invalidate after successful claim
- Pass profile and updateProfileEvent to BlobbiMissionsModal
2026-03-21 21:55:18 -03:00
filemon e159e5bb6d feat(blobbi): implement daily missions system
- Add mission pool with 8 missions (interact, feed, clean, sing, play_music, sleep, take_photo, change_shape)
- Implement weighted random selection for 3 daily missions per user
- Add localStorage persistence with automatic daily reset
- Track progress from all Blobbi actions (inventory, direct actions, sleep, photo, profile shape)
- Add DailyMissionsPanel UI component with progress bars and claim buttons
- Integrate daily missions section into BlobbiMissionsModal
- Each mission rewards 80-110 coins depending on difficulty
2026-03-21 21:43:42 -03:00
filemon fce3c81029 fix(blobbi): ensure Blobbi eyes are always open in photo exports
Added disableBlink option to prevent Blobbi from being captured mid-blink
when downloading or posting photos.

Changes:
- useBlobbiEyes: Added disableBlink option that keeps blinkScaleY at 1 (fully open)
- BlobbiBabyVisual: Added disableBlink prop, passed to useBlobbiEyes
- BlobbiAdultVisual: Added disableBlink prop, passed to useBlobbiEyes
- BlobbiStageVisual: Added disableBlink prop, passed to child visuals
- BlobbiPolaroidCard: Now uses disableBlink={true} alongside lookMode='forward'

Photo mode behavior:
- lookMode='forward': Eyes look straight ahead (no mouse tracking)
- disableBlink={true}: Eyes stay fully open (no blinking animation)
- animated={false}: No ambient animations

Normal dashboard behavior unchanged - Blobbi still blinks and follows pointer.
2026-03-21 20:56:13 -03:00
filemon b14717eddb fix(blobbi): ensure polaroid export matches modal preview exactly
The date text was wrapping to a second line in the exported PNG but not
in the modal preview. This was caused by html-to-image rendering text
differently when using Tailwind classes.

Fixes:
- Convert caption area to inline styles for consistent html-to-image export
- Add whitespace: nowrap to date, stage badge, and caption elements
- Add explicit width constraint to caption container
- Use inline styles instead of Tailwind classes for all caption text

This ensures the downloaded PNG and Blossom-posted image match the
modal preview exactly, with the date staying on one line.
2026-03-21 20:50:23 -03:00
filemon 5891014ff6 refactor(blobbi): refine polaroid photo implementation
1. Polaroid layout - now looks like a real polaroid:
   - White frame on ALL sides (top: 16px, sides: 16px, bottom: 80px)
   - Photo area is inset within the white frame
   - Caption area positioned at bottom of frame
   - Consistent off-white background (#fafafa) for export

2. Removed noisy debug logs:
   - Removed [BlobbiStageVisual][baby] and [adult] console.logs
   - These were render-time logs that added noise without value

3. Improved export/post robustness:
   - URL extraction now explicitly finds 'url' tag instead of assuming [0][1]
   - Added error handling if upload returns no URL
   - Safer parsing that doesn't depend on tag order

4. Verified visual export consistency:
   - White frame visible on all sides
   - Blobbi centered in photo area
   - No clipping or gaps
   - Caption properly positioned
2026-03-21 20:39:50 -03:00
filemon b51535bfa0 feat(blobbi): implement Blobbi Photo (Polaroid) feature
Add the ability to take polaroid-style photos of Blobbis and share them:

- Add lookMode prop to Blobbi rendering system:
  - 'follow-pointer': Eyes track mouse cursor (default, existing behavior)
  - 'forward': Eyes look straight ahead (for photos/export)
  - Updated useBlobbiEyes, BlobbiBabyVisual, BlobbiAdultVisual, BlobbiStageVisual

- Create BlobbiPolaroidCard component:
  - Classic polaroid-style frame with white background and shadow
  - Soft gradient background for photo area
  - Caption area with Blobbi name, stage, and date
  - Fixed dimensions (320x400) for consistent export
  - Built with HTML+CSS (not canvas) for easy customization

- Create BlobbiPhotoModal component:
  - Opens from 'Take a Photo' button on BlobbiPage
  - Shows polaroid preview with Blobbi looking forward
  - Download button: exports as PNG using html-to-image
  - Post button: uploads to Blossom and creates kind 1 note
  - Clean, minimal UI focused on the photo

- Wire up to BlobbiPage:
  - Photo modal state and handler
  - Connected to floating 'Take a Photo' action button

Dependencies:
- Added html-to-image for DOM-to-PNG conversion
2026-03-21 20:29:03 -03:00
filemon 8476edd18f chore(blobbi): temporarily hide unimplemented floating action buttons
Hide 'Set as Companion' and 'Open PiP' buttons from BlobbiDashboardFloatingControls
until their features are implemented. Code is commented out with TODO markers for
easy re-enablement later.

Remaining visible buttons: Take a Photo, Info, Hatch/Evolve actions
2026-03-21 20:15:04 -03:00
filemon e528cdb36d feat(blobbi): add stage-aware validation and semantic cleanup
- Detect final stage from tags and validate tags against stage constraints
- Remove tags not valid for the detected stage (e.g., adult_type only on adults)
- Fix state after transitions (incubating/evolving -> active)
- Validate state is valid for the stage (per VALID_STATES_BY_STAGE)
- Skip required tag recovery for tags not valid for current stage
- Skip persistent tag recovery for tags not valid for current stage
- Add dev diagnostics with console.warn when repairs are applied
- Return finalStage in TagRepairResult for caller inspection
2026-03-21 20:08:56 -03:00
filemon 8696c698ed feat(blobbi): implement tag integrity guard for all Blobbi events
Add validateAndRepairBlobbiTags function that ensures tag integrity
whenever a Blobbi is republished. The guard:

1. Validates tags against the canonical schema
2. Removes deprecated tags automatically
3. Removes task-related tags during stage transitions (when requested)
4. Recovers missing required tags from:
   - Previous canonical tags (if available)
   - System defaults (for b, t, client only)
5. Preserves all persistent tags from previous state
6. NEVER invents personality/trait/adult_type values

Integration points:
- mergeBlobbiStateTagsForRepublish: validates all tag merges
- useBlobbiHatch: validates with task cleanup before publishing
- useBlobbiEvolve: validates with task cleanup before publishing
- buildMigrationTags: validates migration output

Repair strategy:
- System tags (b, t, client): recover from defaults
- Identity tags (name, seed, d): recover from previous only, never invent
- Personality tags: preserve if exist, never invent
- Visual tags: preserve (regenerable from seed if needed)
- Stats: preserve current values
- Task tags: cleanup during transitions
2026-03-21 20:02:20 -03:00
filemon c236caefad docs(blobbi): add product spec for Blobbi tag schema
Create docs/blobbi/blobbi-tag-schema.md as the canonical source of truth
for Blobbi tag definitions. The runtime schema at blobbi-tag-schema.ts
MUST align with this spec.

Includes:
- All 35 canonical tags organized into 11 categories
- Required vs optional designation
- Persistence rules across stage transitions
- Stage transition rules (hatch, evolve)
- Migration rules
- Validation rules
- 8 deprecated tags with migration guidance
2026-03-21 19:50:03 -03:00
filemon f9de5282c9 fix(blobbi): preserve personality/extension tags in migration flow
Update buildMigrationTags to preserve all persistent tags when migrating
legacy Blobbis to canonical format:

- personality, trait, favorite_food, voice_type, mood
- adult_type
- theme, crossover_app

Per blobbi-tag-schema.md spec: Do NOT invent values for tags that don't
exist. Only preserve values that are already present in the legacy event.

Also adds docs/blobbi/blobbi-tag-schema.md as the product spec for all
Blobbi tag definitions. The runtime schema in blobbi-tag-schema.ts MUST
align with this spec.
2026-03-21 19:49:53 -03:00
filemon a4c2895c68 docs(blobbi): add canonical tag schema for Blobbi events
Create blobbi-tag-schema.ts as the single source of truth for all
Blobbi tag definitions. The schema documents:

- 35 canonical tags organized into 11 categories
- Tag metadata: required, stages, persistent, source, regenerable
- 8 deprecated tags with migration guidance
- Helper functions for schema queries and validation

Categories:
- system: d, b, t, client
- identity: name, seed, generation
- visual: base_color, secondary_color, eye_color, pattern, special_mark, size
- personality: personality, trait, favorite_food, voice_type, mood
- stats: hunger, happiness, health, hygiene, energy
- state: stage, state, last_interaction, last_decay_at
- task: state_started_at, task, task_completed
- progression: experience, care_streak
- social: visible_to_others, breeding_ready
- evolution: adult_type
- extension: theme, crossover_app

Also updates MANAGED_BLOBBI_STATE_TAG_NAMES to include extension
tags (theme, crossover_app) and reorganizes comments by category.
2026-03-21 19:39:34 -03:00
filemon e33ee800bc fix(blobbi): preserve identity attributes across stage transitions
- Add identity/personality tags (personality, trait, favorite_food,
  voice_type, mood, adult_type) to MANAGED_BLOBBI_STATE_TAG_NAMES
- Add 'interact_6_progress' to DEPRECATED_BLOBBI_TAG_NAMES to remove
  legacy interaction tracking
- Update hatch flow to clean only task/state-specific tags while
  preserving all identity attributes from canonical.allTags
- Update evolve flow to clean only task/state-specific tags while
  preserving all identity attributes, and set state to 'active'

This ensures Blobbi identity persists across egg → baby → adult
transitions, treating them as persistent entities rather than
reconstructed objects at each stage.
2026-03-21 19:35:53 -03:00
filemon 24aa80840c Merge branch 'main' into feat-blobbi 2026-03-21 19:24:47 -03:00
The Daniel d9aa6258cd fix: allow nsec paste in login field 2026-03-21 18:07:46 -04:00
The Daniel 941e6ee4e6 feat: block nsec paste in all input fields with warning toast
Adds a global capture-phase paste listener that detects nsec private
keys and prevents them from being pasted into any field. Shows a
destructive toast warning the user that private keys should never
be shared.
2026-03-21 18:03:17 -04:00
filemon ffab9d3aaa feat(blobbi): add dev-only instant stage transition button
- Add 'Dev Hatch' button for eggs (bypasses incubation tasks)
- Add 'Dev Evolve' button for babies (bypasses evolution tasks)
- Button only appears in dev mode (import.meta.env.DEV)
- Uses dashed amber border style to clearly indicate dev-only
- Reuses existing onHatch/onEvolve callbacks
- No button shown for adults (no further transitions)
2026-03-20 18:15:30 -03:00
filemon 4d16e1ab83 Merge branch 'main' into feat-blobbi 2026-03-20 18:13:32 -03:00
filemon 6781685252 update typo in epsy url in blobbi 2026-03-20 18:03:52 -03:00
filemon 88bdf87e95 feat(blobbi): reduce evolve post requirement from 3 to 1
Make evolve process lighter and less repetitive:
- EVOLVE_REQUIRED_POSTS: 3 → 1
- Update task name: 'Create Posts' → 'Share Evolution'
- Update task description: 'Share 3 posts about evolving' → 'Post about your Blobbi evolving'
- Reduce query limit since only 1 post needed

Hatch process unchanged - still requires 3 posts with hatch-specific validation
2026-03-20 17:29:14 -03:00
filemon 6b3d98bd66 refactor(blobbi): unify task process with useActiveTaskProcess hook
- Add useActiveTaskProcess hook to consolidate hatch/evolve task logic
- Rename useSyncHatchTaskCompletions to useSyncTaskCompletions
- Fix badge to include dynamic tasks (was only counting persistent)
- Fix 'Edit Wall' task link to point to /settings/profile
- Reduce duplication in BlobbiPage.tsx by using unified hook
- Export new hook and types from blobbi/actions index
2026-03-20 16:07:38 -03:00
filemon ec7ceb2352 fix(blobbi): fix task tag writing and unify badge logic for both processes
BUG 1 - task/task_completed tags not being written:
- incrementInteractionTaskTags now accepts requiredInteractions param
  (7 for hatch, 21 for evolve) instead of hardcoded value
- useBlobbiDirectAction and useBlobbiUseInventoryItem now increment
  interactions for BOTH incubating AND evolving states
- useSyncHatchTaskCompletions now syncs for both processes

BUG 2 - evolving missions not affecting badge:
- remainingTasksCount now uses active process tasks (hatch or evolve)
- allTasksComplete now checks both incubating and evolving states
- BlobbiBottomBar uses isInTaskProcess instead of isIncubating

Architecture rules preserved:
- Persistent tasks: can be cached in task/task_completed tags
- Dynamic tasks: NEVER stored in tags, UI-only
- No infinite loops, no retroactive increments
- Interactions only increment during real user actions
2026-03-20 16:01:15 -03:00
filemon c62c38b136 feat(blobbi): implement evolve task system with dynamic stat requirements
Add comprehensive evolution task system parallel to incubation:

Architecture:
- Separate persistent tasks (event-based, cacheable in tags) from
  dynamic tasks (stat-based, never cached, recomputed every render)
- Both hatch and evolve require all persistent AND dynamic tasks complete

New hooks:
- useEvolveTasks: 6 persistent tasks + 1 dynamic stat task (all stats >= 80)
- useStartEvolution/useStopEvolution: manage evolution state transitions

New components:
- TasksPanel: generalized task display for both hatch and evolve
- StartEvolutionDialog: confirmation dialog for starting evolution
- Updated BlobbiMissionsModal to handle both hatch and evolve flows

Evolve tasks (baby → adult):
- Create 3 themes, 3 color moments, 3 evolve posts
- 21 interactions, use Blobbi shape, edit wall once
- Dynamic: maintain all 5 stats >= 80

Key fixes:
- Renamed isEvolving variable collision in BlobbiPage
- Filter dynamic tasks from sync to prevent tag pollution
- Updated BlobbiPostModal to support evolve posts dynamically
2026-03-20 15:31:02 -03:00
DanConwayDev 33bf59f353 fix: remove unexported NLoginBunker import and prefer-const lint error 2026-03-20 17:22:38 +00:00
DanConwayDev b4d3c4833c feat: remote signer UX improvements for Amber/NIP-46 users on Android
Amber users on Android who manually approve events must switch from Ditto to
Amber to approve, then switch back. Backgrounding Ditto can freeze its
WebSocket, causing the NIP-46 response to be silently dropped — leaving the
operation hanging with no feedback and no way out. Users with Amber
notifications working correctly are unaffected, as approving via notification
does not background Ditto.

Even with auto-approve enabled, kinds outside Amber's default whitelist
require manual approval. Ditto uses several of these regularly: kind 1059
(NIP-17 gift-wrap DMs), 1111 (comments), 1311 (live chat), 31925 (RSVPs),
24242 (Blossom file upload auth), and 30078 (app settings).

This introduces signerWithNudge, a NostrSigner wrapper with the following
behaviour:

Nudge toast after 4 seconds
If a signing or encryption op is still pending after 4 s, a persistent toast
appears naming what is being approved (e.g. 'Approve file upload auth'), with
a human-readable label derived from the event kind.

Android 'Approve in signer' button
On Android the nudge toast includes an 'Approve in signer' button that opens
Amber via the nostrsigner: URI scheme, keeping the WebSocket alive. After
tapping, the button becomes a spinner and a Cancel button appears.

Automatic retry on foreground resume
When Ditto returns to the foreground after being backgrounded, it
automatically retries the pending NIP-46 request (up to 2 times) and shows a
brief 'Checking for signer response' toast.

Hard 45-second timeout
Operations with no response within 45 s are rejected with a clear error.

Cancel / Skip
The nudge toast has a Skip link throughout. After tapping 'Approve in signer'
it becomes a full Cancel button.

Multi-phase encrypt-then-sign
Saving app settings (kind 30078) and mute lists (kind 10000) require a nip44
encrypt followed immediately by a signEvent. When the encrypt nudge was shown,
a phase-transition toast tells the user a second approval is coming. The check
is kind-specific to avoid false positives.

Success feedback
A brief 'Approved' toast confirms the outcome when the nudge was shown.

Relay connectivity check
At nudge time, if the bunker relay WebSocket is not OPEN the toast warns
'Signer relay unreachable' instead of prompting for an approval that cannot
be delivered.

Accessibility
Toast buttons meet the 44 px touch target minimum. Text size and contrast
were increased for readability on small screens.
2026-03-20 17:22:37 +00:00
filemon 44b54f6c32 fix(blobbi): fix remainingTasksCount memo and allTasksComplete condition
- remainingTasksCount now depends on hatchTasks.tasks (not just completedTaskIds)
  to correctly reflect loading state and task changes
- allTasksComplete is now a memoized value that prevents false positives by
  checking: isIncubating && !isLoading && tasks.length > 0 && remaining === 0
- Changed missions badge symbol from '?' to '!' when all tasks complete
2026-03-20 12:02:12 -03:00
filemon 57f7af3141 refactor(blobbi): stabilize task sync, fix hatch content, improve UI indicators
Task Sync Stability:
- Remove hatchTasks.tasks from useEffect dependencies (was causing instability)
- Derive tasksToSync and remainingTasksCount via useMemo keyed off completedTaskIds
- Effect dependencies now only include stable primitives: completedTaskIds,
  cachedCompletedIds, hatchTasks.isLoading, companion?.state

Content Fix on Stage Transition:
- Add generateBlobbiContent() helper with correct grammar ('an egg' vs 'a baby')
- useBlobbiHatch now generates new content: '{name} is a baby Blobbi.'
- useBlobbiEvolve now generates new content: '{name} is an adult Blobbi.'
- Content always reflects current stage (was keeping old egg content)

Missions Icon Badge:
- Show remaining task count when incubating with tasks remaining
- Show '?' badge (success variant) when all tasks complete during incubation
- Badge variants: default (blue), warning (amber), success (emerald)

Blobbies Icon Badge:
- Now shows count of Blobbies needing care (any stat < CARE_THRESHOLD=40)
- Warning variant (amber) when there are needy Blobbies
- Only shows badge when count > 0

Selector Modal Warning:
- Add AlertTriangle indicator in top-right corner for Blobbies needing care
- Uses same companionNeedsCare() logic as bottom bar badge
2026-03-20 11:44:48 -03:00
filemon 23dbd9112a fix(blobbi): prevent infinite loop in task sync, add missions badge
Infinite Loop Fix:
- Add useRef anti-loop memory (lastSyncedKeyRef) to track last synced key
- Mark key as synced BEFORE calling sync to prevent race conditions
- Add guard for companion.state !== 'incubating'
- Remove syncTaskCompletions from useEffect deps (intentional, prevents
  re-triggering when mutation function reference changes)
- Reset ref on error to allow retry

Missions Badge:
- Add remainingTasksCount prop to BlobbiBottomBar
- Show badge on Missions button when incubating with incomplete tasks
- Update BottomBarButton to show badge when count > 0 (was > 1)

The interaction task tags were already being updated correctly by
incrementInteractionTaskTags() in useBlobbiDirectAction and
useBlobbiUseInventoryItem hooks during real user interactions.
2026-03-20 11:19:41 -03:00
filemon 44c8103600 refactor(blobbi): make incubation flow explicit, require Blobbi name in posts
- useStartIncubation now requires explicit mode ('start', 'restart', 'switch')
  instead of auto-detecting behavior. This makes the flow predictable.
- StartIncubationDialog determines mode and passes it to onConfirm callback
- Removed useUpdateTaskProgress hook (architecturally inconsistent - updated
  last_interaction during cache-only sync, violating the rule that only real
  user actions should update timestamps)
- BlobbiPostModal now requires blobbiName and process props for stage-aware
  post generation with Blobbi name as first hashtag
- isValidBlobbiPost() now validates the Blobbi name hashtag is present
- Added sanitizeToHashtag helper to both BlobbiPostModal and useHatchTasks
  for consistent hashtag generation
2026-03-20 10:28:10 -03:00
filemon d2441b345b fix(blobbi): make task sync fully idempotent and safe
Audit and hardening of hatch task cache sync:

1. BlobbiPage useEffect:
   - Use useMemo to create stable string keys for completion comparison
   - Only trigger sync when computed completions differ from cached
   - Skip sync entirely if no diff exists
   - Add dev-only debug logs

2. useSyncHatchTaskCompletions:
   - Remove last_interaction update (this is cache sync, not user action)
   - Add double diff check: first against companion.tasksCompleted, then against canonical.allTags
   - Return detailed result with skip reasons for debugging
   - Add dev-only debug logs for all sync decisions

3. incrementInteractionTaskTags:
   - Add check for already-completed state to prevent duplicate task_completed tags
   - Return previousCount for debugging
   - Add dev-only debug logs
   - Document that this is NOT idempotent by design (each call = real interaction)

Key guarantees:
- Cache-only sync never mutates last_interaction
- Multiple renders with same data = no publish
- WebSocket updates or refetches = no publish unless real diff
- No duplicate task_completed tags possible
2026-03-20 10:13:19 -03:00
filemon 2c828c8778 feat(blobbi): add missions modal, single incubation enforcement, and task sync
Phase 2 of Blobbi incubation audit:

- Add BlobbiMissionsModal component with HatchTasksPanel integration
- Move hatch tasks UI from main page to Missions modal
- Add useStopIncubation hook with confirmation dialog
- Enforce only one Blobbi incubating at a time (auto-stops previous)
- Enhance StartIncubationDialog to show switch warning for other incubating Blobbi
- Add useSyncHatchTaskCompletions hook to sync task completions to kind 31124 tags
- Consolidate STAT_MIN/STAT_MAX to single source in src/lib/blobbi.ts
- Remove unused BlobbiPlaceholderModal component
2026-03-20 09:59:50 -03:00
filemon 8097f0e5fb fix(blobbi): fix selection reset bug, stat safety, and migration visual traits
- Remove auto-save effect that was overwriting user selection during
  WebSocket/query updates. User selection now only persists via explicit
  handleSelectBlobbi() call.
- Add debug logging to trace selection changes in development mode.
- Add STAT_MIN=1 and STAT_MAX=100 constants to blobbi-decay.ts and update
  clamp() to use STAT_MIN instead of 0, preventing soft-lock issues.
- Fix buildMigrationTags() to always derive and include all visual traits
  during migration, ensuring every migrated event has complete visual data.
2026-03-20 09:47:11 -03:00
filemon 14644d0cb3 Clean up deprecated tags in migration and republish flows
- Remove incubation_time and start_incubation from coreStateTags in
  buildMigrationTags - these obsolete fields should not be carried
  forward during migration
- Add DEPRECATED_BLOBBI_TAG_NAMES to the exclusion set in buildMigrationTags
  when filtering unknown tags, preventing deprecated tags from being
  preserved in migrated events
- Add DEPRECATED_BLOBBI_TAG_NAMES filtering to mergeTagsForRepublish so
  deprecated tags are not carried forward when republishing existing events

The deprecated tags (incubation_time, start_incubation, incubation_progress,
egg_status, fees) are now properly excluded from:
1. New events (already handled by buildEggTags)
2. Migrated events (buildMigrationTags)
3. Republished events (mergeTagsForRepublish, mergeBlobbiStateTagsForRepublish)

Legacy parsing still reads these fields for backwards compatibility, but
they are never written to new events.
2026-03-19 13:06:31 -03:00
filemon 4c89a20bbe Fix critical bug: Blobbis disappear after starting incubation
The isValidBlobbiEvent function only accepted 3 states (active, sleeping,
hibernating) but BlobbiState type includes 5 states. When state changed to
'incubating' or 'evolving', events failed validation and were filtered out
by useBlobbisCollection and useBlobbiCompanion hooks.

Added 'incubating' and 'evolving' to the valid states list.
2026-03-19 11:50:45 -03:00
filemon 6a2285ef72 Clean up Blobbi incubation system and enforce consistency
Deprecated tags removed from creation:
- incubation_time: No longer created in buildEggTags or previewToEventTags
- start_incubation: No longer in managed tags
- egg_status: Removed from LEGACY_VISUAL_TAG_NAMES (was duplicated)

These tags are now in DEPRECATED_BLOBBI_TAG_NAMES and stripped on republish.

Visual trait consistency:
- buildEggTags now includes all visual traits (base_color, secondary_color,
  eye_color, pattern, special_mark, size) derived from seed
- VISUAL_TRAIT_TAG_NAMES replaces LEGACY_VISUAL_TAG_NAMES
- Visual traits added to MANAGED_BLOBBI_STATE_TAG_NAMES

Stat safety:
- STAT_MIN = 1 (was 0) to prevent soft-lock
- STAT_MAX = 100
- clampStat now clamps to 1-100 range instead of 0-100
- Recovery is always possible with any healing item

Layout improvements:
- BlobbiPage container: max-w-2xl on mobile, max-w-3xl on desktop
- Reduced side whitespace on mobile (px-2)
- Better breathing room overall

BlobbiCompanion interface:
- incubationTime and startIncubation marked as @deprecated
- DEFAULT_INCUBATION_TIME marked as @deprecated

state_started_at remains the single source of truth for process timing.
2026-03-19 09:55:26 -03:00
filemon 37a791f113 Fix incubation-start flow to properly apply decay and clean obsolete tags
- Apply accumulated decay from last_decay_at to now before state change
- Write decayed stat values into the incubation-start event
- Set last_decay_at = state_started_at = last_interaction for consistency
- Add incubation_progress, egg_status, fees to DEPRECATED_BLOBBI_TAG_NAMES

The incubation-start event now has consistent timestamps:
- created_at: NOW
- state_started_at: NOW
- last_interaction: NOW
- last_decay_at: NOW (was incorrectly preserving old value)
2026-03-19 09:39:03 -03:00
filemon 06f820d355 Refine Blobbi incubation/hatch task system
- Fix Color Moment URL: espy.social -> espy.you
- Improve shape-change task detection: only counts true post-start changes
- Remove duplicate Start Incubation button, integrate into evolve/hatch button
- Extract shared incrementInteractionTaskTags helper for code reuse
- Update floating controls to show incubation action for eggs not yet incubating

The evolve/hatch button now serves as the single entry point:
- Egg (not incubating): Opens incubation dialog
- Egg (incubating): Button hidden, hatch action in HatchTasksPanel
- Baby: Evolves to adult
2026-03-19 09:04:08 -03:00
filemon 3a2571ccc6 Merge branch 'main' into feat-blobbi 2026-03-19 08:08:37 -03:00
filemon dd614e6a6a Merge branch 'main' into feat-blobbi 2026-03-18 18:00:55 -03:00
filemon f5595b3477 feat(blobbi): implement hatch task system for egg incubation
Add task-based progression system for hatching Blobbi eggs:

- Extend BlobbiState with 'incubating' and 'evolving' states
- Add task tracking fields to BlobbiCompanion interface
- Create useStartIncubation hook to begin incubation process
- Create useHatchTasks hook to compute task progress from Nostr events
- Add HatchTasksPanel UI component showing 5 hatch tasks:
  - Create Theme (kind 36767)
  - Color Moment (kind 3367)
  - Change Avatar Shape (kind 0 with shape change)
  - Create Post with required prefix/hashtags
  - 7 Blobbi interactions
- Add BlobbiPostModal with enforced content requirements
- Add StartIncubationDialog confirmation dialog
- Auto-increment interaction counter in action hooks
- Gate hatch button visibility based on task completion
2026-03-18 16:44:38 -03:00
filemon e9c34df51e fix(blobbi): show aggregated stat preview in inventory use dialog
The inventory modal's use confirmation dialog was showing simple
multiplication (effect * quantity) instead of the actual clamped
values that would be applied.

Now it simulates the sequential application of effects, clamping
at each step, to show the true total effect that will be applied
when confirming. This matches the behavior of the action modal's
confirmation dialog.
2026-03-18 11:34:25 -03:00
filemon 82b9964a61 feat(blobbi): improve item usage UX with quantity selector and inventory actions
- Add quantity selector to item usage modal (feed/play/clean/medicine)
  - Users can now use multiple items at once
  - Effects are applied sequentially with proper clamping at each step
  - Shows estimated total effect preview

- Add 'Use' button to inventory modal
  - Items can now be used directly from inventory
  - Reuses same logic as normal item usage flow
  - Opens confirmation dialog with quantity selector

- Add stage-based item blocking in inventory
  - Eggs cannot use food or toys (shown disabled with reason)
  - Shell Repair Kit only usable by eggs (blocked for baby/adult)
  - Blocked items remain visible but cannot be used

- Shell Repair Kit visibility in medicine modal
  - Only appears for eggs, hidden for other stages

- Make shop modal narrower (max-w-4xl to max-w-2xl)

- Add centralized item usability logic (canUseItemForStage)
  - Single source of truth for item/stage restrictions
  - Exported from actions module for reuse
2026-03-18 11:24:07 -03:00
filemon a0cb5cc307 Merge branch 'main' into feat-blobbi 2026-03-18 10:51:45 -03:00
filemon 0b26ef51a2 feat: add pot to Rosey shape 2026-03-18 10:50:44 -03:00
filemon da3cc5f997 fix: apply shape mask to hover/edit overlay in ProfileCard
The hover overlay was showing as a full rectangle instead of respecting
the Blobbi shape mask. This happened because the overlay was using the
sync getAvatarMaskUrl() which returns empty for Blobbi shapes (they now
render asynchronously as PNG).

Changed ProfileCard to:
- Use getAvatarMaskUrlAsync() for the overlay mask
- Load mask URL via useEffect with proper cleanup
- Apply the same mask to the hover overlay as the avatar itself

Now the hover/edit darkening effect respects the actual avatar shape,
only appearing inside the visible silhouette.
2026-03-18 10:44:28 -03:00
filemon 96356eb804 fix: render Blobbi masks to PNG via canvas for reliable CSS mask-image
CSS mask-image with SVG data URLs doesn't work reliably for complex SVGs
with transforms in some browsers. The picker preview works because it renders
inline SVG directly, but the avatar mask-image was failing for shapes like
droppi, flammi, leafy, mushie, owli, rocky, and rosey.

Changes:
- Rewrite getBlobbiMaskUrl() to render SVG to canvas and export PNG
- Use Blob URL for SVG loading (more reliable than data URL)
- Add async mask generation with proper caching and deduplication
- Update Avatar component to load masks asynchronously via useEffect
- Add getAvatarMaskUrlAsync() for async mask URL retrieval

The picker preview continues to use inline SVG (which works fine),
while the avatar mask now uses rasterized PNG (which works everywhere).
2026-03-18 10:36:27 -03:00
filemon 80067a212c fix: inject fill/stroke attributes directly into SVG elements for mask rendering
The previous approach using <style> tags with CSS selectors inside SVG data URLs
didn't work reliably when the SVG was used as a CSS mask-image. Some browsers
don't process CSS inside SVG data URLs correctly.

This fix:
- Adds injectWhiteFillStroke() function to directly inject fill="white" and
  stroke="white" attributes into each SVG shape element
- Properly handles elements with transform attributes (the root cause of
  droppi, flammi, leafy, mushie, owli, rocky, rosey failing)
- Preserves fill="none" for stroke-only elements (like catti's tail)
- Works reliably across all browsers since it uses SVG attributes, not CSS
2026-03-18 10:28:08 -03:00
filemon d962d7952b fix: remove hardcoded white stroke from catti tail and fix avatar mask updates
1. Remove stroke="white" from catti shape - let the styling apply colors
2. Fix Avatar component to properly update mask when shape changes:
   - Compute maskUrl outside useMemo so it's always fresh
   - Use maskUrl directly in dependencies instead of shape string
   - This ensures the mask updates immediately when selecting a new shape
2026-03-18 10:21:39 -03:00
filemon ae4847ce50 fix: use SVG data URL directly for Blobbi mask instead of canvas rendering
The previous implementation tried to render SVG to canvas synchronously,
but Image loading is asynchronous, causing getBlobbiMaskUrl() to return
empty string and avatars to fall back to squares.

CSS mask-image supports SVG data URLs directly, so we now skip the
canvas conversion entirely. This is simpler, more reliable, and works
synchronously.

- Remove renderSvgToMaskUrl() and drawImageToCanvas() functions
- Simplify getBlobbiMaskUrlAsync() to just wrap sync version
2026-03-18 10:15:45 -03:00
filemon a07a2de786 fix: scope Blobbi shape colors and increase preview size
- Use fill/stroke attributes on <g> instead of global CSS to prevent color leakage
- Reduce grid columns from 5 to 4 for larger shape previews
- Reduce inner padding from inset-1 to inset-0.5 for bigger shapes
2026-03-18 10:09:08 -03:00
filemon 96ce34c7f1 refactor: use raw SVG markup for Blobbi shapes instead of single path
- Change BlobbiShape type from 'path: string' to 'svg: string'
- Store original SVG body markup preserving circles, ellipses, rects, paths, transforms, and strokes
- Update getBlobbiMaskUrl() to render SVG string via Image element instead of Path2D
- Add getBlobbiMaskUrlAsync() for guaranteed async loading
- Add getBlobbiShapeSvg() helper to get complete SVG markup with custom fill
- Update BlobbiShapePicker to render multi-element SVG with dangerouslySetInnerHTML
- Shapes now visually match original SVG files exactly (e.g., catti tail is stroke-based)
2026-03-18 09:50:13 -03:00
filemon c6fa1acf66 refactor: improve Blobbi shape picker UI and update character silhouettes
- Add tight viewBox computation for better shape visibility in picker
- Change grid layout to 5 columns for larger, more prominent shapes
- Remove shape name labels for cleaner picker interface
- Rename 'Blobbi' tab to 'Blobbids' in avatar picker dialog
- Update all adult Blobbi SVGs with refined designs
- Redesign shape paths with detailed silhouettes including limbs and accessories
- Add pot to Leafy, adjust Pandi arms, remove shadows from Rocky
2026-03-18 09:44:23 -03:00
filemon a7c29c4a85 feat: add Blobbi shapes as avatar masks
New feature allowing users to select Blobbi character silhouettes as
avatar masks, in addition to existing emoji shapes.

New files:
- src/lib/blobbiShapes.ts: Shape definitions with SVG paths for all
  Blobbi forms (egg, baby, 16 adults)
- src/components/BlobbiShapePicker.tsx: Grid picker with category tabs

Changes:
- Extended avatarShape.ts to support 'blobbi:' prefixed shape values
- Added getAvatarMaskUrl() unified function for both shape types
- Updated Avatar component to render Blobbi masks
- Added tabbed UI in ProfileCard (Emoji/Blobbi tabs)
- Renamed emojiAvatarBorderStyle to shapedAvatarBorderStyle

Shape format: 'blobbi:<id>' (e.g., 'blobbi:baby', 'blobbi:catti')
Stored in kind-0 metadata as 'shape' property, same as emojis.

Available shapes:
- Egg (simple egg silhouette)
- Baby Blobbi (water droplet)
- Adults: catti, owli, froggi, droppi, flammi, crysti, cloudi,
  mushie, starri, pandi, cacti, breezy, leafy, rocky, rosey, bloomi
2026-03-17 12:32:46 -03:00
filemon 91364385c3 fix: use data attributes for blink center instead of CSS parsing
Changes:
- Store blink center as data-cx/data-cy attributes on .blobbi-blink groups
- Use eye white center (cx, cy) as blink anchor when available
- Fallback to pupil center if no eye white found
- Read center from data attributes in animation loop (more reliable than CSS)

Structure per eye:
<g class="blobbi-blink" data-cx="38" data-cy="45">  <!-- blink group -->
  <ellipse ... />                                        <!-- eye white -->
  <g class="blobbi-eye">                               <!-- tracking group -->
    <circle ... />                                       <!-- pupil -->
    <circle ... />                                       <!-- highlight -->
  </g>
</g>

Blink transform: translate(cx,cy) scale(1,blinkY) translate(-cx,-cy)
- Scales around the actual eye center from SVG element data
- No CSS transform-origin parsing needed
- Eye closes in place without shifting
2026-03-17 12:21:02 -03:00
filemon d958722e63 fix: scale blink around eye center to prevent downward shift
Problem: scale(1 blinkY) was scaling from top-left origin, causing
the eye to move down during blink.

Solution: Use translate-scale-translate pattern to scale around the
eye's center point:
  translate(cx cy) scale(1 blinkY) translate(-cx -cy)

The center coordinates are extracted from the transform-origin style
that was already set during SVG processing.

Tracking behavior remains completely unchanged - only the blink
transform application was modified.
2026-03-17 12:11:10 -03:00
filemon d3a19ebfaa fix: separate tracking and blink into distinct transform groups
Problem: Blink was only affecting pupil, not whole eye.
Previous attempt: Wrapping entire eye in one group caused eye white to
move with mouse tracking.

Solution: Two separate nested groups per eye:
- .blobbi-blink (outer): wraps entire eye for scaleY blink animation
- .blobbi-eye (inner): wraps only pupil+highlight for translate tracking

Structure:
<g class="blobbi-blink">     <!-- blink: scale(1 blinkY) -->
  <ellipse ... />             <!-- eye white - stays fixed -->
  <g class="blobbi-eye">    <!-- tracking: translate(x y) -->
    <circle ... />            <!-- pupil - moves with mouse -->
    <circle ... />            <!-- highlight - moves with mouse -->
  </g>
</g>

Result:
- Mouse tracking: only pupil+highlight translate (unchanged behavior)
- Blinking: entire eye scales vertically (natural cartoon blink)
- Eye white: never moves, only scales during blink
2026-03-17 12:01:08 -03:00
filemon c17883bdb8 feat: add natural blinking system for Blobbi eyes
- Random blink intervals between 2-5 seconds for organic feel
- Blink animation: fast close (~80ms), pause (~100ms), slower open (~120ms)
- 20% chance for double blinks (extra polish)
- Uses scaleY transform combined with mouse tracking translate
- Easing functions: ease-in for close, ease-out for open
- Disabled when Blobbi is sleeping
- No CSS transitions - all animation via RAF for instant response
2026-03-17 11:42:40 -03:00
filemon 0b90b0206b fix: eliminate eye tracking lag with direct SVG transforms
- Remove CSS transitions from .blobbi-eye class (root cause of delay)
- Use SVG transform attribute instead of style.transform for reliable repaints
- Cache eye element references after mount with automatic refresh on SVG changes
- Hook now manages DOM directly without onUpdate callback
- Simplified visual components to just pass containerRef
2026-03-17 11:34:58 -03:00
filemon 8f958ef6c7 refactor: simplify eye system to always track mouse globally
Removed from previous system:
- Idle random movement logic
- Energy-based behavior (timing, smoothing, micro-movements)
- Tracking radius (200px distance check)
- Idle/tracking state switching
- lerp interpolation for tracking
- isTracking state and callback parameter
- Per-instance mouse listeners

New behavior:
- Eyes ALWAYS follow the mouse cursor
- Works across the entire screen (no distance limit)
- Instant response (no interpolation, no lag)
- Simple angle calculation every frame

How the new tracking loop works:
1. Global mouse listener updates globalMouseX/globalMouseY
2. RAF loop runs every frame
3. Calculate angle: atan2(mouseY - centerY, mouseX - centerX)
4. Calculate position: cos(angle) * max, sin(angle) * max * 0.7
5. Call onUpdate callback with position
6. DOM updated directly (no React state)

Performance optimizations:
- Single global mouse listener shared by all Blobbi instances
- Instance count tracking for cleanup
- No React state in animation loop
- Direct DOM manipulation via callback
- Minimal computation per frame (just trig)

Hook reduced from ~390 lines to ~140 lines.
2026-03-17 10:35:44 -03:00
filemon 2118fa483b fix: remove React state from animation loop for real-time eye tracking
Problems fixed:

1. React state caused tracking delay
   - setState() batches updates and triggers re-renders
   - Even with refs, calling setState inside RAF caused 1-2 frame lag
   - Eyes only followed mouse properly when it stopped moving

2. Tracking intensity was inverted
   - Old: farther mouse = stronger movement (wrong)
   - New: closer mouse = stronger movement (correct)
   - Formula: intensity = 1 - Math.pow(normalizedDistance, 0.5)

3. Idle froze after mouse left
   - Old: scheduled idle change 300-800ms later
   - New: immediately triggers new idle target when tracking stops
   - Formula: nextIdleChangeRef.current = currentTime (force immediate)

Solution - callback-based architecture:

- REMOVED all setState calls from animation loop
- Added onUpdate(left, right, isTracking) callback option
- Callback is called every RAF frame with current positions
- Components apply transforms directly to DOM via querySelectorAll
- Zero React re-renders during animation = zero lag

Data flow now:
  RAF loop → compute position → onUpdate callback → direct DOM update

Before:
  RAF loop → setState → React re-render → useEffect → DOM update

The onUpdate callback receives positions every frame and applies
transforms immediately, bypassing React's batching entirely.
2026-03-17 10:29:25 -03:00
filemon 2bb9c7738a feat: instant mouse tracking and energy-based idle behavior for Blobbi eyes
1. Mouse tracking now INSTANT (no lag):
   - When tracking, eyes lock directly onto cursor position
   - No interpolation/lerp during tracking mode
   - Feels like 'locked on target' instead of floating/chasing

2. Energy-based idle behavior:
   - High energy (100): frequent movement, shorter pauses, quicker smoothing
   - Low energy (0): lazy movement, longer pauses, slower drift
   - Energy affects: idle duration, smoothing speed, micro-movement chance

3. Micro-movements for aliveness:
   - Small movements (0.2-0.5px) happen randomly
   - High energy = 50% chance, Low energy = 10% chance
   - Makes Blobbi feel alert and curious

4. Pause behavior scaled by energy:
   - Low energy: 50% chance to rest at center
   - High energy: 10% chance to rest at center

Values chosen:
- SMOOTHING_MIN = 0.02 (low energy - dreamy drift)
- SMOOTHING_MAX = 0.06 (high energy - alert movement)
- IDLE_DURATION_MIN = 1000ms (high energy)
- IDLE_DURATION_MAX = 6000ms (low energy)
- MICRO_MOVEMENT_MAX = 0.5px (subtle but visible)

Behavior summary:
- Mouse near -> eyes LOCK instantly on cursor
- High energy -> curious, active, moving often
- Low energy -> slower, lazy, but still alive
- Sleeping -> no movement
2026-03-17 10:17:37 -03:00
filemon 7ed55b00a6 fix: smooth eye animation with proper interpolation and mouse tracking
Root causes of previous issues:

1. Jumping behavior: The old implementation used setState() directly to
   random positions instead of interpolating toward them. Each idle
   movement instantly teleported the eyes.

2. Mouse tracking failure: The updateMouseTracking callback had isTracking
   in its dependencies, causing it to be recreated on every state change.
   This restarted the animation frame loop constantly, breaking the
   continuous tracking.

3. State conflict: Idle timeouts and tracking animation frames ran
   independently and fought each other, causing erratic behavior.

Solution - Single animation loop architecture:

- ONE requestAnimationFrame loop handles ALL animation
- Maintains separate 'current' and 'target' positions
- Always interpolates: current = lerp(current, target, smoothing)
- Idle behavior only sets new targets (doesn't move directly)
- Mouse tracking overrides targets when cursor is nearby
- Clean state machine: tracking active = idle paused

Smoothing values used:
- IDLE_SMOOTHING = 0.03 (very smooth drift)
- TRACKING_SMOOTHING = 0.08 (responsive but not snappy)
- RETURN_SMOOTHING = 0.04 (gentle return to idle)

Timing improvements:
- Idle duration: 3-6 seconds between movements
- 40% chance to pause at center (natural resting)
- Time-scaled smoothing for consistent feel across frame rates

Movement constraints:
- Baby: 2px max, Adult: 2.5px max
- Vertical movement reduced to 70% of horizontal
- State updates throttled (only when position changes > 0.001px)
2026-03-17 10:08:58 -03:00
filemon 4feb051177 fix: rewrite eye animation system for proper SVG transforms and mouse tracking
Root cause: The original implementation had two critical issues:
1. Grouping algorithm assumed highlights immediately followed pupils in SVG,
   but SVGs have all pupils first, then all highlights (proximity-based fix)
2. CSS transforms weren't working on SVG <g> elements without transform-box

Fixes:
- Rewrite pupil/highlight detection to use proximity-based grouping (15px radius)
- Add transform-box: fill-box and transform-origin: center inline styles
- Replace CSS keyframe animation with JavaScript-controlled transforms

New features:
- Natural idle behavior with random movement and pauses
- Mouse tracking when cursor is within 200px radius
- Smooth transitions between idle and tracking states
- Different delays for left/right eyes for organic feel

Implementation:
- useBlobbiEyes hook manages animation state and mouse tracking
- addEyeAnimation wraps pupil+highlight elements in <g class="blobbi-eye">
- Visual components apply transforms via DOM refs in useEffect
- CSS provides transition timing (.3s idle, .1s tracking)
2026-03-17 09:47:39 -03:00
filemon 27bce0d334 feat: add subtle eye movement animation for Blobbi baby and adult visuals
Add eye animation utility that:
- Detects pupil and highlight elements via gradient patterns and dark fills
- Wraps pupil+highlight elements in animated <g> groups
- Applies CSS keyframe animation for gentle wandering eye movement
- Uses different delays for left/right eyes for natural feel
- Only animates when awake (skips sleeping state)

Integrates animation into both BlobbiBabyVisual and BlobbiAdultVisual
components for a more lifelike appearance.
2026-03-16 21:23:24 -03:00
filemon ddf50724f0 fix: adult form resolution now uses adult_type tag as primary source
- Add adultType field to BlobbiCompanion interface
- Parse adult_type tag in parseBlobbiEvent from kind 31124
- Pass adult.evolutionForm in toBlobbiForVisual adapter
- Seed-derived form is now only used as fallback when no adult_type tag exists
2026-03-16 20:07:36 -03:00
filemon d07bd75d07 feat: implement adult Blobbi visual system with 16 evolution forms
- Add adult-blobbi module with types, SVG resolver, and customizer
- Support 16 adult forms: bloomi, breezy, cacti, catti, cloudi, crysti,
  droppi, flammi, froggi, leafy, mushie, owli, pandi, rocky, rosey, starri
- Each form has base and sleeping SVG variants
- Adult form resolved from blobbi.adult.evolutionForm or derived from seed
- Color customization applies to body and pupil gradients via pattern matching
- BlobbiAdultVisual component with reaction animations support
- Replace adult placeholder in BlobbiStageVisual with real visuals
2026-03-16 19:48:43 -03:00
filemon d59ba03cc6 fix: sing reaction only starts when recording begins, adjust animation timing
- Add onRecordingStart/onRecordingStop callbacks to InlineSingCard
- Move singing reaction trigger from card open to actual recording start
- Reduce sing bounce animation movement (6px → 3px for baby, 4px → 2px for egg)
- Slow down sing bounce animation (0.4s → 0.5s for baby, 0.5s → 0.6s for egg)
- Change Record button label to Sing
2026-03-16 19:29:50 -03:00
filemon 04112110f7 polish: faster music animation timing and fix egg centering with music notes 2026-03-16 19:22:40 -03:00
filemon d835cb5e6a feat: add floating music notes and tune animation timing for Blobbi reactions 2026-03-16 19:10:20 -03:00
filemon 6e5a6b5d91 feat: reusable music/dance reaction system for all Blobbi stages
BREAKING: Egg no longer sways by default when animated=true.
Sway animation now requires explicit reaction state.

Changes:
- Remove default egg sway from animated prop in EggGraphic
- Add reaction prop to EggGraphic, BlobbiEggVisual, BlobbiBabyVisual, BlobbiStageVisual
- Add EggReactionState, BabyReactionState, BlobbiReaction types
- Wire blobbiReaction state from BlobbiPage to BlobbiStageVisual
- Add animate-blobbi-sway and animate-blobbi-bounce CSS animations
- Add animate-egg-bounce for singing reaction on eggs

Reaction states:
- idle: no animation (default)
- listening: gentle sway (music playing)
- swaying: gentle sway
- singing: bouncy animation (sing action active)
- happy: gentle sway

Trigger behavior:
- Play Music: listening reaction when audio plays
- Sing: singing reaction when card opens
- Close activity: returns to idle
2026-03-16 18:48:57 -03:00
filemon 270cb51acc fix: volume control popup now renders via portal to avoid clipping
The volume slider was being clipped by the card's overflow:hidden.
Now using Popover component (Radix UI) which renders via portal,
ensuring the volume control appears above all UI elements correctly.
2026-03-16 18:31:02 -03:00
filemon 579c78b2ad feat: add volume control to InlineMusicPlayer
- Add volume state and setVolume function to useAudioPlayback hook
- Default volume is 0.8, persists across track changes
- Volume button shows Volume2 icon (or VolumeX when muted)
- Clicking volume button shows horizontal slider popup
- Slider controls real audio volume (0-1 range)
- Click outside to dismiss volume slider
2026-03-16 18:23:39 -03:00
filemon 470cdd1c76 refactor: change stop button to restart button in InlineMusicPlayer
- Add restart() function to useAudioPlayback hook (sets currentTime=0 and plays)
- Replace Square icon with RotateCcw for restart semantics
- Restart button resets track to beginning and continues playing
- Stop function still exists for cleanup on close
2026-03-16 18:04:31 -03:00
filemon 2abbae38d9 fix: stop button now truly stops playback instead of auto-restarting
The auto-start effect was incorrectly triggering on 'stopped' state,
causing immediate restart. Now 'stopped' is a terminal state that
requires explicit play button click to restart.
2026-03-16 18:00:43 -03:00
filemon 46dc8d9e12 fix: inline music player stop/track-switch behavior and UI cleanup
- Fix stop button: add 'stopped' state so stop truly stops playback instead of pausing at 0
- Fix track switching: detect source.url changes and reload, distinguish change vs initial selection
- Disable Upload tab in PlayMusicModal (marked 'Soon' but was still clickable)
- Remove misplaced chevron arrows from lyrics toggle button in InlineSingCard
2026-03-16 17:56:28 -03:00
filemon 59cbb9d740 fix: inline activity layout and move audio assets to public/
- Move inline activity cards inside padded container to prevent overlap with fixed bottom bar
- Move audio files from src/blobbi/audio/ to public/blobbi/audio/ for correct Vite asset loading
- Update track metadata with accurate durations from ffprobe
- Update documentation comments to reflect correct asset location
- Remove unused MicOff import from InlineSingCard
2026-03-16 17:38:21 -03:00
filemon c50c9bec7e refactor: inline activity cards for Play Music and Sing
- Add InlineMusicPlayer component for persistent music playback UI
- Add InlineSingCard component for inline recording/lyrics experience
- Add useAudioPlayback hook for reusable audio playback logic
- Add blobbi-activity-state types for activity and reaction state management

Play Music flow:
- PlayMusicModal now serves as track picker only
- After track selection, inline player appears with play/pause/stop controls
- Action published first, playback starts only after success

Sing flow:
- Recording happens inline (no modal)
- Lyrics panel expands upward with random lyrics
- Action published only when user confirms with 'Sing for Blobbi'

Blobbi reaction state prepared for future visual animations
2026-03-16 16:21:00 -03:00
filemon 14ebcb1165 Merge branch 'main' into feat-blobbi 2026-03-16 15:53:56 -03:00
filemon 3728ad02e6 fix: CSP and audio recording/playback for SingModal
- Add blob: to media-src CSP directive to allow recorded audio playback
- Add robust MIME type selection helper for MediaRecorder
- Try MIME types in order: webm;opus, webm, mp4, ogg;opus, ogg
- Track actual recorder MIME type and use it for blob creation
- Add user-friendly playback error messages (non-fatal amber warnings)
- Verify PlayMusicModal blob URL handling works correctly
- Add 'Soon' badge to Upload tab in PlayMusicModal
2026-03-16 15:50:22 -03:00
filemon ba9e1f5375 fix: cleanup pass for Blobbi play_music and sing actions
- Relax egg-stage blocking in canUseAction() (UI visibility vs domain logic)
- Fix egg inventory filtering to only show items with egg-compatible effects
- Remove 'shell' wording from medicine UI text
- Fix canonical data usage in mutations (use canonical.companion for decay)
- Fix browser timer typing (NodeJS.Timeout -> ReturnType<typeof setInterval>)
- Fix PlayMusicModal audio source switching (recreate Audio on source change)
- Fix SingModal recording playback (track current playback URL)
- Add random lyrics helper for Sing action with collapsible UI
2026-03-16 15:32:09 -03:00
filemon 598c5f90ea Add play_music and sing actions, fix egg clean/medicine consistency
- Fix egg stage actions: clean and medicine now work for eggs
- Add play_music action with built-in tracks and file upload
- Add sing action with in-browser audio recording
- Hide feed/play/sleep actions for eggs in UI (not hard-blocked)
- Both new actions increase happiness only (+15/+20)
- Placeholder built-in tracks in blobbi-builtin-tracks.ts
2026-03-16 15:08:30 -03:00
filemon 6e1a195615 Deprecate egg_temperature - eggs now use warmth prop fallback 2026-03-16 11:11:34 -03:00
filemon 452848f14f Remove shell_integrity - eggs now use standard health stat
BREAKING: shell_integrity is fully removed from the egg model.
Eggs now use the standard 3-stat model: health, hygiene, happiness.

Changes:
- Remove EggStats, EggMedicineResult types from blobbi-action-utils.ts
- Remove applyMedicineToEgg function (medicine now uses applyStat directly)
- Update useBlobbiUseInventoryItem to apply medicine health effect to egg health
- Update BlobbiActionInventoryModal to preview health changes for egg medicine
- Remove shell_integrity from ItemEffect in shop types
- Remove shellIntegrity from BlobbiEggData in types/blobbi.ts
- Remove EggStats, EggMedicineResult, applyMedicineToEgg from exports
- Add DEPRECATED_BLOBBI_TAG_NAMES set with 'shell_integrity'
- Update mergeBlobbiStateTagsForRepublish to filter out deprecated tags

Migration: Existing events with shell_integrity tags will have them
automatically removed on the next republish (any user interaction).

Egg stat model is now fully consistent:
- health, hygiene, happiness: active (decay + medicine)
- hunger, energy: fixed at 100
2026-03-16 00:20:33 -03:00
filemon 4ccc123209 Apply decay before stage transitions (hatch/evolve)
- Create useBlobbiHatch hook for egg -> baby transition
- Create useBlobbiEvolve hook for baby -> adult transition
- Both hooks apply accumulated decay before publishing new state
- Wire up floating action button to trigger hatch/evolve based on stage
- Hide evolve button for adults (already fully evolved)
- Show loading state during transitions
- Export new hooks and types from blobbi/actions module

Stage transitions now consistently apply decay first, ensuring
no transition can happen from stale stats.
2026-03-15 21:10:44 -03:00
filemon a987449789 Apply decay before sleep/wake mutations in handleRest
- Import applyBlobbiDecay in BlobbiPage
- Calculate accumulated decay before state change
- Persist decayed stats along with new sleep/wake state
- Reset last_decay_at timestamp after applying decay

This ensures stats accurately reflect elapsed time when toggling
between active and sleeping states.
2026-03-15 21:04:36 -03:00
filemon ba9ff0964b feat(blobbi): implement decay system with projected UI state
Core decay system (src/lib/blobbi-decay.ts):
- Pure applyBlobbiDecay() function for deterministic decay calculation
- Stage-specific decay rates: egg (2-3hr), baby (3-5hr), adult (5-7hr)
- Health modifiers based on other stats
- Health regeneration when all stats >= 80
- Floor all deltas, clamp stats to 0-100
- Warning/critical threshold helpers

UI projection hook (src/hooks/useProjectedBlobbiState.ts):
- Calculates projected stats without publishing
- Recalculates every 60 seconds
- Returns visible stats with status indicators

BlobbiPage updates:
- Uses projected state for display
- Egg shows 3 stats (health, hygiene, happiness)
- Baby/adult shows all 5 stats
- StatIndicator supports warning/critical status styling

Mutation updates (useBlobbiUseInventoryItem):
- Applies accumulated decay before interactions
- Uses decayed stats as base for item effects
- Updates last_decay_at on every interaction

Documentation (docs/blobbi/decay-system.md):
- Comprehensive explanation of the system
- All decay rates and thresholds
- Mutation flow diagram
- Edge cases and assumptions
2026-03-15 21:01:34 -03:00
filemon 3cd64fb0af refactor(blobbi): make evolve button icon stage-aware
- egg stage: Shows Egg icon with 'Hatch' tooltip
- baby/adult stages: Shows Sparkles icon with 'Evolve' tooltip

Implementation:
- Added 'stage' prop to BlobbiDashboardFloatingControlsProps
- Created getEvolveIcon() helper - returns Egg or Sparkles based on stage
- Created getEvolveTooltip() helper - returns 'Hatch' or 'Evolve'
- Removed unused Zap import

Icon choice rationale:
- Sparkles was chosen for non-egg stages because it communicates magical
  transformation, which fits the Blobbi fantasy/pet theme better than
  technical icons like TrendingUp or ArrowUpCircle
2026-03-15 19:48:43 -03:00
filemon 6e8e6fe243 feat(blobbi): add floating action buttons to dashboard (placeholders)
New components:
- FloatingActionDef: Typed interface for button definitions
- BlobbiDashboardFloatingControls: Component that renders left and right
  floating button clusters

Buttons added (all visual-only placeholders):
- Right side (top cluster):
  - Settings (Settings icon)
  - Set as Companion (Heart icon)
  - Take a Photo (Camera icon)
  - Open PiP (PictureInPicture2 icon)
  - Blobbi Info (Info icon) - wired to existing info modal
  - Evolve (Zap icon) - styled with accent/primary colors
- Left side:
  - Back button (ArrowLeft icon) - optional, not rendered by default

Implementation:
- Uses existing QuickActionButton for consistent styling
- Evolve button has distinct accent styling (primary colors)
- Button definitions centralized in typed arrays
- Placeholder handlers use console.log('TODO: ...')
- Existing info modal functionality preserved
2026-03-15 19:37:35 -03:00
filemon 476f1bade2 feat(blobbi): apply base color to dashboard and info modal names
- BlobbiDashboard: The h2 element displaying companion.name now uses
  style={{ color: companion.visualTraits.baseColor }}
- BlobbiInfoModal: The DialogTitle displaying companion.name now uses
  style={{ color: companion.visualTraits.baseColor }}
- Both update correctly when selecting a different Blobbi
- Selector card names left unchanged (not prominent display)
2026-03-15 19:29:39 -03:00
filemon cf1d9ad53f fix(blobbi): add separate colored name display above egg
- Removed baseColor styling from Input (was not working reliably)
- Added a separate <p> element between the input and egg visual
- The <p> displays trimmedName with style={{ color: preview.visualTraits.baseColor }}
- This element updates live as the user types (uses trimmedName from preview.name)
- Input reverted to normal theme styling (text-center font-medium)
- No duplicate name under the egg (title still not passed to EggGraphic)
2026-03-15 19:22:10 -03:00
filemon 680ff86202 fix(blobbi): apply egg base color to name input field
- Removed the extra styled name display div (was creating a second name)
- Applied base color directly to the Input element via style prop
- Changed Input className to 'text-center font-semibold text-lg' for better visibility
- Now only ONE visible name exists: the input field itself, styled with the egg's baseColor
2026-03-15 19:19:06 -03:00
filemon 9797fcd95a fix(blobbi): canonicalize patterns and fix duplicate name display
Pattern canonicalization:
- Changed VALID_PATTERNS from ['gradient', 'solid', 'speckled', 'striped']
  to ['solid', 'spotted', 'striped', 'gradient'] to match domain model
- 'spotted' is the canonical value (used by BlobbiPattern type, BLOBBI_PATTERNS,
  derivePatternFromSeed, normalizePatternTag, and PATTERN_MAP)

Duplicate name fix:
- Removed title from toEggGraphicVisualBlobbi() adapter - the EggGraphic
  'title' field is for special designations (e.g., 'Divine'), not pet names
- The duplicate was: input field above egg + title display below egg
- Now only the input field exists, plus a new styled name display

Name styling:
- Added styled name display above the egg using the egg's baseColor
- Styling matches the former bottom title: bg-black/20, backdrop-blur-sm,
  font-semibold, text-shadow, and color from preview.visualTraits.baseColor
2026-03-15 19:14:52 -03:00
filemon 5d3841d6a7 fix(blobbi): cleanup egg validation inconsistencies
- Fix getColorRarity() to properly merge both base color palettes by
  creating MERGED_BASE_COLORS_BY_RARITY that combines colors per rarity
  tier (previous spread syntax overwrote keys instead of merging arrays)
- Update validation error messages to match actual validation logic:
  colors now accept any valid hex format, not just specification palettes
- Add Rarity type for better type safety in rarity functions
- Add JSDoc clarifying that getColorRarity returns null for domain model
  colors (BLOBBI_BASE_COLORS) which are not in the legacy palettes
2026-03-15 18:39:10 -03:00
filemon 9e5de53ad4 fix(blobbi): make egg colors update on reroll
Root cause: EggGraphic validation rejected derived colors because
isValidBaseColor() used a hardcoded allowlist that didn't include
the BLOBBI_BASE_COLORS palette (e.g., #F59E0B, #55C4A2, etc.).

Changes:
- Update isValidBaseColor/isValidSecondaryColor to accept any valid
  hex color format (palette enforcement at domain level)
- Add visual trait tags (base_color, secondary_color, eye_color,
  pattern, special_mark, size) to previewToEventTags for
  deterministic rendering
- Improve useMemo dependencies in BlobbiEggVisual to ensure
  re-render on preview change
2026-03-15 13:59:36 -03:00
filemon 13cdbc565c feat(blobbi): add 'Adopt another Blobbi' entry point from selector
Adds a dedicated CTA card in the Blobbi selector modal and page to allow
existing users to adopt additional Blobbies without going through full
onboarding.

Changes:
- Added AdoptAnotherBlobbiCard component with plus icon, tooltip, and
  distinct visual styling (dashed border, centered layout)
- Updated BlobbiOnboardingFlow to support adoptionOnly prop that skips
  profile creation and adoption question, going directly to egg preview
- Updated useBlobbiOnboarding hook with adoptionOnly mode support that:
  - Derives initial step as 'preview' when adoptionOnly is true
  - Generates preview immediately on mount in adoptionOnly mode
  - Skips auto-sync logic that would interfere with explicit control
- Added adoption flow modal to BlobbiDashboard with full callback wiring
- Added adoption flow modal to BlobbiSelectorPage (Cases G and H)
- Passed required adoption callbacks through BlobbiDashboard props

UX flow:
1. User clicks 'Adopt another Blobbi' card in selector
2. Selector closes, adoption flow modal opens
3. User sees egg preview directly (no profile/adoption question steps)
4. User can reroll, name, and adopt as normal
5. On completion, modal closes and new Blobbi is selected
2026-03-15 13:44:48 -03:00
filemon 5ea4b0f73d fix(blobbi): add pettingLevel, fix reroll visual, improve onboarding stability
1. pettingLevel support:
   - Added pettingLevel to BlobbonautProfile type and parsing
   - New profiles include pettingLevel: 0 by default
   - Created useBlobbonautProfileNormalization hook to auto-add
     pettingLevel to existing profiles that are missing it

2. Reroll visual fix:
   - Added key prop to BlobbiStageVisual to force remount on preview change
   - Added debug logging to track preview identity changes (d/seed/petId)
   - Reroll preserves typed name while generating new identity

3. Onboarding stability improvements:
   - Enhanced step sync logic in useBlobbiOnboarding to handle all edge cases
   - Added defensive checks for profile state changes
   - Better debug logging for state transitions

4. Verified invariants:
   - Preview remains single source of truth for adopted event
   - Name is editable, required for adoption, preserved on reroll
   - No any types introduced
2026-03-15 13:16:26 -03:00
filemon f3e262bd3a fix(blobbi): respect user profile state in onboarding flow
- Fixed useBlobbiOnboarding to derive initial step from profile state
- Added useEffect to sync step when profile loads from cache/relay
- Added egg name customization via updatePreviewName() function
- Removed 'Maybe Later' skip option from adoption step
- Refactored BlobbiPage with cleaner state logic and debug logging
- Fixed TypeScript errors (unused vars, empty interface)

Resolves issue where onboarding always started on 'profile' step
even when user already had a profile, and adds name input to
egg preview before adoption.
2026-03-15 12:48:58 -03:00
filemon 96b8288c5b feat(blobbi): implement new onboarding flow with egg preview
Add complete Blobbi onboarding flow:
- Profile creation step with name prefill from kind 0 metadata
- Adoption question step after profile creation
- Egg preview with reroll (10 coins) and adopt (100 coins) options
- Confirmation dialog before adoption
- New profiles start with 200 coins

Key components:
- BlobbiProfileOnboarding: Profile creation with name input
- BlobbiAdoptionStep: 'Ready to adopt?' prompt
- BlobbiEggPreviewCard: Egg preview with visual traits and actions
- BlobbiAdoptionConfirmDialog: Adoption cost confirmation
- useBlobbiOnboarding: State and action orchestration hook

Preview is the source of truth for adoption - same exact data is
used to create the final kind 31124 event. Coins are deducted
from profile before publishing events.
2026-03-14 16:44:14 -03:00
filemon a85590f5fb Merge branch 'main' into feat-blobbi 2026-03-13 23:16:16 -03:00
filemon 5a93bdd0a6 Merge branch 'main' into feat-blobbi 2026-03-10 10:14:43 -03:00
filemon be582f4db7 fix: use migrated profile context in inventory item usage flow
Bug: When using Feed/Play/Clean on a legacy Blobbi, the migration
would correctly publish a canonical profile, but then the inventory
usage flow would republish the profile using stale pre-migration tags
from the hook closure, restoring legacy has/current_companion values.

Fix:
- Extend EnsureCanonicalResult to include profileAllTags and profileStorage
- Extend MigrationResult to include profileTags and profileStorage
- Update ensureCanonicalBlobbiBeforeAction to return profile context
- Update useBlobbiUseInventoryItem to use canonical.profileStorage and
  canonical.profileAllTags instead of profile.storage/profile.allTags

This ensures the post-item-use 31125 event is built from the migrated
profile state, preserving:
- canonical has[] values
- canonical current_companion
- storage changes (item decrement)
- all unknown tags
2026-03-09 17:39:53 -03:00
filemon 01a174f9e3 feat: add Medicine action with egg-specific shell_integrity support
- Add 'medicine' to InventoryAction type and related mappings
- Medicine is available for all stages: egg, baby, adult
- For eggs: health effect is converted to shell_integrity
- For eggs: other effects (energy, happiness, etc.) are ignored
- For baby/adult: all effects are applied normally

Egg-specific behavior:
- previewMedicineForEgg() shows shell_integrity changes
- applyMedicineToEgg() converts health → shell_integrity
- hasMedicineEffectForEgg() validates egg-applicable effects

Stage restriction changes:
- canUseAction(companion, action) replaces canUseInventoryItems()
- EGG_ALLOWED_ACTIONS defines which actions eggs can use
- getStageRestrictionMessage() now action-aware

UI updates:
- Medicine button added to BlobbiActionsModal (Pill icon)
- Inventory modal shows shell_integrity preview for eggs
- Contextual description: 'Strengthen your egg's shell' for eggs
2026-03-09 17:25:51 -03:00
filemon ecc306079b feat: implement inventory item usage with Feed, Play, Clean actions
- Add blobbi-action-utils.ts with stat clamping, item effects, and inventory filtering
- Create useBlobbiUseInventoryItem hook for consuming inventory items
- Add BlobbiActionInventoryModal for selecting items to use per action type
- Update BlobbiActionsModal with Feed, Play, Clean, Sleep/Wake buttons
- Integrate action modals with BlobbiPage
- Remove duplicate StorageItem from shop.types.ts (kept in lib/blobbi.ts)
- Stage restrictions: eggs cannot use items, only baby/adult can

The inventory usage flow:
1. User opens Actions modal from bottom bar
2. Selects Feed/Play/Clean to open inventory modal
3. Modal shows filtered items by action type with effect preview
4. On item use: updates Blobbi stats (31124) and decrements storage (31125)
2026-03-09 17:12:49 -03:00
filemon 251ea43e33 refactor: reorganize Blobbi shop into domain-scoped structure with list layout
- Move shop module to src/blobbi/shop/ for clear domain boundaries
- Rename components with Blobbi prefix for clarity:
  - ShopModal → BlobbiShopModal
  - InventoryModal → BlobbiInventoryModal
  - PurchaseDialog → BlobbiPurchaseDialog
  - usePurchaseItem → useBlobbiPurchaseItem
  - shop-items.ts → blobbi-shop-items.ts
  - shop.ts → shop.types.ts

- Convert shop UI from card grid to vertical list layout:
  - Replace ShopItemCard with BlobbiShopItemRow
  - Horizontal row layout: icon, name, category, effects, price, button
  - More compact and scannable design
  - Better mobile responsiveness
  - Easier to compare items at a glance

- Add blobbi-shop-utils.ts with helper functions:
  - formatEffectSummary() for compact effect display
  - getEffectCounts() for stat summaries
  - getPrimaryEffect() for tooltips/badges

- Folder structure:
  src/blobbi/shop/
    components/     (UI components)
    hooks/          (purchase hook)
    lib/            (catalog + utils)
    types/          (TypeScript types)

- Update all imports in BlobbiPage to use new paths
- Remove old generic shop paths (src/components/shop, etc.)
- Preserve all existing behavior and purchase flow
- No Lightning/sats/kind 40100/40101 (as specified)
- TypeScript strict, no any types used
2026-03-09 15:48:31 -03:00
filemon 37b8fc6752 feat: implement Blobbi Shop and Inventory system
- Add shop item catalog with 24 items (food, toys, medicine, hygiene, accessories)
- Create shop types: ShopItem, StorageItem, ItemEffect, PurchaseRequest
- Extend BlobbonautProfile with coins and storage fields
- Add storage tag parsing/serialization helpers for kind 31125
- Implement usePurchaseItem hook for atomic coin + storage updates
- Create ShopModal with category tabs and item grid
- Create PurchaseDialog with quantity selector and affordability checks
- Create InventoryModal to display purchased items
- Integrate shop and inventory into BlobbiPage footer
- Purchase flow: validates price, checks coins, stacks items, publishes single event
- Accessories marked as 'Coming Soon' (disabled state)
- No Lightning/LNURL/sats purchase flow (as specified)
- Only uses kind 31125 for profile persistence (no kind 40100/40101)
2026-03-09 15:32:03 -03:00
filemon 17b986c21d fix: use baseColor as secondaryColor fallback for legacy events without seed
When a legacy Blobbi has only base_color (no secondary_color, no seed),
the secondary color now falls back to the resolved baseColor instead of
the generic yellow default (#FCD34D).

This creates a unified palette for legacy events with partial traits,
avoiding the incorrect mixed palette (e.g., cyan base + yellow accent).
2026-03-09 13:04:51 -03:00
filemon d6b3dbc9f9 fix: respect explicit visual traits in legacy Blobbi events
The deriveVisualTraits function was returning default values immediately
when no seed was present, ignoring explicit tags in the event.

Fixed priority order (per field):
1. Explicit valid tags (always take precedence)
2. Seed-derived values (for canonical events)
3. Default fallbacks (when both are missing)

This ensures legacy Blobbi with explicit color/pattern tags render
correctly instead of falling back to default yellow/orange palette.
2026-03-09 12:51:56 -03:00
filemon 1f41478d53 wire baby rendering into the app with stage-aware visual component
- Add BlobbiBabyVisual component using baby-blobbi module for SVG resolution and customization
- Add BlobbiStageVisual component that routes rendering by life stage (egg/baby/adult)
- Update BlobbiPage to use BlobbiStageVisual for all Blobbi displays
- Uncomment BlobbiBabyData interface in types/blobbi.ts to fix type error
2026-03-09 12:27:16 -03:00
filemon bd71520cb5 add baby-blobbi file 2026-03-09 12:22:44 -03:00
filemon 6276d135e4 Merge branch 'main' into feat-blobbi 2026-03-08 10:33:58 -03:00
filemon 3abdbc2d88 cleanup: remove visibility feature, info card, and polish bottom bar spacing
Removed features:
- handleToggleVisibility handler and all related props
- Visibility quick action button from top-right floating actions
- Visibility action from BlobbiActionsModal
- Info card section (Generation, Experience, Care Streak, Last Active)

Removed unused code:
- InfoItem component
- formatTimeAgo helper function
- Card/CardContent imports

Bottom bar spacing adjustments:
- Container padding: px-2 → px-3
- Grid gap: gap-1 → gap-2
- Group internal gap: gap-0.5 → gap-1
- Center button margin: mx-1 → mx-2
- Button padding: px-2.5 → px-3
- Button min-width: 52px → 56px

Result: Cleaner dashboard with more breathing room in the bottom bar
2026-03-06 23:46:18 -03:00
filemon 241f234a82 polish: refine bottom bar layout and change center icon to Sparkles
Layout changes:
- Switch from flex justify-between to 3-column grid layout
- Left group now uses justify-end (closer to center)
- Right group now uses justify-start (closer to center)
- Reduce group gap from gap-1 to gap-0.5
- Reduce container padding from px-3 to px-2

Center button adjustments:
- Reduce vertical offset from -mt-6 to -mt-4 (more integrated)
- Reduce size from size-14 to size-12
- Add mx-1 for controlled horizontal spacing
- Replace Zap icon with Sparkles (better fits Blobbi identity)
- Reduce icon size from size-6 to size-5

Side button adjustments:
- Reduce horizontal padding from px-3 to px-2.5
- Reduce vertical padding from py-2 to py-1.5
- Reduce min-width from 60px to 52px

Result: More compact, balanced bottom bar with groups visually
closer to the center action button
2026-03-06 23:39:44 -03:00
filemon 28ee5b6881 feat: add bottom action bar and modal system to BlobbiPage
UI adjustments:
- Move top-right floating buttons lower for visual balance
- Remove switch button from top-right (now in bottom bar)
- Add fixed bottom action bar with left/center/right layout:
  - Left: Blobbies (opens selector), Missions (placeholder)
  - Center: Actions button (opens actions modal)
  - Right: Shop (placeholder), Inventory (placeholder)

New components:
- BlobbiBottomBar: Fixed bottom navigation bar
- BlobbiActionsModal: Rest/Wake and Hide/Show actions
- BlobbiPlaceholderModal: Reusable placeholder for future features
- BlobbiInfoModal: Detailed Blobbi information display
- BottomBarButton: Reusable button for bottom bar

Behavior:
- Blobbies button opens existing selector modal
- Actions modal contains functional Rest and Visibility toggles
- Info button (top-right) opens detailed info modal
- Placeholder modals ready for Missions, Shop, Inventory features
2026-03-06 23:34:21 -03:00
filemon d92790f0af refactor: replace hardcoded colors with app theme tokens in BlobbiPage
- Remove purple/pink gradients and hardcoded colors
- Use theme tokens: primary, muted, accent, border, card, background
- Keep semantic stat colors (orange/yellow/green/blue/violet) for meaning
- Page chrome now adapts to light/dark theme automatically
2026-03-06 23:22:46 -03:00
filemon e18d0592d6 refactor: redesign BlobbiPage with dashboard-style layout
Visual changes:
- Add frosted glass dashboard container with purple top border
- Add decorative purple/pink gradient overlay
- Center hero layout with prominent Blobbi visual
- Add floating quick action buttons (switch, rest, visibility)
- Replace stat bars with circular stat indicators
- Improve visual hierarchy with hero section focus
- Add glow effect behind main Blobbi visual
- Use purple/pink color theme consistently

Extracted components:
- DashboardShell: Frosted glass container wrapper
- BlobbiDashboard: Main dashboard view with hero section
- QuickActionButton: Floating circular action buttons
- StatIndicator: Circular progress stat display
- DashboardLoadingState: Skeleton loading for dashboard

Preserved unchanged:
- All data hooks (useBlobbonautProfile, useBlobbisCollection, etc.)
- Selection flow with localStorage priority
- Migration flow with ensureCanonicalBlobbiBeforeAction
- Create egg / rest / visibility handlers
- EggGraphic adapter integration
- Selector page and card components (restyled)
2026-03-06 23:16:35 -03:00
filemon 11802fc38a refactor: tighten all adapter types to exact Egg module unions
- Add EggPattern, EggSpecialMark, EggThemeVariant type aliases
- All derived via NonNullable<EggVisualBlobbi[field]>
- Update PATTERN_MAP to Record<BlobbiPattern, EggPattern>
- Update SPECIAL_MARK_MAP to Record<BlobbiSpecialMark, EggSpecialMark>
- Update all fallback constants with exact Egg types
- Update themeVariant parameter to EggThemeVariant
- No runtime changes, type-only refinement
2026-03-06 23:08:04 -03:00
filemon b2ae35d597 refactor: tighten adapter typing for EggVisualBlobbi
- Derive EggLifeStage type from EggVisualBlobbi using NonNullable
- Type LIFE_STAGE_MAP with exact EggLifeStage return type
- Add DEFAULT_THEME_VARIANT constant for consistency
- Remove unnecessary 'as const' assertions on mapping objects
- Improve JSDoc for toEggGraphicVisualBlobbi return type
- Rename areEggGraphicVisualsEqual parameter types to EggVisualBlobbi
2026-03-06 23:04:25 -03:00
filemon ec37a8befe refactor: cleanup Blobbi egg integration
- Remove fake try/catch render wrapper (not a real error boundary)
- Use real EggVisualBlobbi type from @/blobbi/egg module
- Pass full allTags to EggGraphic instead of filtering
- Adjust size mapping: sm (size-14/small), md (size-24/medium), lg (size-40/large)
- Gate debug logs behind import.meta.env.DEV check
- Simplify adapter by removing duplicated type definitions
- Reduce adapter from 290 lines to ~150 lines
- Keep architecture intact: domain → adapter → BlobbiEggVisual → EggGraphic
2026-03-06 21:24:58 -03:00
filemon 804dd550a2 feat(blobbi): add egg visual module 2026-03-06 21:10:29 -03:00
filemon 736d76f457 feat: integrate EggGraphic visual module into Blobbi UI
- Create BlobbiEggVisual reusable component in src/blobbi/ui/
- Replace placeholder Egg icons with real EggGraphic rendering
- Update adapter tags format to string[][] for EggGraphic compatibility
- Main display: large animated egg visual with seed-derived colors
- Selector cards: small egg visual showing unique traits per Blobbi
- Switch dialog: uses same selector cards with real visuals
- Add memoization for adapter output to avoid re-renders
- Include fallback safety with simple placeholder on render errors
- Preserve all existing fetch/migration/selection behavior
2026-03-06 21:06:01 -03:00
filemon 84734d7304 feat: add EggGraphic adapter and improve visual trait derivation
- Create blobbi-egg-adapter.ts with toEggGraphicVisualBlobbi() function
- Add explicit mapping tables for pattern, specialMark, size, lifeStage
- Add comprehensive VISUAL TRAIT POLICY documentation
- Export LEGACY_VISUAL_TAG_NAMES constant for migration preservation
- Improve deriveNameFromLegacyD() to handle multi-word names (mr-cool → Mr Cool)
- Update buildMigrationTags() to explicitly preserve legacy visual tags
- Add capitalizeWords() helper for human-friendly name formatting
- Add areEggGraphicVisualsEqual() utility for memoization support
2026-03-06 20:09:46 -03:00
filemon e19b99a2bc refactor: align visual traits with EggGraphic contract
Visual Traits Interface:
- baseColor: hex value (e.g., '#F59E0B')
- secondaryColor: hex value for accent
- eyeColor: hex value for eyes
- pattern: 'solid' | 'spotted' | 'striped' | 'gradient'
- specialMark: 'none' | 'star' | 'heart' | 'sparkle' | 'blush'
- size: 'small' | 'medium' | 'large'

Color Palettes (10 colors each):
- BLOBBI_BASE_COLORS: Amber, Teal, Sky Blue, Pink, Purple, Coral, Emerald, Yellow, Indigo, Orange
- BLOBBI_SECONDARY_COLORS: Light variants for accents
- BLOBBI_EYE_COLORS: 8 expressive options

Seed Derivation:
- deriveIndexFromSeed with documented offset layout
- Each trait derived from different seed segment
- Validation/normalization for legacy tags
- Falls back to DEFAULT_VISUAL_TRAITS when seed missing

Debug Logs:
- Concise format: d, name, isLegacy, hasSeed, traits summary
- Removed verbose visualTraits object dump

parseBlobbiEvent remains single source of truth for:
- name resolution (tag > legacy d > fallback)
- seed
- visualTraits
- isLegacy flag

Migration flow unchanged - ready for EggGraphic integration.
2026-03-06 14:45:58 -03:00
filemon 577334cbaa feat: add visual trait derivation from seed and centralized migration
PART 1 - Visual Traits from Seed:
- Add BlobbiVisualTraits interface with baseColor, pattern, specialMark, size
- Add deriveNumberFromSeed helper for deterministic derivation
- Add deriveBaseColorFromSeed, derivePatternFromSeed, deriveSpecialMarkFromSeed, deriveSizeFromSeed
- Add deriveVisualTraits to combine tag values with seed-derived fallbacks
- Visual trait priority: explicit tags > derived from seed > default values

PART 2 - Legacy Event Detection:
- Add isLegacyBlobbiEvent helper function
- Legacy criteria: non-canonical d-tag, missing seed, missing name tag, visual traits without seed
- Add companionNeedsMigration convenience wrapper
- Add isLegacy and visualTraits fields to BlobbiCompanion interface

PART 3-7 - Centralized Migration:
- Create useBlobbiMigration hook with ensureCanonicalBlobbiBeforeAction
- Migration auto-updates: localStorage selection, React Query caches, profile references
- Refactor BlobbiPage action handlers to use centralized helper
- Migration continues original action after upgrading legacy pet

No changes to event kinds, collection fetching, or selection system.
2026-03-06 14:27:18 -03:00
filemon 35b615dc9f fix: support legacy Blobbi names derived from d-tag
- Add deriveNameFromLegacyD() helper to extract name from legacy d-tags
- Update parseBlobbiEvent to use name resolution priority:
  1. Use 'name' tag if present
  2. Derive from legacy d-tag format (blobbi-{name} → Name)
  3. Fall back to 'Unnamed Blobbi'
- Add debug logs in parser showing d, name, nameTag, stage, state
- Add '[Blobbi UI]' debug log when selected companion changes
- Legacy pets like 'blobbi-puck' now display as 'Puck'
2026-03-06 14:13:40 -03:00
filemon ef8e9a3ccf fix: load ALL Blobbis and implement proper UI selection flow
- Update useBlobbisCollection to fetch ALL pets without limit:1
- Add chunking support (20 items per chunk) for relay compatibility
- Add debug logs for dList and 31124 query filter
- Implement localStorage-based UI selection (user-scoped key)
- Selection priority: localStorage > first in profile.has > show selector
- Add BlobbiSelectorPage for when no valid selection exists
- Add BlobbiSelectorCard component for pet selection UI
- Add 'Switch Blobbi' button in header for users with multiple pets
- Separate concerns: currentCompanion (global) vs selectedBlobbi (page UI)
2026-03-06 13:45:51 -03:00
filemon 27d5544f8b Fetch all Blobbi pets (Kind 31124) by multiple d-tags
- Add useBlobbisCollection hook to query all d-tags from profile.has[] and currentCompanion
- Update BlobbiContent to use collection hook instead of single companion hook
- Keep only newest event per d-tag for deduplication
- Add debug logging to verify multi-d-tag REQs in DevTools
- UI still renders only the selected companion (currentCompanion or first in has[])
2026-03-06 13:25:49 -03:00
filemon e98111bf00 fix: useBlobbonautProfile type alignment and add effectiveCompanionD
- Fix BlobbiBootCache type usage (companion singular, not companions plural)
- Add effectiveCompanionD to hook return value for BlobbiPage
- Add debug logging to track kind 31125 query execution
- Add refetchOnMount: 'always' with initialDataUpdatedAt for proper cache behavior
2026-03-05 21:04:38 -03:00
filemon c8c68f1898 Refactor Blobbi system for spec compliance and fix loading issues
- Fix canonical Blobbi ID regex to require hex-only petId per spec
- Add getOrDeriveSeed helper to prevent seed recomputation
- Separate managed tag sets for Kind 31124 and 31125
- Add tag deduplication helpers (deduplicateHasTags)
- Add legacy pet migration on user interactions
- Fix localStorage cache validation with pubkey ownership check
- Add effectiveCompanionD fallback to has[] array
- Prevent premature "Create Egg" UI while queries resolve
- Add usePersistentNostrSession hook with exponential backoff
- Configure React Query to prevent refetch loops (staleTime, refetchOnWindowFocus)
2026-03-05 17:32:32 -03:00
filemon f47ccbec51 Merge branch 'main' into feat-blobbi 2026-03-05 17:11:28 -03:00
filemon ba53bc05a4 Add Blobbi egg feature with profile and companion management
Implements the initial Blobbi ecosystem (egg stage only) per the spec:
- Kind 31125 (Blobbonaut Profile) with canonical d-tag and legacy support
- Kind 31124 (Blobbi Current State) with canonical d-tag and seed derivation
- localStorage boot cache for instant UI on page load
- Profile initialization, egg creation, rest action, visibility toggle
- Preserves unknown tags when republishing for forward compatibility
2026-03-04 20:29:21 -03:00
416 changed files with 70477 additions and 4306 deletions
+29 -11
View File
@@ -26,19 +26,37 @@ test:
script:
- npm run test
pages:
deploy-nsite:
stage: deploy
timeout: 5 minutes
timeout: 10 minutes
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
variables:
NSYTE_VERSION: "v0.24.1"
script:
# Build the web app
- npm ci
- npm run build
- rm -rf public
- mv dist public
artifacts:
paths:
- public
only:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
- cp dist/index.html dist/404.html
# Download nsyte binary
- curl -fsSL "https://github.com/sandwichfarm/nsyte/releases/download/${NSYTE_VERSION}/nsyte-linux" -o /usr/local/bin/nsyte
- chmod +x /usr/local/bin/nsyte
# Deploy to nsite via nsyte using the nbunksec credential
- >-
nsyte deploy ./dist
-i
--sec "$NSITE_NBUNKSEC"
--name ditto
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
--fallback "/index.html"
--publish-server-list
--use-fallback-relays
--use-fallback-servers
build-apk:
stage: build
@@ -198,4 +216,4 @@ publish-zapstore:
- VERSION="${CI_COMMIT_TAG#v}"
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
- zsp publish -y --skip-metadata --skip-preview zapstore.yaml
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
+161 -16
View File
@@ -12,6 +12,7 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
- **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality
- **TanStack Query**: For data fetching, caching, and state management
- **TypeScript**: For type-safe JavaScript development
- **Capacitor**: Native iOS and Android shell wrapping the web app
## Project Structure
@@ -293,14 +294,16 @@ When adding support for a new Nostr event kind to the application, the kind must
- `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. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content. Both `EmbeddedNote` and `EmbeddedNaddr` render a compact card (author + title/content preview) for all kinds automatically — no per-kind registration needed. The same components are reused by CommentContext hover cards and the reply composer.
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`.
> **Note**: Do not confuse these with the `compact` prop on `NoteCard`. The `compact` prop simply hides action buttons on a full `NoteCard`; `EmbeddedNote`/`EmbeddedNaddr` are entirely different components with their own rendering logic.
7. **Reply composer** (`src/components/ReplyComposeModal.tsx`):
- The `EmbeddedPost` component delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components — no per-kind registration needed
#### Why so many places?
These are genuinely different UI contexts (feed cards, detail pages, inline embeds, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
### NIP.md
@@ -692,6 +695,27 @@ export function MyComponent() {
The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events.
### Mutating Replaceable 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, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const freshEvent = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
// ...modify tags...
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### Nostr Login
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
@@ -953,6 +977,16 @@ const defaultConfig: AppConfig = {
The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations.
### Adding a New AppConfig Value
Adding a new configuration field requires updates in **three places**. Missing any of them will cause build failures or runtime issues.
1. **TypeScript interface** (`src/contexts/AppContext.ts`): Add the field to the `AppConfig` interface with a JSDoc comment.
2. **Zod schema** (`src/lib/schemas.ts`): Add the same field to `AppConfigSchema`. The `DittoConfigSchema` (used to validate the build-time `ditto.json` file) is derived from `AppConfigSchema` with `.strict()` mode, so any field present in `ditto.json` but missing from the Zod schema will cause a build error.
3. **Default value** (`src/contexts/AppContext.ts`): If the field is required (not optional), add a default value in `defaultConfig`. Optional fields (`?` in the interface, `.optional()` in Zod) can be omitted from the default.
### Relay Management
The project includes a complete NIP-65 relay management system:
@@ -1248,12 +1282,73 @@ When your changes are complete and validated, create a git commit with a descrip
**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.**
## Capacitor Compatibility
The app runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
### What Doesn't Work in WKWebView (iOS)
- **`<a download>` file downloads** -- Programmatically creating an anchor element with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
- **`<a target="_blank">` new tabs** -- Programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
- **`window.open()`** -- May be blocked or behave unexpectedly without user gesture context.
### File Downloads and URL Opening
The project provides two utility functions in `src/lib/downloadFile.ts` that handle the web/native split automatically:
#### `downloadTextFile(filename, content)`
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
```typescript
import { downloadTextFile } from '@/lib/downloadFile';
await downloadTextFile('backup.txt', fileContents);
```
#### `openUrl(url)`
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
```typescript
import { openUrl } from '@/lib/downloadFile';
await openUrl('https://example.com/image.jpg');
```
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. Always use the utilities above. They handle the Capacitor/web split and will work correctly on all platforms.
### Detecting Native Platforms
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
```typescript
import { Capacitor } from '@capacitor/core';
if (Capacitor.isNativePlatform()) {
// iOS or Android
} else {
// Web browser
}
```
### Installed Capacitor Plugins
- `@capacitor/app` -- App lifecycle events (deep links, back button)
- `@capacitor/core` -- Core runtime and platform detection
- `@capacitor/filesystem` -- Read/write files on the native filesystem
- `@capacitor/local-notifications` -- Schedule local push notifications
- `@capacitor/share` -- Native share sheet
- `@capacitor/status-bar` -- Control the native status bar style
After adding or removing plugins, run `npx cap sync` to update the native projects.
## CI/CD Pipeline
The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
1. **test** - Runs `npm run test` on every commit (skipped for tags)
2. **deploy** - Builds and deploys to GitLab Pages (default branch only)
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
@@ -1293,19 +1388,69 @@ NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and
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):**
1. Generate a client key: `nak key generate` (save the hex output)
2. Store it as `ZAPSTORE_CLIENT_KEY` in GitLab CI/CD variables
3. Get a bunker URL from Amber (with `secret` param for first connection)
4. Authorize the client key locally using `nak`:
```bash
export NOSTR_CLIENT_KEY="<the hex client key>"
nak event --sec "bunker://<pubkey>?relay=...&secret=<secret>" -c "test"
```
5. Approve the connection on Amber when prompted
6. Store the bunker URL **without the `secret` param** as `ZAPSTORE_BUNKER_URL` in GitLab CI/CD variables (the secret is single-use and no longer needed after authorization)
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 will then output the `bunker://` URI and client key hex, and write the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
The script accepts 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)
**Key points:**
- The `secret` in bunker URLs is **single-use** -- it is consumed on first connection and cannot be reused
- The `ZAPSTORE_CLIENT_KEY` must be authorized locally first by connecting to the bunker with a fresh secret and approving on Amber
- 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, the authorization step must be repeated with a new bunker URL secret
- If the client key is rotated, run the script again and update the GitLab CI/CD variables
### nsite Publishing
The project automatically deploys the web app to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The `deploy-nsite` CI job builds the Vite app and uploads the `dist/` directory to Blossom servers, publishing site manifest events to Nostr relays.
nsyte uses a NIP-46 bunker credential called `nbunksec` -- a bech32-encoded string that bundles the bunker pubkey, client secret key, and relay info into a single self-contained token. This is passed to nsyte via `--sec`.
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
#### Initial Setup (one-time)
1. Install nsyte locally:
```bash
curl -fsSL https://nsyte.run/get/install.sh | bash
```
2. Generate the CI credential:
```bash
nsyte ci
```
This will guide you through connecting a NIP-46 bunker (e.g. Amber) and output an `nbunksec1...` string. The credential is shown only once.
3. Add the `nbunksec1...` value as the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
#### Configured Relays and Servers
The deploy job publishes to these relays:
- `wss://relay.ditto.pub`
- `wss://relay.nsite.lol`
- `wss://relay.dreamith.to`
- `wss://relay.primal.net`
And uploads blobs to these Blossom servers:
- `https://blossom.primal.net`
- `https://blossom.ditto.pub`
- `https://blossom.dreamith.to`
The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
#### Credential Rotation
To rotate the nsite credential:
1. Revoke the old bunker connection in your signer app
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
+166
View File
@@ -1,5 +1,171 @@
# Changelog
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber and NIP-46 users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
+68 -1
View File
@@ -2,12 +2,31 @@
## Event Kinds Overview
### Ditto Kinds
| Kind | Name | Description |
|-------|----------------------|-------------------------------------------------------|
| 36767 | Theme Definition | Shareable, named custom UI theme |
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery |
### Community Kinds
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
| Kind | Name | Description | Spec |
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14921 | Blobbi Record | Immutable lifecycle record (birth, evolution, adoption) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 31124 | Blobbi Pet State | Current state of a virtual Blobbi pet (addressable) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
---
@@ -294,3 +313,51 @@ The `shape` field is added to the JSON content of a kind 0 event alongside stand
- The `shape` field is purely cosmetic and has no protocol-level significance.
- Clients MAY choose not to support this extension, in which case avatars render as circles as usual.
---
## Community NIP Specifications
The following specifications are maintained by their respective authors. Ditto implements these kinds but does not own the specs. See each link for the full event structure, tags, and client behavior.
### Color Moments (Kind 3367)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md
**App:** https://espy.you
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
### Geocaching (Kinds 37516, 7516)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md
**App:** https://treasures.to
NIP-GC defines geocaching on Nostr. Kind 37516 (addressable) is a geocache listing with location (geohash), difficulty/terrain scores, size, and type. Kind 7516 is a found log recording a successful visit. The spec also covers comment logs (kind 1111 via NIP-22), verified finds with cryptographic proof (kind 7517), and cache retirement.
### Personal Letters (Kind 8211)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md
**App:** https://lief.to
NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, decorative frames, and custom fonts. Letters render as 5:4 landscape postcards. The privacy model is intentionally postcard-like: sender/recipient metadata is visible, content is encrypted.
### Weather Station (Kinds 4223, 16158)
**Author:** Sam Thomson
**Spec:** https://github.com/nostr-protocol/nips/pull/2163
**App:** https://weather.shakespeare.wtf
**Firmware:** https://github.com/samthomson/weather-station
Kind 16158 (replaceable) describes a weather station's configuration: name, geohash location, elevation, power source, connectivity, and sensor inventory. Kind 4223 (regular) carries individual sensor readings as 3-parameter tags `[sensor_type, value, model]`, enabling historical queries and cross-station comparison. Each station has its own keypair.
### Blobbi Virtual Pet (Kinds 31124, 14919, 14920, 14921, 11125)
**Author:** Danifra
**Spec:** https://github.com/Danidfra/nostr-pet/blob/production/NIP.md
**App:** https://nostr-pet.vercel.app
**See also:** [Blobbi tag schema](docs/blobbi/blobbi-tag-schema.md) (Ditto-specific integration details)
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
+1 -1
View File
@@ -13,7 +13,7 @@ Made by [Soapbox](https://soapbox.pub).
## Features
- **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 (Vines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
- **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)
+4 -3
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.1.0"
versionName "2.2.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -35,8 +35,9 @@ android {
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
+2
View File
@@ -10,7 +10,9 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
}
+13 -6
View File
@@ -5,12 +5,19 @@
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Keep Capacitor classes (WebView JS bridge)
-keep class com.getcapacitor.** { *; }
-keep class pub.ditto.app.** { *; }
# Keep WebView JS interfaces
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Keep OkHttp (used by Capacitor)
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
# Uncomment this to preserve the line number information for
# debugging stack traces.
@@ -25,6 +25,8 @@ public class DittoNotificationPlugin extends Plugin {
public void configure(PluginCall call) {
String userPubkey = call.getString("userPubkey");
String relayUrlsRaw = null;
String enabledKindsRaw = null;
String authorsRaw = null;
try {
JSONArray relayUrls = call.getArray("relayUrls");
@@ -35,14 +37,40 @@ public class DittoNotificationPlugin extends Plugin {
Log.w(TAG, "Failed to read relayUrls", e);
}
try {
JSONArray enabledKinds = call.getArray("enabledKinds");
if (enabledKinds != null) {
enabledKindsRaw = enabledKinds.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read enabledKinds", e);
}
try {
JSONArray authors = call.getArray("authors");
if (authors != null) {
authorsRaw = authors.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read authors", e);
}
SharedPreferences prefs = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
if (userPubkey != null && relayUrlsRaw != null) {
prefs.edit()
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw)
.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw);
.putString("relayUrls", relayUrlsRaw);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
if (authorsRaw != null) {
editor.putString("authors", authorsRaw);
} else {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
} else {
// Clear config (user logged out)
prefs.edit().clear().apply();
@@ -265,6 +265,7 @@ public class NostrPoller {
}
return "commented on your post";
}
case 8211: return "sent you a letter";
default: return "mentioned you";
}
}
@@ -243,6 +243,8 @@ public class NotificationRelayService extends Service {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String userPubkey = prefs.getString("userPubkey", null);
String relayUrlsJson = prefs.getString("relayUrls", null);
String enabledKindsJson = prefs.getString("enabledKinds", null);
String authorsJson = prefs.getString("authors", null);
if (userPubkey == null || relayUrlsJson == null) {
Log.d(TAG, "No config, skipping fetch");
@@ -268,10 +270,17 @@ public class NotificationRelayService extends Service {
return;
}
fetch(relayUrls.get(relayIndex), userPubkey);
List<Integer> enabledKinds = parseEnabledKinds(enabledKindsJson);
if (enabledKinds.isEmpty()) {
Log.d(TAG, "No enabled kinds, skipping fetch");
releaseFetchWakeLock();
return;
}
List<String> authors = parseAuthors(authorsJson);
fetch(relayUrls.get(relayIndex), userPubkey, enabledKinds, authors);
}
private void fetch(String relayUrl, String userPubkey) {
private void fetch(String relayUrl, String userPubkey, List<Integer> enabledKinds, List<String> authors) {
long since = poller.getLastSeenTimestamp();
if (since == 0) {
since = (System.currentTimeMillis() / 1000) - 300; // 5 min ago on first run
@@ -284,7 +293,9 @@ public class NotificationRelayService extends Service {
try {
JSONObject filter = new JSONObject();
JSONArray kinds = new JSONArray();
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111);
for (int kind : enabledKinds) {
kinds.put(kind);
}
filter.put("kinds", kinds);
JSONArray pTags = new JSONArray();
pTags.put(userPubkey);
@@ -292,6 +303,15 @@ public class NotificationRelayService extends Service {
filter.put("since", since + 1);
filter.put("limit", FETCH_LIMIT);
// When "only from people I follow" is enabled, restrict to those authors
if (!authors.isEmpty()) {
JSONArray authorsArr = new JSONArray();
for (String author : authors) {
authorsArr.put(author);
}
filter.put("authors", authorsArr);
}
JSONArray req = new JSONArray();
req.put("REQ");
req.put(currentSubId);
@@ -397,7 +417,8 @@ public class NotificationRelayService extends Service {
}
Log.d(TAG, "Retrying in " + backoffMs + "ms on relay " + relayIndex);
Runnable retry = () -> fetch(relayUrls.get(relayIndex), userPubkey);
// Re-read config from prefs on retry so enabled kinds stay current.
Runnable retry = this::runFetchCycle;
handler.postDelayed(retry, backoffMs);
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
@@ -515,6 +536,46 @@ public class NotificationRelayService extends Service {
return urls;
}
/**
* Parse the authors filter from JSON. Returns an empty list when the value
* is null or invalid (meaning no author restriction).
*/
private List<String> parseAuthors(String json) {
List<String> authors = new ArrayList<>();
if (json != null) {
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
authors.add(arr.getString(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse authors", e);
}
}
return authors;
}
/**
* Parse the enabled notification kinds from JSON. Returns an empty list
* when the value is null or invalid — the caller should skip polling
* when the list is empty (the JS layer always provides kinds via
* DittoNotification.configure in the same write as pubkey/relays).
*/
private List<Integer> parseEnabledKinds(String json) {
List<Integer> kinds = new ArrayList<>();
if (json != null) {
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
kinds.add(arr.getInt(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse enabled kinds", e);
}
}
return kinds;
}
private Notification buildForegroundNotification() {
Intent notificationIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(
+6
View File
@@ -5,8 +5,14 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
+255
View File
@@ -0,0 +1,255 @@
# Blobbi Tag Schema
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
## Overview
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
- All valid tags and their purposes
- Which tags are required vs optional
- Which tags persist across stage transitions
- Which tags should be removed during transitions
- Deprecated tags that should be filtered out
---
## Tag Categories
### 1. System / Metadata Tags
Core protocol-level tags required for event identification and ecosystem membership.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
### 2. Core Identity Tags
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
### 3. Visual Trait Tags
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
### 4. Personality / Trait Tags
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
### 5. Stat Tags
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
**Stage Transition Behavior**:
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
### 6. State / Lifecycle Tags
Tags that track the Blobbi's current lifecycle state.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
**State Constraints**:
- `incubating` is only valid for `stage: egg`
- `evolving` is only valid for `stage: baby`
- After hatch/evolve completes, `state` MUST be set to `active`
### 7. Task System Tags
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
### 8. Progression Tags
Long-term progress tracking that persists across all stages.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
### 9. Social / Flag Tags
User preferences and computed flags.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `visible_to_others` | No | egg, baby, adult | Yes | user | `true\|false` | true | Public visibility |
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
### 10. Evolution Tags
Tags specific to adult Blobbis.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
### 11. Extension Tags
Optional tags for themes and crossover features.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
---
## Deprecated Tags
These tags are from legacy versions and MUST be removed when republishing events.
| Tag | Reason | Replaced By |
|-----|--------|-------------|
| `shell_integrity` | Eggs use standard `health` stat | `health` |
| `egg_temperature` | Warmth handled via UI props | N/A |
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
| `egg_status` | Replaced by standard state | `state` |
| `fees` | Removed | N/A |
| `incubation_time` | Uses state_started_at | `state_started_at` |
| `start_incubation` | Uses state_started_at | `state_started_at` |
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
---
## Stage Transition Rules
### Hatch (egg → baby)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``baby`
- `state``active`
- `hunger``100`
- `happiness``100`
- `hygiene``100`
- `energy``100`
- `health` → (inherited from egg after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- All system tags (`d`, `b`, `t`, `client`)
- All identity tags (`name`, `seed`, `generation`)
- All visual tags (colors, pattern, size)
- All personality tags (if present)
- All progression tags (`experience`, `care_streak`)
- All social tags (`visible_to_others`, `breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``adult`
- `state``active`
- All stats → (inherited from baby after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- Same as hatch, plus all stats are inherited (not reset)
**Tags to ADD (optional)**:
- `adult_type` → computed based on care history
---
## Migration Rules
When migrating legacy Blobbis to canonical format:
1. **Always preserve existing values** - Do not regenerate tags that already exist
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
---
## Validation Rules
A valid Blobbi event MUST have:
- `d` tag in canonical format
- `b` tag = `blobbi:ecosystem:v1`
- `t` tag = `blobbi`
- `name` tag (non-empty)
- `seed` tag (64 hex chars)
- `stage` tag (valid value)
- `state` tag (valid value)
- `last_interaction` tag (valid timestamp)
---
## Implementation Checklist
When implementing any flow that modifies Blobbi tags:
- [ ] Start from `canonical.allTags` as the base
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
- [ ] Filter out deprecated tags
- [ ] Update only the tags that need to change
- [ ] Validate required tags are present
+7
View File
@@ -39,6 +39,13 @@ export default tseslint.config(
},
],
"custom/no-placeholder-comments": "error",
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.object.type='MetaProperty'][callee.property.name='glob']",
"message": "import.meta.glob is Vite-only and breaks other bundlers. Inline the assets or use standard imports instead.",
},
],
"no-warning-comments": [
"error",
{ terms: ["fixme"] },
+1 -1
View File
@@ -23,7 +23,7 @@
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.2.8;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.2.8;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+4
View File
@@ -47,5 +47,9 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Ditto needs access to your microphone to record voice messages.</string>
</dict>
</plist>
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<!-- Crash / performance data via Sentry/GlitchTip -->
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCrashData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<!-- Performance / analytics data via Plausible -->
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePerformanceData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<!-- UserDefaults — used by Capacitor/WKWebView for localStorage -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- CA92.1: Access info from same app -->
<string>CA92.1</string>
</array>
</dict>
<!-- File timestamp APIs — used by @capacitor/filesystem -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- C617.1: Access file timestamps inside app container -->
<string>C617.1</string>
</array>
</dict>
<!-- Disk space APIs — used by WKWebView / file operations -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- E174.1: Check available disk space before writing -->
<string>E174.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+4
View File
@@ -13,7 +13,9 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
],
targets: [
@@ -23,7 +25,9 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
]
)
+2261 -358
View File
File diff suppressed because it is too large Load Diff
+29 -20
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.1.0",
"version": "2.2.8",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -11,13 +11,14 @@
"icons": "bash scripts/generate-icons.sh"
},
"engines": {
"npm": "10.9.4",
"node": "22.x"
"node": ">=22"
},
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -43,6 +44,7 @@
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/creepster": "^5.2.7",
"@fontsource/luckiest-guy": "^5.2.8",
"@fontsource/noto-sans-nushu": "^5.2.6",
"@fontsource/pacifico": "^5.2.7",
"@fontsource/permanent-marker": "^5.2.7",
"@fontsource/pirata-one": "^5.2.8",
@@ -52,7 +54,7 @@
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.3.1",
"@nostrify/react": "^0.4.0",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -61,18 +63,18 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
@@ -81,7 +83,7 @@
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.0.10",
@@ -97,15 +99,16 @@
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
"hls.js": "^1.6.15",
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react": "^19.2.4",
"react-blurhash": "^0.3.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.71.1",
"react-intersection-observer": "^9.16.0",
@@ -118,7 +121,7 @@
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uri-templates": "^0.2.0",
"vaul": "^0.9.3",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -129,13 +132,14 @@
"@html-eslint/eslint-plugin": "^0.41.0",
"@html-eslint/parser": "^0.41.0",
"@tailwindcss/typography": "^0.5.15",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.5.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"@webxdc/types": "^2.1.2",
@@ -146,10 +150,15 @@
"globals": "^15.9.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^8.0.0",
"vite": "^8.0.3",
"vitest": "^3.1.4"
},
"overrides": {
"react": "$react",
"react-dom": "$react-dom"
}
}
+166
View File
@@ -1,5 +1,171 @@
# Changelog
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber and NIP-46 users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
- Emoji picker and shortcode autocomplete in zap comment box
- Zap button on badge detail view
- Theme descriptions now display on "updated their theme" posts and detail pages
- Badge thumbnail previews in award notifications
- Letter notifications with envelope card preview
- Kind-specific labels in notification text instead of generic "post"
### Fixed
- Compose modal no longer closes when dismissing emoji picker on mobile
- Compose preview overflow is now scrollable in modal
- Toast notifications swipe up to dismiss on mobile instead of sideways
- File downloads and URL opening work correctly on iOS
- Badges page no longer shows infinite skeleton when logged out
## [2.1.0] - 2026-03-26
### Added
+26 -5
View File
@@ -6,7 +6,7 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Generating Android app icons...${NC}\n"
echo -e "${GREEN}Generating app icons...${NC}\n"
# Check for inkscape (preferred) or rsvg-convert as fallback
if command -v inkscape &> /dev/null; then
@@ -138,12 +138,33 @@ cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
</resources>
EOF
# ── iOS App Icon (1024x1024, white logo on purple background) ──
echo "Generating iOS app icon..."
IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
if [ -d "$IOS_ICON_DIR" ]; then
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
\( "$LOGO_WHITE" -resize "614x614" \) \
-gravity center -compose over -composite \
"$IOS_ICON"
echo -e " ${GREEN}${NC} $IOS_ICON"
else
echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}"
fi
# Cleanup temp files
rm -rf "$TMPDIR"
echo -e "\n${GREEN}Android icons generated successfully!${NC}"
echo -e "\n${GREEN}App icons generated successfully!${NC}"
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
echo -e "Generated:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
echo -e " - ic_launcher.png (legacy square, all densities)"
echo -e " - ic_launcher_round.png (legacy round, all densities)"
echo -e " Android:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
echo -e " - ic_launcher.png (legacy square, all densities)"
echo -e " - ic_launcher_round.png (legacy round, all densities)"
echo -e " iOS:"
echo -e " - AppIcon-512@2x.png (1024x1024)"
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* NIP-46 Client-Initiated Auth Script
*
* Generates an ephemeral client keypair and a `nostrconnect://` URI.
* Import the URI into a remote signer app (e.g. Amber) to authorize
* the client key. Once authorized, the script outputs:
*
* - bunker:// URI (for ZAPSTORE_BUNKER_URL)
* - client secret key hex (for ZAPSTORE_CLIENT_KEY)
*
* It also writes the client key to ~/.config/zsp/bunker-keys/<bunkerPubkey>.key
* so that `zsp` can use it immediately.
*
* Usage:
* node scripts/nip46-auth.mjs [--relay wss://relay.example.com] [--name MyApp] [--timeout 300]
*/
import { NPool, NRelay1, NConnectSigner, NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
import QRCode from 'qrcode';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const result = {
relays: [],
name: 'Ditto',
timeout: 300, // seconds
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--relay':
result.relays.push(args[++i]);
break;
case '--name':
result.name = args[++i];
break;
case '--timeout':
result.timeout = parseInt(args[++i], 10);
break;
case '--help':
case '-h':
console.log(`Usage: node scripts/nip46-auth.mjs [options]
Options:
--relay <url> Relay URL for NIP-46 communication (repeatable)
Default: wss://relay.ditto.pub
--name <name> Application name shown to the signer
Default: Ditto
--timeout <sec> How long to wait for signer approval (seconds)
Default: 300 (5 minutes)
--help, -h Show this help message
`);
process.exit(0);
}
}
if (result.relays.length === 0) {
result.relays.push('wss://relay.ditto.pub');
}
return result;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
// 1. Generate ephemeral client keypair
const clientSecretKey = generateSecretKey();
const clientPubkey = getPublicKey(clientSecretKey);
const clientHex = bytesToHex(clientSecretKey);
console.log('');
console.log('=== NIP-46 Client-Initiated Auth ===');
console.log('');
console.log(`Client pubkey: ${clientPubkey}`);
console.log(`Relay(s): ${opts.relays.join(', ')}`);
console.log(`Timeout: ${opts.timeout}s`);
console.log('');
// 2. Generate random secret
const randomBytes = new Uint8Array(4);
crypto.getRandomValues(randomBytes);
const secret = Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// 3. Build nostrconnect:// URI
const searchParams = new URLSearchParams();
for (const relay of opts.relays) {
searchParams.append('relay', relay);
}
searchParams.set('secret', secret);
searchParams.set('name', opts.name);
const nostrConnectURI = `nostrconnect://${clientPubkey}?${searchParams.toString()}`;
console.log('Scan this QR code with your signer app (e.g. Amber):');
console.log('');
console.log(await QRCode.toString(nostrConnectURI, { type: 'terminal', small: true }));
console.log('Or import this URI manually:');
console.log('');
console.log(` ${nostrConnectURI}`);
console.log('');
console.log('Waiting for signer to approve the connection...');
console.log('');
// 4. Set up relay pool
const pool = new NPool({
open: (url) => new NRelay1(url),
reqRouter: async (filters) => new Map(opts.relays.map((r) => [r, filters])),
eventRouter: async () => opts.relays,
});
const clientSigner = new NSecSigner(clientSecretKey);
const relayGroup = pool.group(opts.relays);
// 5. Subscribe and wait for the signer's response
const signal = AbortSignal.timeout(opts.timeout * 1000);
const sub = relayGroup.req(
[{ kinds: [24133], '#p': [clientPubkey], limit: 1 }],
{ signal },
);
let bunkerPubkey;
let userPubkey;
try {
for await (const msg of sub) {
if (msg[0] === 'CLOSED') {
throw new Error('Relay closed the subscription before signer responded');
}
if (msg[0] === 'EVENT') {
const event = msg[2];
let decrypted;
try {
decrypted = await clientSigner.nip44.decrypt(event.pubkey, event.content);
} catch {
// Could not decrypt -- not for us, skip
continue;
}
let response;
try {
response = JSON.parse(decrypted);
} catch {
continue;
}
if (response.result !== secret && response.result !== 'ack') {
continue;
}
bunkerPubkey = event.pubkey;
console.log(`Signer responded! Bunker pubkey: ${bunkerPubkey}`);
console.log('');
// 6. Get user pubkey via the now-established connection
const signer = new NConnectSigner({
relay: relayGroup,
pubkey: bunkerPubkey,
signer: clientSigner,
timeout: 60_000,
});
console.log('Requesting user public key...');
userPubkey = await signer.getPublicKey();
console.log(`User pubkey: ${userPubkey}`);
console.log('');
break;
}
}
} catch (err) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
console.error(`Timed out after ${opts.timeout}s waiting for signer approval.`);
console.error('Make sure you imported the nostrconnect:// URI into your signer app.');
process.exit(1);
}
throw err;
}
if (!bunkerPubkey || !userPubkey) {
console.error('Failed to establish connection with remote signer.');
process.exit(1);
}
// 7. Build bunker:// URI (for CI)
const bunkerParams = new URLSearchParams();
for (const relay of opts.relays) {
bunkerParams.append('relay', relay);
}
const bunkerURI = `bunker://${bunkerPubkey}?${bunkerParams.toString()}`;
// 8. Write client key to zsp config
const zspDir = path.join(os.homedir(), '.config', 'zsp', 'bunker-keys');
const zspKeyFile = path.join(zspDir, `${bunkerPubkey}.key`);
fs.mkdirSync(zspDir, { recursive: true });
fs.writeFileSync(zspKeyFile, clientHex + '\n', { mode: 0o600 });
// 9. Print results
console.log('=== Connection Established ===');
console.log('');
console.log('Bunker URI (ZAPSTORE_BUNKER_URL):');
console.log(` ${bunkerURI}`);
console.log('');
console.log('Client secret key hex (ZAPSTORE_CLIENT_KEY):');
console.log(` ${clientHex}`);
console.log('');
console.log(`User pubkey: ${userPubkey}`);
console.log(`User npub: ${nip19.npubEncode(userPubkey)}`);
console.log('');
console.log(`zsp client key written to: ${zspKeyFile}`);
console.log('');
console.log('Next steps:');
console.log(' 1. Update ZAPSTORE_BUNKER_URL in GitLab CI/CD variables');
console.log(' 2. Update ZAPSTORE_CLIENT_KEY in GitLab CI/CD variables');
console.log('');
// Clean up
pool.close();
process.exit(0);
}
main().catch((err) => {
console.error('Fatal error:', err.message);
process.exit(1);
});
+18 -11
View File
@@ -18,13 +18,15 @@ import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
const dmConfig: DMConfig = {
enabled: true,
enabled: false,
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
@@ -57,7 +59,9 @@ const hardcodedConfig: AppConfig = {
},
feedSettings: {
feedIncludePosts: true,
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeArticles: true,
showArticles: true,
showEvents: true,
@@ -116,11 +120,10 @@ const hardcodedConfig: AppConfig = {
"feed",
"notifications",
"search",
"bookmarks",
"profile",
"photos",
"videos",
"themes",
"letters",
"badges",
"blobbi",
"theme",
"settings",
"help",
@@ -156,6 +159,8 @@ const defaultConfig: AppConfig = {
};
export function App() {
useNsecPasteGuard();
useEffect(() => {
// Initialize StatusBar for mobile apps
if (Capacitor.isNativePlatform()) {
@@ -180,12 +185,14 @@ export function App() {
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
<EmotionDevProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
</NWCProvider>
</NostrProvider>
+75 -47
View File
@@ -1,62 +1,77 @@
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { ReplyComposeModal } from "@/components/ReplyComposeModal";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { MainLayout } from "./components/MainLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { getExtraKindDef } from "./lib/extraKinds";
import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage";
import { AIChatPage } from "./pages/AIChatPage";
import { BadgesPage } from "./pages/BadgesPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import { BooksPage } from "./pages/BooksPage";
import { ContentPage } from "./pages/ContentPage";
import { ContentSettingsPage } from "./pages/ContentSettingsPage";
import { DomainFeedPage } from "./pages/DomainFeedPage";
import { EventsFeedPage } from "./pages/EventsFeedPage";
import { ExternalContentPage } from "./pages/ExternalContentPage";
import { GeotagPage } from "./pages/GeotagPage";
import { HashtagPage } from "./pages/HashtagPage";
import { HelpPage } from "./pages/HelpPage";
import { HomePage } from "./pages/HomePage";
// Critical-path pages: eagerly loaded (landing + fallback)
import Index from "./pages/Index";
import { KindFeedPage } from "./pages/KindFeedPage";
import { MagicSettingsPage } from "./pages/MagicSettingsPage";
import { MusicFeedPage } from "./pages/MusicFeedPage";
import { NetworkSettingsPage } from "./pages/NetworkSettingsPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
import { NotificationSettings } from "./pages/NotificationSettings";
import { NotificationsPage } from "./pages/NotificationsPage";
import { PhotosFeedPage } from "./pages/PhotosFeedPage";
import { PodcastsFeedPage } from "./pages/PodcastsFeedPage";
import { CSAEPolicyPage } from "./pages/CSAEPolicyPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { ProfileSettings } from "./pages/ProfileSettings";
import { RelayPage } from "./pages/RelayPage";
import { SearchPage } from "./pages/SearchPage";
import { SettingsPage } from "./pages/SettingsPage";
import { ThemesPage } from "./pages/ThemesPage";
import { TreasuresPage } from "./pages/TreasuresPage";
import { TrendsPage } from "./pages/TrendsPage";
import { UserListsPage } from "./pages/UserListsPage";
import { VideosFeedPage } from "./pages/VideosFeedPage";
import { VinesFeedPage } from "./pages/VinesFeedPage";
import { WalletSettingsPage } from "./pages/WalletSettingsPage";
import { WebxdcFeedPage } from "./pages/WebxdcFeedPage";
import { WorldPage } from "./pages/WorldPage";
import { ArchivePage } from "./pages/ArchivePage";
import { BlueskyPage } from "./pages/BlueskyPage";
import { ChangelogPage } from "./pages/ChangelogPage";
import { WikipediaPage } from "./pages/WikipediaPage";
import { LettersPage } from "./pages/LettersPage";
import { LetterPreferencesPage } from "./pages/LetterPreferencesPage";
// Lazy-loaded companion layer (~450K code-split)
const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => ({ default: m.BlobbiCompanionLayer })));
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
// All other pages: code-split via React.lazy
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
const MusicFeedPage = lazy(() => import("./pages/MusicFeedPage").then(m => ({ default: m.MusicFeedPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
const colorsDef = getExtraKindDef("colors")!;
@@ -77,7 +92,11 @@ function PollsFeedPage() {
icon={sidebarItemIcon("polls", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
{composeOpen && (
<Suspense fallback={null}>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
</Suspense>
)}
</>
);
}
@@ -98,6 +117,11 @@ export function AppRouter() {
<AudioNavigationGuard />
<DeepLinkHandler />
<ScrollToTop />
<BlobbiActionsProvider>
<Suspense fallback={null}>
<BlobbiCompanionLayer />
</Suspense>
</BlobbiActionsProvider>
<Routes>
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
@@ -207,6 +231,7 @@ export function AppRouter() {
<Route path="/themes" element={<ThemesPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/blobbi" element={<BlobbiPage />} />
<Route path="/world" element={<WorldPage />} />
<Route path="/badges" element={<BadgesPage />} />
<Route path="/books" element={<BooksPage />} />
@@ -214,6 +239,7 @@ export function AppRouter() {
<Route path="/bluesky" element={<BlueskyPage />} />
<Route path="/wikipedia" element={<WikipediaPage />} />
<Route path="/letters" element={<LettersPage />} />
<Route path="/letters/compose" element={<LetterComposePage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
@@ -226,6 +252,8 @@ export function AppRouter() {
/>
<Route path="/i/*" element={<ExternalContentPage />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
@@ -0,0 +1,614 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/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 confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
}
export function BlobbiActionInventoryModal({
open,
onOpenChange,
action,
companion,
profile,
onUseItem,
onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// State for confirmation dialog
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Filter inventory by action type, respecting egg-compatible effects
const availableItems = useMemo(() => {
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
const stageMessage = getStageRestrictionMessage(companion, action);
const isEmpty = availableItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowConfirmDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset after starting use
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseConfirmDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const handleOpenShop = () => {
onOpenChange(false);
onOpenShop();
};
// Quantity controls
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
{actionMeta.icon}
</div>
<div className="min-w-0">
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
{actionMeta.description}
</p>
</div>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
{/* Stage Restriction Message */}
{!canUse && stageMessage && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
<span className="text-3xl">🥚</span>
</div>
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{stageMessage}
</p>
</div>
)}
{/* Empty State */}
{canUse && isEmpty && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-4">
You don't have any items for this action. Visit the shop to get some!
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
{/* Item List */}
{canUse && !isEmpty && (
<div className="grid gap-3">
{availableItems.map((item) => (
<BlobbiInventoryUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleSelectItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
</div>
)}
</div>
</DialogContent>
{/* Confirmation Dialog with Quantity Selector */}
{selectedItem && (
<BlobbiUseItemConfirmDialog
open={showConfirmDialog}
onOpenChange={handleCloseConfirmDialog}
item={selectedItem}
companion={companion}
action={action}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
)}
</Dialog>
);
}
// ─── Inventory Use Row ────────────────────────────────────────────────────────
interface BlobbiInventoryUseRowProps {
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
onUse: () => void;
isUsing: boolean;
disabled: boolean;
}
function BlobbiInventoryUseRow({
item,
companion,
action,
onUse,
isUsing,
disabled,
}: BlobbiInventoryUseRowProps) {
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
// For eggs using medicine, show health preview
// Eggs use the 3-stat model: health, hygiene, happiness
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
item.effect
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
};
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
{/* Top row on mobile: Icon + Info + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className="relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl">
{item.icon}
</div>
</div>
{/* Item Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs shrink-0">
x{item.quantity}
</Badge>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
</div>
{/* Use Button */}
<Button
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
</div>
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
);
}
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
interface BlobbiUseItemConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function BlobbiUseItemConfirmDialog({
open,
onOpenChange,
item,
companion,
action,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: BlobbiUseItemConfirmDialogProps) {
const actionMeta = ACTION_METADATA[action];
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes for the selected quantity
const statPreview = useMemo(() => {
if (!item.effect) return { normalChanges: [], eggChanges: [] };
if (isEgg && isMedicine) {
// Calculate health change for N items
const healthDelta = item.effect.health ?? 0;
let currentHealth = companion.stats.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
}
const totalDelta = currentHealth - (companion.stats.health ?? 0);
return {
normalChanges: [],
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
};
}
if (isEgg && isClean) {
// Calculate hygiene and happiness changes for N items
const hygieneDelta = item.effect.hygiene ?? 0;
const happinessDelta = item.effect.happiness ?? 0;
let currentHygiene = companion.stats.hygiene ?? 0;
let currentHappiness = companion.stats.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
}
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
return { normalChanges: [], eggChanges: changes };
}
// Normal stats preview - simulate N applications
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const changes: Array<{ stat: string; delta: number }> = [];
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
changes.push({ stat, delta });
}
}
return { normalChanges: changes, eggChanges: [] };
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>{actionMeta.label}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{hasChanges && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<div className="flex flex-wrap gap-2">
{statPreview.normalChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
{statPreview.eggChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,201 @@
// src/blobbi/actions/components/BlobbiActionsModal.tsx
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/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>
);
}
@@ -0,0 +1,447 @@
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
/**
* Missions modal for Blobbi.
*
* Shows:
* - Daily missions (always visible, separate reward system)
* - Incubation tasks when the current Blobbi is incubating (egg stage)
* - Evolve tasks when evolving (baby stage)
*/
import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
import { formatCompactNumber, cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useState } from 'react';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { NostrEvent } from '@nostrify/nostrify';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
import { TasksPanel } from './TasksPanel';
import { DailyMissionsPanel } from './DailyMissionsPanel';
import { useDailyMissions } from '../hooks/useDailyMissions';
import { useClaimMissionReward } from '../hooks/useClaimMissionReward';
import { useRerollMission } from '../hooks/useRerollMission';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Current companion being viewed */
companion: BlobbiCompanion;
/** Current Blobbonaut profile (required for coin updates) */
profile: BlobbonautProfile | null;
/** Callback to update profile in query cache after claiming */
updateProfileEvent: (event: NostrEvent) => void;
/** Hatch tasks result from useHatchTasks */
hatchTasks: HatchTasksResult;
/** Evolve tasks result from useEvolveTasks */
evolveTasks: EvolveTasksResult;
/** Called when user clicks "Create Post" action in tasks */
onOpenPostModal: () => void;
/** Called when all hatch tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching: boolean;
/** Called when all evolve tasks are complete and user clicks "Evolve" */
onEvolve: () => void;
/** Whether evolving is in progress */
isEvolving: boolean;
/** Called when user confirms stopping incubation */
onStopIncubation: () => Promise<void>;
/** Whether stop incubation is in progress */
isStoppingIncubation: boolean;
/** Called when user confirms stopping evolution */
onStopEvolution: () => Promise<void>;
/** Whether stop evolution is in progress */
isStoppingEvolution: boolean;
/** Available Blobbi stages across all user's companions (for mission filtering) */
availableStages?: ('egg' | 'baby' | 'adult')[];
}
// ─── Daily Missions Section ───────────────────────────────────────────────────
interface DailyMissionsSectionProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
/** Available Blobbi stages the user has */
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayClaimedReward,
totalPotentialReward,
bonusAvailable,
bonusClaimed,
bonusReward,
noMissionsAvailable,
rerollsRemaining,
} = useDailyMissions({ availableStages });
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
profile,
updateProfileEvent
);
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const handleClaimReward = (missionId: string) => {
claimReward({ missionId });
};
const handleRerollMission = (missionId: string) => {
rerollMission({ missionId, availableStages });
};
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-sm">Daily Missions</h3>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0" />
<span className="whitespace-nowrap">
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
</span>
</div>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
</div>
</div>
</CollapsibleTrigger>
{/* Mission list */}
<CollapsibleContent className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={handleClaimReward}
onRerollMission={handleRerollMission}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</CollapsibleContent>
</Collapsible>
);
}
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
interface StopConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companionName: string;
processType: 'incubation' | 'evolution';
onConfirm: () => Promise<void>;
isPending: boolean;
}
function StopConfirmationDialog({
open,
onOpenChange,
companionName,
processType,
onConfirm,
isPending,
}: StopConfirmationDialogProps) {
const handleConfirm = async () => {
await onConfirm();
onOpenChange(false);
};
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Stop {label}?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
<strong>{companionName}</strong>?
</p>
<p>
This will interrupt the {processType} process and clear all task progress.
You can restart {processType} later, but you'll need to complete the tasks again.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending}
className="bg-destructive hover:bg-destructive/90"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
`Stop ${label}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
interface ProcessContentProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
onOpenPostModal: () => void;
onComplete: () => void;
isCompleting: boolean;
onStop: () => Promise<void>;
isStopping: boolean;
defaultOpen?: boolean;
}
function ProcessContent({
companion,
tasks,
processType,
onOpenPostModal,
onComplete,
isCompleting,
onStop,
isStopping,
defaultOpen = true,
}: ProcessContentProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const isIncubation = processType === 'incubation';
const emoji = isIncubation ? '🥚' : '🐣';
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
const description = isIncubation
? 'Complete these tasks to hatch your Blobbi'
: 'Complete these tasks to evolve your Blobbi';
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
const completeEmoji = isIncubation ? '🐣' : '';
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
const completedCount = tasks.tasks.filter(t => t.completed).length;
const totalTasks = tasks.tasks.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
<div className="flex items-center gap-2">
<span className="text-lg">{emoji}</span>
<h3 className="font-semibold text-sm">{title}</h3>
</div>
<div className="flex items-center gap-2">
<span className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full",
tasks.allCompleted
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{completedCount}/{totalTasks}
</span>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
</div>
</div>
</CollapsibleTrigger>
{/* Tasks content */}
<CollapsibleContent className="pt-3">
{/* Tasks Panel */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onOpenPostModal={onOpenPostModal}
onComplete={onComplete}
isCompleting={isCompleting}
emoji={emoji}
title={title}
description={description}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
/>
{/* Stop Process Button */}
<div className="mt-6 pt-4 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
{isStopping ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-4 mr-2" />
{stopLabel}
</>
)}
</Button>
</div>
</CollapsibleContent>
{/* Stop Confirmation Dialog */}
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
companionName={companion.name}
processType={processType}
onConfirm={onStop}
isPending={isStopping}
/>
</Collapsible>
);
}
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function BlobbiMissionsModal({
open,
onOpenChange,
companion,
profile,
updateProfileEvent,
hatchTasks,
evolveTasks,
onOpenPostModal,
onHatch,
isHatching,
onEvolve,
isEvolving,
onStopIncubation,
isStoppingIncubation,
onStopEvolution,
isStoppingEvolution,
availableStages,
}: BlobbiMissionsModalProps) {
const isIncubating = companion.state === 'incubating';
const isEvolvingState = companion.state === 'evolving';
const isEgg = companion.stage === 'egg';
const isBaby = companion.stage === 'baby';
// Check if there's an active hatch/evolve process
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle className="flex items-center gap-2">
<Target className="size-5 shrink-0" />
Missions
</DialogTitle>
<DialogDescription className="break-words">
Complete missions to earn rewards for {companion.name}
</DialogDescription>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-6 py-3 sm:py-4 space-y-4">
{/* Daily Missions Section - Always visible, expanded by default */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
defaultOpen={true}
/>
{/* Hatch/Evolve Process Section - Only when active, expanded by default */}
{hasActiveProcess && (
<>
{isIncubating && isEgg ? (
<ProcessContent
companion={companion}
tasks={hatchTasks}
processType="incubation"
onOpenPostModal={onOpenPostModal}
onComplete={onHatch}
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
defaultOpen={true}
/>
) : isEvolvingState && isBaby ? (
<ProcessContent
companion={companion}
tasks={evolveTasks}
processType="evolution"
onOpenPostModal={onOpenPostModal}
onComplete={onEvolve}
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
defaultOpen={true}
/>
) : null}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,323 @@
// src/blobbi/actions/components/BlobbiPostModal.tsx
/**
* Modal for creating a Blobbi post (hatch or evolve).
*
* Requirements:
* - Prefilled with stage-aware text:
* - Hatch: "Hello Nostr! Posting to hatch #<blobbiName> #blobbi #ditto #nostr"
* - Evolve: "Hello Nostr! Posting to evolve #<blobbiName> #blobbi #ditto #nostr"
* - User can ADD text but CANNOT delete the prefix or required hashtags
* - Blobbi name is sanitized into a valid hashtag format
* - Enforced programmatically
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { X, Loader2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
BLOBBI_POST_REQUIRED_HASHTAGS,
} from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The process type for the post */
export type BlobbiPostProcess = 'hatch' | 'evolve';
interface BlobbiPostModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's name (will be converted to hashtag) */
blobbiName: string;
/** The process type - 'hatch' for incubation, 'evolve' for evolution */
process?: BlobbiPostProcess;
onSuccess?: () => void;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Sanitize a name into a valid hashtag format.
* - Removes special characters
* - Replaces spaces with nothing (camelCase-like)
* - Ensures lowercase
* - Handles edge cases
*/
function sanitizeToHashtag(name: string): string {
return name
.toLowerCase()
// Remove emojis and special characters, keep letters, numbers, underscores
.replace(/[^\p{L}\p{N}_]/gu, '')
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
.replace(/^(\d)/, 'blobbi$1')
// Limit length
.slice(0, 30)
// Fallback if empty
|| 'myblobbi';
}
/**
* Build the required prefix text based on process type.
*/
function buildPrefix(process: BlobbiPostProcess): string {
return process === 'evolve'
? 'Hello Nostr! Posting to evolve'
: 'Hello Nostr! Posting to hatch';
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function BlobbiPostModal({
open,
onOpenChange,
blobbiName,
process = 'hatch',
onSuccess,
}: BlobbiPostModalProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending } = useNostrPublish();
// Compute the required elements based on props
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
const prefix = useMemo(() => buildPrefix(process), [process]);
// All required hashtags including the Blobbi name (first)
const allRequiredHashtags = useMemo(() =>
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
[blobbiHashtag]
);
// Build default content
const defaultContent = useMemo(() =>
`${prefix} #${allRequiredHashtags.join(' #')}`,
[prefix, allRequiredHashtags]
);
const [content, setContent] = useState(defaultContent);
const [validationError, setValidationError] = useState<string | null>(null);
// Reset content when modal opens or props change
useEffect(() => {
if (open) {
setContent(defaultContent);
setValidationError(null);
}
}, [open, defaultContent]);
/**
* Validate that the content still contains the required prefix and hashtags.
*/
const validateContent = useCallback((text: string): string | null => {
// Check prefix
if (!text.startsWith(prefix)) {
return 'The post must start with the required text';
}
// Check all required hashtags are present (including Blobbi name)
const lowerText = text.toLowerCase();
for (const tag of allRequiredHashtags) {
if (!lowerText.includes(`#${tag.toLowerCase()}`)) {
return `Missing required hashtag: #${tag}`;
}
}
return null;
}, [prefix, allRequiredHashtags]);
/**
* Handle content change with validation.
* Prevents deletion of required content.
*/
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value;
// Allow content changes only if it preserves the required elements
const error = validateContent(newContent);
if (error) {
setValidationError(error);
// Still update content but show error
// This allows the user to see what they're trying to do
// but the post button will be disabled
} else {
setValidationError(null);
}
setContent(newContent);
}, [validateContent]);
/**
* Handle post creation.
*/
const handlePost = useCallback(async () => {
if (!user?.pubkey) {
toast({
title: 'Not logged in',
description: 'Please log in to create a post',
variant: 'destructive',
});
return;
}
// Final validation
const error = validateContent(content);
if (error) {
setValidationError(error);
return;
}
try {
// Build tags for the post
const tags: string[][] = [];
// Add all required hashtags as 't' tags
for (const hashtag of allRequiredHashtags) {
tags.push(['t', hashtag.toLowerCase()]);
}
// Extract any additional hashtags the user added
const additionalHashtags = content.match(/#(\w+)/g) || [];
const requiredLower = allRequiredHashtags.map(t => t.toLowerCase());
for (const tag of additionalHashtags) {
const tagValue = tag.slice(1).toLowerCase();
if (!requiredLower.includes(tagValue)) {
tags.push(['t', tagValue]);
}
}
await createEvent({
kind: 1,
content,
tags,
});
toast({
title: 'Post created!',
description: process === 'evolve'
? 'Your Blobbi evolution post has been published.'
: 'Your Blobbi hatch post has been published.',
});
onOpenChange(false);
onSuccess?.();
} catch (error) {
toast({
title: 'Failed to create post',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
const canPost = !validationError && content.trim().length > 0;
const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post';
const alertText = process === 'evolve'
? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post."
: "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post.";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0">
{/* Header */}
<div className="flex items-center justify-between px-4 h-14 border-b">
<DialogTitle className="text-base font-semibold">
{dialogTitle}
</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Info alert */}
<Alert className="border-primary/20 bg-primary/5">
<AlertDescription className="text-sm">
{alertText}
</AlertDescription>
</Alert>
{/* Textarea */}
<div className="space-y-2">
<Textarea
value={content}
onChange={handleContentChange}
placeholder="Write your post..."
className="min-h-[150px] resize-none"
disabled={isPending}
/>
{/* Character count and validation */}
<div className="flex items-center justify-between text-sm">
<div>
{validationError && (
<span className="text-destructive flex items-center gap-1">
<AlertCircle className="size-3.5" />
{validationError}
</span>
)}
</div>
<span className="text-muted-foreground">
{content.length} characters
</span>
</div>
</div>
{/* Preview of required content */}
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
<p className="text-xs text-muted-foreground mb-1">Required content:</p>
<p className="text-sm font-medium">
<span className="text-primary">{prefix}</span>
{' '}
{allRequiredHashtags.map(tag => (
<span key={tag} className="text-blue-500">#{tag} </span>
))}
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/30">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handlePost}
disabled={!canPost || isPending}
className="min-w-24"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Posting...
</>
) : (
'Post'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,370 @@
/**
* DailyMissionsPanel - UI component for displaying daily missions
*
* Shows:
* - Daily mission list with progress bars
* - Completion state
* - Claim buttons for completed missions
* - Coin rewards
* - Bonus mission after completing all regular missions
* - Empty state when no missions available (egg-only users)
* - Reroll button to replace missions (max 3/day)
*/
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMission } from '../lib/daily-missions';
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
/** The daily missions to display */
missions: DailyMission[];
/** Callback when claiming a mission reward */
onClaimReward: (missionId: string) => void;
/** Callback when rerolling a mission */
onRerollMission?: (missionId: string) => void;
/** Total coins earned today */
todayCoins: number;
/** Whether claiming is disabled (e.g., during another operation) */
disabled?: boolean;
/** Whether the bonus mission is available */
bonusAvailable?: boolean;
/** Whether the bonus mission has been claimed */
bonusClaimed?: boolean;
/** Bonus mission reward amount */
bonusReward?: number;
/** Whether user has no eligible missions (e.g., only eggs) */
noMissionsAvailable?: boolean;
/** Number of rerolls remaining today */
rerollsRemaining?: number;
/** Whether a reroll is currently in progress */
isRerolling?: boolean;
}
// ─── Mission Item ─────────────────────────────────────────────────────────────
interface MissionItemProps {
mission: DailyMission;
onClaim: () => void;
onReroll?: () => void;
disabled?: boolean;
canReroll?: boolean;
isRerolling?: boolean;
}
function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) {
const progressPercent = (mission.currentCount / mission.requiredCount) * 100;
const canClaim = mission.completed && !mission.claimed;
// Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining
const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll;
return (
<div
className={cn(
'relative p-3 sm:p-4 rounded-lg border transition-colors overflow-hidden',
mission.claimed
? 'bg-primary/5 border-primary/20'
: mission.completed
? 'bg-green-500/5 border-green-500/30'
: 'bg-card border-border'
)}
>
{/* Top right area: Claimed badge OR Reroll button */}
<div className="absolute top-2 right-2">
{mission.claimed ? (
<div className="flex items-center gap-1 text-xs text-primary font-medium">
<Check className="size-3" />
Claimed
</div>
) : showRerollButton ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={onReroll}
disabled={disabled || isRerolling}
>
<RefreshCw className={cn("size-3.5", isRerolling && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace this mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
{/* Mission content */}
<div className="space-y-2 sm:space-y-3">
{/* Title and description */}
<div className="pr-14 sm:pr-16">
<h4 className="font-medium text-sm break-words">{mission.title}</h4>
<p className="text-xs text-muted-foreground mt-0.5 break-words">
{mission.description}
</p>
</div>
{/* Progress bar */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs gap-2">
<span className="text-muted-foreground whitespace-nowrap">
{mission.currentCount} / {mission.requiredCount}
</span>
<span className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400 whitespace-nowrap">
<Coins className="size-3 shrink-0" />
{formatCompactNumber(mission.reward)}
</span>
</div>
<Progress
value={progressPercent}
className={cn(
'h-2',
mission.completed && '[&>div]:bg-green-500'
)}
/>
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-green-600 hover:bg-green-700 text-white"
>
<Gift className="size-4 mr-2 shrink-0" />
<span className="truncate">Claim {formatCompactNumber(mission.reward)} Coins</span>
</Button>
)}
</div>
</div>
);
}
// ─── Bonus Mission Item ───────────────────────────────────────────────────────
interface BonusMissionItemProps {
isAvailable: boolean;
isClaimed: boolean;
reward: number;
onClaim: () => void;
disabled?: boolean;
}
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
return (
<div
className={cn(
'relative p-3 sm:p-4 rounded-lg border-2 transition-colors overflow-hidden',
isClaimed
? 'bg-amber-500/10 border-amber-500/30'
: isAvailable
? 'bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-500/40 animate-pulse'
: 'bg-muted/30 border-dashed border-muted-foreground/20'
)}
>
{/* Claimed badge */}
{isClaimed && (
<div className="absolute top-2 right-2">
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
<Check className="size-3" />
Claimed
</div>
</div>
)}
{/* Mission content */}
<div className="space-y-2 sm:space-y-3">
{/* Title and description */}
<div className={cn("pr-14 sm:pr-16", !isAvailable && !isClaimed && "opacity-50")}>
<div className="flex items-center gap-2">
<Trophy className={cn(
"size-4 shrink-0",
isClaimed
? "text-amber-600 dark:text-amber-400"
: isAvailable
? "text-amber-500"
: "text-muted-foreground"
)} />
<h4 className="font-medium text-sm">Daily Champion</h4>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions above to unlock this bonus'}
</p>
</div>
{/* Reward display */}
<div className="flex items-center justify-between text-xs gap-2">
<span className={cn(
"text-muted-foreground",
!isAvailable && !isClaimed && "opacity-50"
)}>
Bonus Reward
</span>
<span className={cn(
"flex items-center gap-1 font-medium",
isClaimed || isAvailable
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground"
)}>
<Coins className="size-3 shrink-0" />
+{formatCompactNumber(reward)}
</span>
</div>
{/* Claim button */}
{isAvailable && !isClaimed && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
<Trophy className="size-4 mr-2 shrink-0" />
<span className="truncate">Claim Bonus {formatCompactNumber(reward)} Coins!</span>
</Button>
)}
</div>
</div>
);
}
// ─── No Missions Available State ──────────────────────────────────────────────
function NoMissionsState() {
return (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Egg className="size-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm">Hatch Your Blobbi First</h4>
<p className="text-xs text-muted-foreground">
Daily missions will be available once you have
<br />
a hatched Blobbi to interact with!
</p>
</div>
</div>
);
}
// ─── All Claimed State ────────────────────────────────────────────────────────
interface AllClaimedStateProps {
todayCoins: number;
}
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
return (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-6 text-primary" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm">All Done for Today!</h4>
<p className="text-xs text-muted-foreground">
You earned <span className="font-medium text-amber-600 dark:text-amber-400">{formatCompactNumber(todayCoins)} coins</span> today.
<br />
Come back tomorrow for new missions!
</p>
</div>
</div>
);
}
// ─── Reroll Counter ───────────────────────────────────────────────────────────
interface RerollCounterProps {
remaining: number;
}
function RerollCounter({ remaining }: RerollCounterProps) {
const text = remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
return (
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
<RefreshCw className="size-3" />
<span>{text}</span>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function DailyMissionsPanel({
missions,
onClaimReward,
onRerollMission,
todayCoins,
disabled,
bonusAvailable = false,
bonusClaimed = false,
bonusReward = 50,
noMissionsAvailable = false,
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
// Show empty state if user has no eligible missions (e.g., only eggs)
if (noMissionsAvailable) {
return <NoMissionsState />;
}
const allRegularClaimed = missions.every((m) => m.claimed);
const allDone = allRegularClaimed && bonusClaimed;
// Show "all done" state only when everything including bonus is claimed
if (allDone) {
return <AllClaimedState todayCoins={todayCoins} />;
}
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
return (
<div className="space-y-3">
{/* Reroll counter - only show if reroll functionality is available */}
{onRerollMission && (
<RerollCounter remaining={rerollsRemaining} />
)}
{/* Regular missions */}
{missions.map((mission) => (
<MissionItem
key={mission.id}
mission={mission}
onClaim={() => onClaimReward(mission.id)}
onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined}
disabled={disabled}
canReroll={canReroll}
isRerolling={isRerolling}
/>
))}
{/* Bonus mission - always visible */}
<BonusMissionItem
isAvailable={bonusAvailable}
isClaimed={bonusClaimed}
reward={bonusReward}
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
disabled={disabled}
/>
</div>
);
}
@@ -0,0 +1,221 @@
// src/blobbi/actions/components/HatchTasksPanel.tsx
/**
* UI component for displaying hatch task progress.
* Shows a list of tasks with progress indicators and action buttons.
*/
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { HatchTask } from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HatchTasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching?: boolean;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
onOpenPostModal();
}
break;
}
};
const progress = task.required > 1
? Math.round((task.current / task.required) * 100)
: task.completed ? 100 : 0;
return (
<div
className={cn(
"flex items-center gap-4 p-4 rounded-xl border transition-all",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Status indicator */}
<div className={cn(
"size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-5" />
) : task.required > 1 ? (
<span className="text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className={cn(
"font-medium",
task.completed && "text-emerald-600 dark:text-emerald-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs">
Complete
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{task.description}
</p>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !task.completed && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
</div>
{/* Action button */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2"
>
{task.actionLabel}
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
</Button>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HatchTasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onHatch,
isHatching = false,
}: HatchTasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = Math.round((completedCount / totalTasks) * 100);
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🥚</span>
Hatch Tasks
</CardTitle>
<CardDescription>
Complete these tasks to hatch your Blobbi
</CardDescription>
</div>
<Badge variant="outline" className="text-base px-3 py-1">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Overall progress</span>
<span className="font-medium">{overallProgress}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{tasks.map(task => (
<TaskRow
key={task.id}
task={task}
onOpenPostModal={onOpenPostModal}
/>
))}
{/* Hatch button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<Button
onClick={onHatch}
disabled={isHatching}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
{isHatching ? (
<>
<Loader2 className="size-5 animate-spin" />
Hatching...
</>
) : (
<>
<span className="text-xl">🐣</span>
Hatch Your Blobbi!
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,251 @@
// src/blobbi/actions/components/InlineMusicPlayer.tsx
import { useCallback, useEffect } from 'react';
import { Music, Play, Pause, RotateCcw, MoreHorizontal, Loader2, AlertCircle, X, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import type { SelectedTrack } from './PlayMusicModal';
// Re-export for external use
export type { SelectedTrack } from './PlayMusicModal';
interface InlineMusicPlayerProps {
/** The selected track */
selection: SelectedTrack;
/** Called when user wants to change the track */
onChangeTrack: () => void;
/** Called when user closes the player */
onClose: () => void;
/** Called when playback starts (for Blobbi reaction state) */
onPlaybackStart?: () => void;
/** Called when playback stops/pauses (for Blobbi reaction state) */
onPlaybackStop?: () => void;
/** Whether the action has been published (playback only starts after publish) */
isPublished: boolean;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineMusicPlayer({
selection,
onChangeTrack,
onClose,
onPlaybackStart,
onPlaybackStop,
isPublished,
isPublishing,
}: InlineMusicPlayerProps) {
const {
state: playbackState,
error: playbackError,
load,
toggle,
restart,
stop,
isPlaying,
volume,
setVolume,
cleanup,
} = useAudioPlayback({
onEnded: () => {
onPlaybackStop?.();
},
});
// Auto-start playback when first published (idle -> playing)
// Note: 'stopped' state is NOT included here - stop is a terminal state
// that requires explicit user action (play button) to restart
useEffect(() => {
if (isPublished && playbackState === 'idle') {
load(selection.url, true);
onPlaybackStart?.();
}
}, [isPublished, playbackState, selection.url, load, onPlaybackStart]);
// Force reload when source URL changes while already playing/paused
useEffect(() => {
// Only trigger reload if we're in an active playback state with a different URL
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
// The load function will check if URL changed and reload if needed
load(selection.url, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to selection.url changes
}, [selection.url]);
// Notify on playback state changes
useEffect(() => {
if (isPlaying) {
onPlaybackStart?.();
} else if (playbackState === 'paused' || playbackState === 'stopped') {
onPlaybackStop?.();
}
}, [isPlaying, playbackState, onPlaybackStart, onPlaybackStop]);
// Cleanup on close
const handleClose = useCallback(() => {
stop();
cleanup();
onPlaybackStop?.();
onClose();
}, [stop, cleanup, onPlaybackStop, onClose]);
// Handle play/pause toggle
const handleToggle = useCallback(async () => {
if (playbackState === 'idle' || playbackState === 'stopped') {
load(selection.url, true);
} else {
await toggle();
}
}, [playbackState, selection.url, load, toggle]);
// Track info
const trackTitle = selection.track.title;
const trackArtist = selection.track.artist;
const isLoading = playbackState === 'loading' || isPublishing;
const hasError = playbackState === 'error';
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isPlaying && "ring-2 ring-pink-500/30"
)}>
{/* Main content row */}
<div className="flex items-center gap-3 p-3">
{/* Music icon / Now Playing indicator */}
<div className={cn(
"size-10 rounded-lg flex items-center justify-center shrink-0",
isPlaying
? "bg-pink-500/20"
: "bg-muted"
)}>
<Music className={cn(
"size-5",
isPlaying ? "text-pink-500 animate-pulse" : "text-muted-foreground"
)} />
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{trackTitle}</p>
{trackArtist && (
<p className="text-xs text-muted-foreground truncate">{trackArtist}</p>
)}
{!trackArtist && (
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : isPublishing ? 'Starting...' : 'Ready to play'}
</p>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink-0">
{/* Play/Pause button */}
<Button
size="icon"
variant="ghost"
onClick={handleToggle}
disabled={isLoading || !isPublished}
className="size-9 rounded-full"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
{/* Restart button - only show when actively playing or paused */}
{isPublished && (playbackState === 'playing' || playbackState === 'paused') && (
<Button
size="icon"
variant="ghost"
onClick={() => {
restart();
}}
className="size-9 rounded-full"
title="Restart from beginning"
>
<RotateCcw className="size-3.5" />
</Button>
)}
{/* Volume control */}
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-9 rounded-full"
title={volume === 0 ? 'Unmute' : 'Volume'}
>
{volume === 0 ? (
<VolumeX className="size-4" />
) : (
<Volume2 className="size-4" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="center"
className="w-32 p-3"
>
<Slider
value={[volume * 100]}
onValueChange={([val]) => setVolume(val / 100)}
max={100}
step={1}
className="w-full"
/>
</PopoverContent>
</Popover>
{/* Change track button */}
<Button
size="icon"
variant="ghost"
onClick={onChangeTrack}
disabled={isPublishing}
className="size-9 rounded-full"
>
<MoreHorizontal className="size-4" />
</Button>
{/* Close button */}
<Button
size="icon"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="size-9 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
</div>
</div>
{/* Error message */}
{hasError && playbackError && (
<div className="px-3 pb-3">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{playbackError.message}</p>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,487 @@
// src/blobbi/actions/components/InlineSingCard.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import {
Mic,
Play,
Pause,
Square,
FileText,
Check,
X,
Loader2,
AlertCircle,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'error';
interface InlineSingCardProps {
/** Called when user confirms the singing action (publish the action) */
onConfirm: () => Promise<void>;
/** Called when user closes the sing card */
onClose: () => void;
/** Called when recording starts (for Blobbi reaction) */
onRecordingStart?: () => void;
/** Called when recording stops (for Blobbi reaction) */
onRecordingStop?: () => void;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── MIME Type Selection ──────────────────────────────────────────────────────
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineSingCard({
onConfirm,
onClose,
onRecordingStart,
onRecordingStop,
isPublishing,
}: InlineSingCardProps) {
// Recording state
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [recordingError, setRecordingError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
// Lyrics state
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
// Refs
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Audio playback for preview
const {
state: playbackState,
error: playbackError,
load: loadAudio,
toggle: togglePlayback,
stop: stopPlayback,
isPlaying,
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, []);
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
try {
mediaRecorderRef.current.stop();
} catch {
// Ignore errors during cleanup
}
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Cleanup playback
cleanupPlayback();
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl, cleanupPlayback]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
setRecordingState('idle');
setRecordingError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
actualMimeTypeRef.current = undefined;
// Keep lyrics
}, [cleanupAll]);
// Check browser support
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setRecordingError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setRecordingError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get supported MIME type
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
mediaRecorder = new MediaRecorder(stream);
}
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setRecordingError('Recording failed. Please try again.');
setRecordingState('error');
};
mediaRecorder.start(100);
setRecordingState('recording');
setRecordingDuration(0);
// Notify parent that recording started (for Blobbi reaction)
onRecordingStart?.();
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setRecordingError('Microphone access was denied.');
} else if (err.name === 'NotFoundError') {
setRecordingError('No microphone found.');
} else {
setRecordingError(err.message);
}
} else {
setRecordingError('Failed to access microphone.');
}
setRecordingState('error');
}
}, [onRecordingStart]);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
// Notify parent that recording stopped (for Blobbi reaction)
onRecordingStop?.();
}, [onRecordingStop]);
// Handle preview playback
const handlePreview = useCallback(() => {
if (!audioUrl) return;
if (playbackState === 'idle') {
loadAudio(audioUrl, true);
} else {
togglePlayback();
}
}, [audioUrl, playbackState, loadAudio, togglePlayback]);
// Handle confirm
const handleConfirm = useCallback(async () => {
stopPlayback();
await onConfirm();
// After successful publish, close the card
onClose();
}, [stopPlayback, onConfirm, onClose]);
// Handle close
const handleClose = useCallback(() => {
cleanupAll();
onClose();
}, [cleanupAll, onClose]);
// Handle lyrics toggle
const handleLyricsToggle = useCallback(() => {
if (!currentLyrics && !showLyrics) {
// Generate lyrics on first open
setCurrentLyrics(getRandomLyrics());
}
setShowLyrics(!showLyrics);
}, [currentLyrics, showLyrics]);
// Get new lyrics
const handleNewLyrics = useCallback(() => {
setCurrentLyrics(getRandomLyrics());
}, []);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded';
const isRecording = recordingState === 'recording';
const canConfirm = hasRecording && !isPublishing;
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isRecording && "ring-2 ring-red-500/30"
)}>
{/* Lyrics panel (expands upward visually by being above controls) */}
{showLyrics && currentLyrics && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{currentLyrics.title}</span>
<Button
size="icon"
variant="ghost"
onClick={handleNewLyrics}
className="size-7 rounded-full"
>
<RefreshCw className="size-3" />
</Button>
</div>
<div className="p-3 rounded-lg bg-muted/50 text-sm leading-relaxed whitespace-pre-line max-h-32 overflow-y-auto">
{currentLyrics.lines.join('\n')}
</div>
</div>
)}
{/* Status row (recording/recorded info) */}
{(isRecording || hasRecording) && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-center gap-2">
{isRecording && (
<>
<div className="size-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-sm font-mono font-medium text-red-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recording...</span>
</>
)}
{hasRecording && !isRecording && (
<>
<Check className="size-4 text-purple-500" />
<span className="text-sm font-mono font-medium text-purple-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recorded</span>
</>
)}
</div>
</div>
)}
{/* Error message */}
{(recordingError || playbackError) && (
<div className="px-3 pt-2">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{recordingError || playbackError?.message}</p>
</div>
</div>
)}
{/* Main controls row */}
<div className="flex items-center justify-between gap-2 p-3">
{/* Left: Lyrics button */}
<Button
size="icon"
variant={showLyrics ? "secondary" : "ghost"}
onClick={handleLyricsToggle}
className="size-10 rounded-full shrink-0"
>
<FileText className="size-4" />
</Button>
{/* Center: Record/Stop button */}
<div className="flex items-center gap-2">
{!isRecording && !hasRecording && (
<Button
onClick={startRecording}
disabled={isPublishing}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-4 mr-2" />
Sing
</Button>
)}
{isRecording && (
<Button
onClick={stopRecording}
variant="destructive"
className="rounded-full px-6"
>
<Square className="size-4 mr-2" />
Stop
</Button>
)}
{hasRecording && !isRecording && (
<>
<Button
onClick={resetRecording}
variant="outline"
size="icon"
className="size-10 rounded-full"
>
<RefreshCw className="size-4" />
</Button>
<Button
onClick={handleConfirm}
disabled={!canConfirm}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
{isPublishing ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Check className="size-4 mr-2" />
)}
{isPublishing ? 'Singing...' : 'Sing for Blobbi'}
</Button>
</>
)}
</div>
{/* Right: Preview button (when recording exists) */}
{hasRecording ? (
<Button
size="icon"
variant="ghost"
onClick={handlePreview}
disabled={isPublishing}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
) : (
/* Close button when no recording */
<Button
size="icon"
variant="ghost"
onClick={handleClose}
className="size-10 rounded-full shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
)}
</div>
{/* Close button row when recording exists */}
{hasRecording && (
<div className="px-3 pb-3 pt-0 flex justify-end">
<Button
size="sm"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3 mr-1" />
Cancel
</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,301 @@
// src/blobbi/actions/components/PlayMusicModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Music, Play, Pause, Check, Loader2, Volume2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
getAllTracks,
formatTrackDuration,
type BlobbiTrack,
} from '../lib/blobbi-track-catalog';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Selected track for the music player
*/
export interface SelectedTrack {
track: BlobbiTrack;
url: string;
}
interface PlayMusicModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with the selected track when user confirms */
onConfirm: (selection: SelectedTrack) => void;
isLoading: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function PlayMusicModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: PlayMusicModalProps) {
const [selectedTrack, setSelectedTrack] = useState<SelectedTrack | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Track the current audio source URL to detect changes
const currentAudioUrlRef = useRef<string | null>(null);
const tracks = getAllTracks();
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
setSelectedTrack(null);
setIsPlaying(false);
setError(null);
currentAudioUrlRef.current = null;
}
}, [open]);
// Handle selecting a track
const handleSelectTrack = useCallback((track: BlobbiTrack) => {
// Stop current playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
setSelectedTrack({ track, url: track.url });
setError(null);
}, []);
// Handle play/pause preview
const handleTogglePlay = useCallback(() => {
if (!selectedTrack) return;
const audioUrl = selectedTrack.url;
// Check if we need to create a new Audio instance (source changed or first time)
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Stop and cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the correct source
audioRef.current = new Audio(audioUrl);
currentAudioUrlRef.current = audioUrl;
audioRef.current.onended = () => setIsPlaying(false);
audioRef.current.onerror = () => {
setError('Failed to load this track. Please try another one.');
setIsPlaying(false);
};
}
if (isPlaying && !needsNewAudio) {
// Pause current playback
audioRef.current?.pause();
setIsPlaying(false);
} else {
// Start playback (either new source or resuming)
audioRef.current?.play().catch(() => {
setError('Failed to play this track. Please try another one.');
setIsPlaying(false);
});
setIsPlaying(true);
}
}, [selectedTrack, isPlaying]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (!selectedTrack) return;
// Stop playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onConfirm(selectedTrack);
}, [selectedTrack, onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen && audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onOpenChange(isOpen);
}, [onOpenChange]);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-pink-500/20 to-pink-500/5 flex items-center justify-center">
<Music className="size-5 text-pink-500" />
</div>
<div>
<DialogTitle className="text-xl">Play Music</DialogTitle>
<p className="text-sm text-muted-foreground">
Choose a track to play for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content - Track List */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="grid gap-2">
{tracks.map((track) => (
<TrackRow
key={track.id}
track={track}
isSelected={selectedTrack?.track.id === track.id}
onSelect={() => handleSelectTrack(track)}
/>
))}
</div>
{error && (
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{error}</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
{/* Preview Controls */}
{selectedTrack && (
<div className="mb-4 p-3 rounded-lg bg-card border">
<div className="flex items-center gap-3">
<Button
size="icon"
variant="outline"
onClick={handleTogglePlay}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate text-sm">{selectedTrack.track.title}</p>
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : 'Click to preview'}
</p>
</div>
{isPlaying && (
<Volume2 className="size-4 text-primary animate-pulse shrink-0" />
)}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedTrack || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Playing...
</>
) : (
<>
<Music className="size-4 mr-2" />
Play for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Track Row Component ──────────────────────────────────────────────────────
interface TrackRowProps {
track: BlobbiTrack;
isSelected: boolean;
onSelect: () => void;
}
function TrackRow({ track, isSelected, onSelect }: TrackRowProps) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"w-full p-3 rounded-xl text-left transition-all",
"border hover:border-primary/30",
isSelected
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border bg-card/60"
)}
>
<div className="flex items-center gap-3">
<div className={cn(
"size-10 rounded-lg flex items-center justify-center",
isSelected ? "bg-primary/20" : "bg-muted"
)}>
<Music className={cn(
"size-5",
isSelected ? "text-primary" : "text-muted-foreground"
)} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm text-muted-foreground">
{formatTrackDuration(track.durationSeconds)}
</span>
{isSelected && <Check className="size-4 text-primary" />}
</div>
</div>
</button>
);
}
+601
View File
@@ -0,0 +1,601 @@
// src/blobbi/actions/components/SingModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isLoading: boolean;
}
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
/**
* Ordered list of MIME types to try for audio recording.
* The first supported type will be used.
*/
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
/**
* Get the first supported MIME type for MediaRecorder.
* Returns undefined if no explicit MIME type is supported (let browser decide).
*/
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
// No explicit MIME type supported, let browser use default
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function SingModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: SingModalProps) {
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [error, setError] = useState<string | null>(null);
const [playbackError, setPlaybackError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open]);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Stop audio playback
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl]);
const resetRecording = useCallback(() => {
cleanup();
setRecordingState('idle');
setError(null);
setPlaybackError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
currentPlaybackUrlRef.current = null;
actualMimeTypeRef.current = undefined;
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
setCurrentLyrics(lyrics);
setShowLyrics(true);
}, []);
// Check if browser supports media recording
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setError(null);
setPlaybackError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get the first supported MIME type using our helper
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder with or without explicit MIME type
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
// Let browser choose default MIME type
mediaRecorder = new MediaRecorder(stream);
}
// Store the actual MIME type being used (may differ from what we requested)
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
// Create blob from chunks using the actual MIME type used by the recorder
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setError('Recording failed. Please try again.');
setRecordingState('error');
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setRecordingState('recording');
setRecordingDuration(0);
// Start timer
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('Microphone access was denied. Please allow microphone access and try again.');
} else if (err.name === 'NotFoundError') {
setError('No microphone found. Please connect a microphone and try again.');
} else {
setError(`Failed to access microphone: ${err.message}`);
}
} else {
setError('Failed to access microphone. Please try again.');
}
setRecordingState('error');
}
}, []);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
}, []);
// Track the current audio URL to detect changes
const currentPlaybackUrlRef = useRef<string | null>(null);
// Play/pause preview
const togglePlayback = useCallback(() => {
if (!audioUrl) return;
// Clear previous playback error when attempting to play
setPlaybackError(null);
if (recordingState === 'playing') {
if (audioRef.current) {
audioRef.current.pause();
}
setRecordingState('recorded');
} else {
// Check if we need to create a new Audio instance (URL changed or first time)
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the recorded audio URL
audioRef.current = new Audio(audioUrl);
currentPlaybackUrlRef.current = audioUrl;
audioRef.current.onended = () => setRecordingState('recorded');
// Handle playback errors with user-visible message
audioRef.current.onerror = () => {
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
setRecordingState('recorded');
};
}
audioRef.current?.play()
.then(() => {
setRecordingState('playing');
})
.catch((err) => {
console.error('Failed to play recording:', err);
// Provide user-friendly error message
if (err.name === 'NotSupportedError') {
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
} else if (err.name === 'NotAllowedError') {
setPlaybackError('Playback was blocked. Try interacting with the page first.');
} else {
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
}
setRecordingState('recorded');
});
}
}, [audioUrl, recordingState]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
}
onConfirm();
}, [onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen) {
cleanup();
}
onOpenChange(isOpen);
}, [onOpenChange, cleanup]);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
<Mic className="size-5 text-purple-500" />
</div>
<div>
<DialogTitle className="text-xl">Sing</DialogTitle>
<p className="text-sm text-muted-foreground">
Record yourself singing for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 px-6 py-8">
<div className="flex flex-col items-center justify-center gap-6">
{/* Recording Visualization */}
<div className={cn(
"relative size-40 rounded-full flex items-center justify-center transition-all",
recordingState === 'recording' && "animate-pulse",
recordingState === 'recording'
? "bg-red-500/10 ring-4 ring-red-500/30"
: hasRecording
? "bg-purple-500/10 ring-4 ring-purple-500/30"
: "bg-muted"
)}>
{/* Animated rings for recording */}
{recordingState === 'recording' && (
<>
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
</>
)}
{/* Icon */}
<div className={cn(
"relative size-20 rounded-full flex items-center justify-center",
recordingState === 'recording'
? "bg-red-500 text-white"
: hasRecording
? "bg-purple-500 text-white"
: "bg-muted-foreground/20"
)}>
{recordingState === 'requesting' ? (
<Loader2 className="size-8 animate-spin" />
) : recordingState === 'recording' ? (
<Mic className="size-8" />
) : hasRecording ? (
recordingState === 'playing' ? (
<Pause className="size-8" />
) : (
<Play className="size-8 ml-1" />
)
) : (
<MicOff className="size-8 text-muted-foreground" />
)}
</div>
</div>
{/* Duration / Status */}
<div className="text-center">
{recordingState === 'idle' && (
<p className="text-muted-foreground">Tap the button below to start recording</p>
)}
{recordingState === 'requesting' && (
<p className="text-muted-foreground">Requesting microphone access...</p>
)}
{recordingState === 'recording' && (
<>
<p className="text-3xl font-mono font-bold text-red-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
</>
)}
{hasRecording && (
<>
<p className="text-3xl font-mono font-bold text-purple-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
</p>
</>
)}
{recordingState === 'error' && (
<p className="text-destructive">Recording failed</p>
)}
</div>
{/* Error Message */}
{error && (
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
)}
{/* Playback Error Message (non-fatal, recording still works) */}
{playbackError && (
<div className="w-full p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{playbackError}</p>
</div>
</div>
)}
{/* Lyrics Helper */}
<div className="w-full">
{!currentLyrics ? (
<Button
variant="outline"
size="sm"
onClick={handleRandomLyrics}
className="w-full gap-2"
>
<Sparkles className="size-4" />
Need lyrics? Get random lyrics
</Button>
) : (
<div className="rounded-lg border bg-card/60">
<button
type="button"
onClick={() => setShowLyrics(!showLyrics)}
className="w-full flex items-center justify-between p-3 text-left"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-purple-500" />
<span className="font-medium text-sm">{currentLyrics.title}</span>
</div>
{showLyrics ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</button>
{showLyrics && (
<div className="px-3 pb-3 pt-0">
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
{currentLyrics.lines.join('\n')}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRandomLyrics}
className="w-full mt-2 gap-2 text-muted-foreground"
>
<RotateCcw className="size-3" />
Get different lyrics
</Button>
</div>
)}
</div>
)}
</div>
{/* Recording Controls */}
<div className="flex items-center gap-3">
{recordingState === 'idle' || recordingState === 'error' ? (
<Button
size="lg"
onClick={startRecording}
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-5 mr-2" />
Start Recording
</Button>
) : recordingState === 'recording' ? (
<Button
size="lg"
variant="destructive"
onClick={stopRecording}
className="rounded-full px-8"
>
<Square className="size-5 mr-2" />
Stop
</Button>
) : hasRecording ? (
<>
<Button
size="lg"
variant="outline"
onClick={togglePlayback}
className="rounded-full"
>
{recordingState === 'playing' ? (
<>
<Pause className="size-5 mr-2" />
Pause
</>
) : (
<>
<Play className="size-5 mr-2" />
Preview
</>
)}
</Button>
<Button
size="lg"
variant="ghost"
onClick={resetRecording}
className="rounded-full"
>
<RotateCcw className="size-5 mr-2" />
Re-record
</Button>
</>
) : null}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!hasRecording || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Singing...
</>
) : (
<>
<Mic className="size-4 mr-2" />
Sing for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,125 @@
// src/blobbi/actions/components/StartEvolutionDialog.tsx
/**
* Dialog for confirming start of evolution.
*
* Evolution is simpler than incubation:
* - Only baby Blobbis can evolve
* - Shows restart confirmation if already evolving
* - Otherwise shows normal start confirmation
*/
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartEvolutionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start evolving */
companion: BlobbiCompanion | null;
/** Called when confirmed */
onConfirm: () => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartEvolutionDialog({
open,
onOpenChange,
companion,
onConfirm,
isPending,
}: StartEvolutionDialogProps) {
// Check if the current Blobbi is already evolving
const isAlreadyEvolving = companion?.state === 'evolving';
// Determine title and description based on state
const getDialogContent = () => {
if (isAlreadyEvolving) {
return {
title: 'Restart Evolution?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Evolution',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Evolution',
icon: <Sparkles className="size-5 text-primary" />,
description: (
<>
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
Complete all the tasks to evolve your baby Blobbi into an adult!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Evolution',
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,180 @@
// src/blobbi/actions/components/StartIncubationDialog.tsx
/**
* Dialog for confirming start of incubation.
*
* Determines the mode and passes it explicitly to the confirm callback:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating)
* - 'switch': Stop another Blobbi first, then start this one
*
* The mode is determined by UI state, NOT auto-detected by the hook.
* This makes the flow explicit and predictable.
*/
import { useMemo } from 'react';
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartIncubationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start incubating */
companion: BlobbiCompanion | null;
/** All companions in the collection (to check for other incubating Blobbis) */
companions?: BlobbiCompanion[];
/** Called with explicit mode and optional stopOtherD when confirmed */
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartIncubationDialog({
open,
onOpenChange,
companion,
companions = [],
onConfirm,
isPending,
}: StartIncubationDialogProps) {
// Check if the current Blobbi is already in a task state
const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving';
// Check if another Blobbi (not this one) is currently incubating
const otherIncubatingBlobbi = useMemo(() => {
if (!companion) return null;
return companions.find(c =>
c.d !== companion.d &&
c.state === 'incubating' &&
c.stage === 'egg'
) ?? null;
}, [companion, companions]);
// Determine the mode based on current state
const mode: StartIncubationMode = useMemo(() => {
if (isAlreadyInTaskState) return 'restart';
if (otherIncubatingBlobbi) return 'switch';
return 'start';
}, [isAlreadyInTaskState, otherIncubatingBlobbi]);
// Handle confirm with explicit mode
const handleConfirm = () => {
if (mode === 'switch' && otherIncubatingBlobbi) {
onConfirm(mode, otherIncubatingBlobbi.d);
} else {
onConfirm(mode);
}
};
// Determine title and description based on mode
const getDialogContent = () => {
if (mode === 'restart') {
return {
title: 'Restart Incubation?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
Your Blobbi is already {companion?.state}. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Incubation',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
if (mode === 'switch') {
return {
title: 'Switch Incubation?',
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
description: (
<>
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
Only one Blobbi can incubate at a time.
<br /><br />
Starting incubation for <strong>{companion?.name}</strong> will{' '}
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
reset their task progress.
<br /><br />
Do you want to switch?
</>
),
buttonText: 'Switch & Start',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Incubation',
icon: null,
description: (
<>
Starting incubation begins your Blobbi's hatching journey.
Complete all the tasks to hatch your egg into a baby Blobbi!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Incubation',
buttonClass: undefined,
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,263 @@
// src/blobbi/actions/components/TasksPanel.tsx
/**
* Generic UI component for displaying task progress.
* Shows a list of tasks with progress indicators and action buttons.
* Used for both hatch and evolve tasks.
*/
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { 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 TasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks the complete button */
onComplete: () => void;
/** Whether completion is in progress */
isCompleting?: boolean;
/** Emoji to show in header */
emoji: string;
/** Title for the tasks panel */
title: string;
/** Description for the tasks panel */
description: string;
/** Label for the complete button */
completeLabel: string;
/** Label while completing */
completingLabel: string;
/** Emoji for complete button */
completeEmoji: string;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const isDynamic = task.type === 'dynamic';
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
onOpenPostModal();
}
break;
}
};
const progress = task.required > 1
? Math.round((task.current / task.required) * 100)
: task.completed ? 100 : 0;
return (
<div
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border transition-all overflow-hidden",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: isDynamic
? "bg-amber-500/5 border-amber-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Top row on mobile: Status + Task info */}
<div className="flex items-start sm:items-center gap-3 sm:contents">
{/* Status indicator */}
<div className={cn(
"size-8 sm:size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: isDynamic
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-4 sm:size-5" />
) : isDynamic ? (
<AlertCircle className="size-4 sm:size-5" />
) : task.required > 1 ? (
<span className="text-xs sm:text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-base sm:text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mb-0.5 sm:mb-1">
<h4 className={cn(
"font-medium text-sm sm:text-base break-words",
task.completed && "text-emerald-600 dark:text-emerald-400",
isDynamic && !task.completed && "text-amber-600 dark:text-amber-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs shrink-0">
Complete
</Badge>
)}
{isDynamic && !task.completed && (
<Badge variant="secondary" className="bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs shrink-0">
Live
</Badge>
)}
</div>
<p className="text-xs sm:text-sm text-muted-foreground break-words">
{task.description}
</p>
{/* Progress bar for multi-step tasks (not for dynamic stat tasks) */}
{task.required > 1 && !task.completed && !isDynamic && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
{/* Dynamic task hint */}
{isDynamic && !task.completed && (
<p className="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">
Lowest stat: {task.current}% (need {task.required}%+)
</p>
)}
</div>
</div>
{/* Action button - full width on mobile when present */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2 w-full sm:w-auto mt-1 sm:mt-0"
>
<span className="truncate">{task.actionLabel}</span>
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
</Button>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function TasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onComplete,
isCompleting = false,
emoji,
title,
description,
completeLabel,
completingLabel,
completeEmoji,
}: TasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent overflow-hidden">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-start sm:items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<span className="text-xl sm:text-2xl shrink-0">{emoji}</span>
<span className="break-words">{title}</span>
</CardTitle>
<CardDescription className="text-xs sm:text-sm break-words">
{description}
</CardDescription>
</div>
<Badge variant="outline" className="text-sm sm:text-base px-2 sm:px-3 py-0.5 sm:py-1 shrink-0">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-3 sm:mt-4">
<div className="flex items-center justify-between text-xs sm:text-sm mb-2">
<span className="text-muted-foreground">Overall progress</span>
<span className="font-medium">{overallProgress}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
</CardHeader>
<CardContent className="space-y-2 sm:space-y-3 px-3 sm:px-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{tasks.map(task => (
<TaskRow
key={task.id}
task={task}
onOpenPostModal={onOpenPostModal}
/>
))}
{/* Complete button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<Button
onClick={onComplete}
disabled={isCompleting}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-xl">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,232 @@
// src/blobbi/actions/hooks/useActiveTaskProcess.ts
/**
* Central abstraction for the active task process (hatch or evolve).
*
* This hook consolidates all scattered if/else logic for determining:
* - Which process is active (incubating vs evolving)
* - Which tasks to use (hatch vs evolve)
* - Thresholds and configuration
* - Badge-related computed values
*
* ARCHITECTURE RULES:
* - Computed tasks remain the source of truth
* - Tags are cache only for PERSISTENT tasks
* - Dynamic tasks are NEVER persisted
* - Badge counts ALL incomplete tasks (persistent + dynamic)
*/
import { useMemo } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
import type { EvolveTasksResult } from './useEvolveTasks';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The type of task process currently active */
export type TaskProcessType = 'hatch' | 'evolve' | null;
/**
* Configuration for the active task process.
* This provides a unified interface regardless of whether
* the process is hatch or evolve.
*/
export interface TaskProcessConfig {
/** The type of process ('hatch' | 'evolve' | null) */
type: TaskProcessType;
/** Whether there is an active task process */
isActive: boolean;
/** Required interactions threshold for the current process */
interactionThreshold: number;
}
/**
* Result of the active task process hook.
* Provides unified access to all task-related state.
*/
export interface ActiveTaskProcessResult {
/** Configuration for the current process */
config: TaskProcessConfig;
/** All tasks for the current process (empty if no active process) */
tasks: HatchTask[];
/** Whether tasks are still loading */
isLoading: boolean;
/** Whether all tasks (persistent + dynamic) are complete */
allCompleted: boolean;
/** Whether all persistent tasks are complete */
persistentTasksComplete: boolean;
/** Whether the dynamic task is complete */
dynamicTaskComplete: boolean;
/** Refetch function for current tasks */
refetch: () => void;
// ─── Badge-related computed values ───
/**
* Count of ALL remaining incomplete tasks (persistent + dynamic).
* This is used for the badge display.
* Dynamic tasks ARE counted here but are NEVER synced to tags.
*/
remainingTasksCount: number;
/**
* Only persistent tasks that are incomplete.
* Used for sync logic - dynamic tasks must NEVER be synced.
*/
incompletePersistentTasks: HatchTask[];
/**
* Only persistent tasks that are complete.
* Used for sync logic.
*/
completedPersistentTasks: HatchTask[];
/**
* Stable string key of completed persistent task IDs.
* Used for sync anti-loop protection.
*/
completedPersistentTaskIds: string;
/**
* Tasks to sync (persistent only, with completion status).
* Dynamic tasks are excluded.
*/
tasksToSync: Array<{ taskId: string; completed: boolean }>;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Filter tasks to only persistent tasks.
* Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
/**
* Filter tasks to only dynamic tasks.
*/
export function filterDynamicTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'dynamic');
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook that provides a unified interface for the active task process.
*
* Usage:
* ```ts
* const taskProcess = useActiveTaskProcess(companion, hatchTasks, evolveTasks);
*
* // Access unified data
* taskProcess.config.type // 'hatch' | 'evolve' | null
* taskProcess.tasks // current tasks
* taskProcess.remainingTasksCount // for badge (includes dynamic)
* taskProcess.tasksToSync // for sync (excludes dynamic)
* ```
*/
export function useActiveTaskProcess(
companion: BlobbiCompanion | null,
hatchTasks: HatchTasksResult,
evolveTasks: EvolveTasksResult
): ActiveTaskProcessResult {
// Determine which process is active
const processType = useMemo((): TaskProcessType => {
if (!companion) return null;
if (companion.state === 'incubating') return 'hatch';
if (companion.state === 'evolving') return 'evolve';
return null;
}, [companion]);
// Build configuration
const config = useMemo((): TaskProcessConfig => {
const isActive = processType !== null;
const interactionThreshold = processType === 'hatch'
? HATCH_REQUIRED_INTERACTIONS
: processType === 'evolve'
? EVOLVE_REQUIRED_INTERACTIONS
: 0;
return {
type: processType,
isActive,
interactionThreshold,
};
}, [processType]);
// Get the active tasks result based on process type
const activeResult = useMemo(() => {
if (processType === 'hatch') return hatchTasks;
if (processType === 'evolve') return evolveTasks;
return null;
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = activeResult?.tasks ?? [];
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
const dynamicTaskComplete = activeResult?.dynamicTaskComplete ?? false;
const refetch = activeResult?.refetch ?? (() => {});
// Compute persistent task list (dynamic tasks computed for badge count directly from tasks array)
const persistentTasks = useMemo(() => filterPersistentTasks(tasks), [tasks]);
// Compute incomplete tasks (for badge - includes BOTH persistent and dynamic)
const remainingTasksCount = useMemo(() => {
// Count ALL incomplete tasks - persistent AND dynamic
// Dynamic tasks are included in badge count but NEVER synced to tags
return tasks.filter(t => !t.completed).length;
}, [tasks]);
// Compute persistent task lists for sync
const incompletePersistentTasks = useMemo(() =>
persistentTasks.filter(t => !t.completed),
[persistentTasks]
);
const completedPersistentTasks = useMemo(() =>
persistentTasks.filter(t => t.completed),
[persistentTasks]
);
// Compute stable string key for completed persistent tasks (anti-loop)
const completedPersistentTaskIds = useMemo(() => {
if (!completedPersistentTasks.length) return '';
return completedPersistentTasks
.map(t => t.id)
.sort()
.join(',');
}, [completedPersistentTasks]);
// Compute tasks to sync (persistent only)
// CRITICAL: Dynamic tasks must NEVER be included here
const tasksToSync = useMemo(() => {
if (!persistentTasks.length) return [];
return persistentTasks.map(t => ({
taskId: t.id,
completed: t.completed,
}));
}, [persistentTasks]);
return {
config,
tasks,
isLoading,
allCompleted,
persistentTasksComplete,
dynamicTaskComplete,
refetch,
remainingTasksCount,
incompletePersistentTasks,
completedPersistentTasks,
completedPersistentTaskIds,
tasksToSync,
};
}
@@ -0,0 +1,287 @@
// src/blobbi/actions/hooks/useAudioPlayback.ts
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* Audio playback state
* - idle: No audio loaded
* - loading: Audio is being loaded
* - playing: Audio is playing
* - paused: Audio is paused (can resume)
* - stopped: Audio was stopped (must reload to play again)
* - error: An error occurred
*/
export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
/**
* Audio playback error info
*/
export interface PlaybackError {
message: string;
code?: string;
}
/** Default volume level (0-1) */
const DEFAULT_VOLUME = 0.8;
/**
* Options for the useAudioPlayback hook
*/
export interface UseAudioPlaybackOptions {
/** Called when playback ends naturally */
onEnded?: () => void;
/** Called when an error occurs */
onError?: (error: PlaybackError) => void;
/** Initial volume level (0-1), defaults to 0.8 */
initialVolume?: number;
}
/**
* Return type for useAudioPlayback hook
*/
export interface UseAudioPlaybackReturn {
/** Current playback state */
state: PlaybackState;
/** Current error (if any) */
error: PlaybackError | null;
/** Current audio URL being played */
currentUrl: string | null;
/** Load and optionally start playing an audio URL */
load: (url: string, autoplay?: boolean) => void;
/** Play the current audio */
play: () => Promise<void>;
/** Pause the current audio */
pause: () => void;
/** Stop playback and reset */
stop: () => void;
/** Restart playback from the beginning */
restart: () => Promise<void>;
/** Toggle play/pause */
toggle: () => Promise<void>;
/** Whether audio is currently playing */
isPlaying: boolean;
/** Current volume level (0-1) */
volume: number;
/** Set volume level (0-1) */
setVolume: (volume: number) => void;
/** Cleanup function to release resources */
cleanup: () => void;
}
/**
* Reusable hook for audio playback.
* Handles Audio element lifecycle, error handling, and state management.
*/
export function useAudioPlayback(options: UseAudioPlaybackOptions = {}): UseAudioPlaybackReturn {
const { onEnded, onError, initialVolume = DEFAULT_VOLUME } = options;
const [state, setState] = useState<PlaybackState>('idle');
const [error, setError] = useState<PlaybackError | null>(null);
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
const [volume, setVolumeState] = useState<number>(initialVolume);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentUrlRef = useRef<string | null>(null);
const volumeRef = useRef<number>(initialVolume);
// Cleanup audio element
const cleanup = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
audioRef.current = null;
}
currentUrlRef.current = null;
setState('idle');
setCurrentUrl(null);
setError(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Load audio from URL
const load = useCallback((url: string, autoplay = false) => {
// If same URL, don't reload
if (currentUrlRef.current === url && audioRef.current) {
if (autoplay) {
audioRef.current.play().catch(() => {});
}
return;
}
// Cleanup previous audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
}
setState('loading');
setError(null);
setCurrentUrl(url);
currentUrlRef.current = url;
const audio = new Audio(url);
audio.volume = volumeRef.current; // Apply current volume to new audio
audioRef.current = audio;
audio.oncanplay = () => {
if (autoplay) {
audio.play().catch((err) => {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err.name,
};
setError(playbackError);
setState('error');
onError?.(playbackError);
});
} else {
setState('paused');
}
};
audio.onplaying = () => {
setState('playing');
};
audio.onpause = () => {
if (state === 'playing') {
setState('paused');
}
};
audio.onended = () => {
setState('paused');
onEnded?.();
};
audio.onerror = () => {
const playbackError: PlaybackError = {
message: 'Failed to load audio. The format may not be supported.',
code: 'MEDIA_ERR',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
};
// Start loading
audio.load();
}, [onEnded, onError, state]);
// Play current audio
const play = useCallback(async () => {
if (!audioRef.current) return;
try {
setError(null);
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Pause current audio
const pause = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
setState('paused');
}, []);
// Stop playback completely (requires reload to play again)
const stop = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
audioRef.current.currentTime = 0;
// Clear URL ref so next load() will actually reload
currentUrlRef.current = null;
setState('stopped');
}, []);
// Restart playback from the beginning
const restart = useCallback(async () => {
if (!audioRef.current) return;
audioRef.current.currentTime = 0;
try {
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Toggle play/pause
const toggle = useCallback(async () => {
if (state === 'playing') {
pause();
} else {
await play();
}
}, [state, play, pause]);
// Set volume (0-1)
const setVolume = useCallback((newVolume: number) => {
const clampedVolume = Math.max(0, Math.min(1, newVolume));
volumeRef.current = clampedVolume;
setVolumeState(clampedVolume);
if (audioRef.current) {
audioRef.current.volume = clampedVolume;
}
}, []);
return {
state,
error,
currentUrl,
load,
play,
pause,
stop,
restart,
toggle,
isPlaying: state === 'playing',
volume,
setVolume,
cleanup,
};
}
/**
* Get a user-friendly error message for playback errors
*/
function getPlaybackErrorMessage(err: unknown): string {
if (err instanceof Error) {
if (err.name === 'NotSupportedError') {
return 'This audio format is not supported by your browser.';
}
if (err.name === 'NotAllowedError') {
return 'Playback was blocked. Try interacting with the page first.';
}
return err.message;
}
return 'An unknown error occurred during playback.';
}
@@ -0,0 +1,189 @@
/**
* useBlobbiCareActivity - Hook for registering care activity and updating streaks
*
* This hook provides a centralized way to register care activity for a Blobbi companion.
* It handles:
* - Calculating streak updates based on the last activity day
* - Publishing updated Blobbi state to Nostr
* - Updating local cache
*
* Use this hook whenever care activity should count toward the streak:
* - Opening the Blobbi page (page check-in)
* - Performing care actions (feed, clean, play, etc.)
* - Any other care interaction
*
* The streak only increments once per calendar day, regardless of how many
* activities are performed.
*/
import { useCallback, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
export interface CareActivityResult {
/** Whether the streak was updated */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** Description of what happened */
action: StreakUpdateResult['action'];
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to register care activity and update streaks.
*
* Returns a function to register activity and a mutation for the actual update.
* The register function is idempotent - calling it multiple times on the same day
* will only update once.
*/
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiCareActivityParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
// Track if we've already registered activity this session to avoid duplicate calls
// This is a performance optimization - the actual idempotency is handled by day comparison
const lastRegisteredDay = useRef<string | null>(null);
const mutation = useMutation({
mutationFn: async (): Promise<CareActivityResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to register care activity');
}
if (!companion) {
throw new Error('No companion available');
}
const now = new Date();
// Calculate what the streak update should be
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
// If no update needed (same day), return early without publishing
if (!result.wasUpdated) {
return {
wasUpdated: false,
newStreak: result.newStreak,
action: result.action,
};
}
// Get the tag updates
const streakUpdates = getStreakTagUpdates(companion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
tags: updatedTags,
});
// Update local cache
updateCompanionEvent(event);
// Update session tracker
lastRegisteredDay.current = result.newLastDay;
// Log for debugging (dev only)
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: companion.careStreak,
newStreak: result.newStreak,
lastDay: companion.careStreakLastDay,
newDay: result.newLastDay,
});
}
return {
wasUpdated: true,
newStreak: result.newStreak,
action: result.action,
};
},
onSuccess: (result) => {
if (result.wasUpdated) {
invalidateCompanion();
}
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
});
/**
* Register care activity. Call this when care-related activity happens.
* Safe to call multiple times - only updates streak once per day.
*
* @returns Promise with the result of the activity registration
*/
const registerCareActivity = useCallback(async (): Promise<CareActivityResult | null> => {
if (!companion) {
return null;
}
// Quick check if we've already registered for this companion's last day (session cache)
// This is an optimization to avoid unnecessary mutation calls
if (lastRegisteredDay.current === companion.careStreakLastDay) {
// Already processed this day in this session, skip
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
return mutation.mutateAsync();
}, [companion, mutation]);
return {
/** Register care activity - call when page opens or care action happens */
registerCareActivity,
/** Whether an update is currently in progress */
isUpdating: mutation.isPending,
/** The last update result */
lastResult: mutation.data,
/** Any error from the last update attempt */
error: mutation.error,
};
}
@@ -0,0 +1,228 @@
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import {
clampStat,
applyStat,
DIRECT_ACTION_METADATA,
incrementInteractionTaskTags,
type DirectAction,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Configuration for direct action happiness effects.
* These are the happiness deltas for each direct action.
*/
export const DIRECT_ACTION_HAPPINESS_EFFECTS: Record<DirectAction, number> = {
play_music: 15,
sing: 20,
};
/**
* Request payload for executing a direct action
*/
export interface DirectActionRequest {
action: DirectAction;
}
/**
* Result of executing a direct action
*/
export interface DirectActionResult {
action: DirectAction;
happinessChange: number;
xpGained: number;
newXP: number;
}
/**
* Parameters for the useBlobbiDirectAction hook
*/
export interface UseBlobbiDirectActionParams {
companion: BlobbiCompanion | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration happened) */
invalidateProfile: () => void;
}
/**
* Hook to execute a direct action on a Blobbi companion.
* Direct actions (play_music, sing) don't consume inventory items.
* They directly affect happiness stat.
*
* This hook:
* 1. Validates the companion exists
* 2. Ensures canonical format before action
* 3. Applies accumulated decay
* 4. Applies happiness boost
* 5. Updates Blobbi state (kind 31124)
* 6. Invalidates relevant queries
*/
export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to perform actions');
}
if (!companion) {
throw new Error('No companion selected');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
const statsAfterDecay = decayResult.stats;
// ─── Apply Happiness Effect ───
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
// Track if happiness actually changed
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
// Build stats update
const isEgg = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {
happiness: newHappiness.toString(),
health: statsAfterDecay.health.toString(),
hygiene: statsAfterDecay.hygiene.toString(),
};
if (isEgg) {
// Eggs have fixed hunger and energy
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
statsUpdate.hunger = clampStat(statsAfterDecay.hunger).toString();
statsUpdate.energy = clampStat(statsAfterDecay.energy).toString();
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (ONLY if happiness actually changed) ───
// Direct actions modify happiness. Only grant XP if happiness actually increased.
const xpGained = happinessChanged ? calculateActionXP(action) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
action,
happinessChange: happinessDelta,
xpGained,
newXP,
};
},
onSuccess: ({ action, happinessChange, xpGained }) => {
const actionMeta = DIRECT_ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} complete!`,
description: `Your Blobbi's happiness increased by ${happinessChange}! ${xpText}`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'sing') dailyActions.push('sing');
if (action === 'play_music') dailyActions.push('play_music');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Action failed',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,939 @@
// src/blobbi/actions/hooks/useBlobbiIncubation.ts
/**
* Hooks for Blobbi incubation task system.
*
* When a user starts incubation:
* 1. Apply accumulated decay from last_decay_at to now
* 2. Set state to 'incubating'
* 3. Add state_started_at timestamp
* 4. Update last_decay_at to the same timestamp
* 5. Clear any previous task progress
*
* Tasks are computed from Nostr events with created_at >= state_started_at
*/
import { useMutation } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mode for starting incubation.
* This makes the intent explicit rather than auto-detecting behavior.
*/
export type StartIncubationMode =
| 'start' // Normal start (no other Blobbi incubating)
| 'restart' // Restart same Blobbi (already incubating)
| 'switch'; // Switch from another incubating Blobbi
/**
* Request to start incubation with explicit mode.
*/
export interface StartIncubationRequest {
/** Explicit mode for this operation */
mode: StartIncubationMode;
/** The d-tag of the other Blobbi to stop (required when mode === 'switch') */
stopOtherD?: string;
}
/**
* Parameters for start incubation hook.
*/
export interface UseStartIncubationParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of starting incubation.
*/
export interface StartIncubationResult {
/** The Blobbi's name */
name: string;
/** Timestamp when incubation started */
stateStartedAt: number;
/** Mode that was used */
mode: StartIncubationMode;
/** Name of other Blobbi that was stopped (if mode === 'switch') */
stoppedOtherName?: string;
}
// ─── Start Incubation Hook ────────────────────────────────────────────────────
/**
* Hook to start the incubation process for an egg.
*
* This sets the Blobbi state to 'incubating' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* IMPORTANT: The mode must be explicitly specified by the caller (UI).
* This hook does NOT auto-detect whether to switch or restart.
* The UI dialog determines the mode and passes it explicitly.
*
* Modes:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating), resets task progress
* - 'switch': Stop another Blobbi first, then start this one
*
* Requirements:
* - Blobbi must be in egg stage
* - User must be logged in
*/
export function useStartIncubation({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (request: StartIncubationRequest): Promise<StartIncubationResult> => {
const { mode, stopOtherD } = request;
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be incubated');
}
// Validate switch mode requires stopOtherD
if (mode === 'switch' && !stopOtherD) {
throw new Error('Switch mode requires stopOtherD parameter');
}
let stoppedOtherName: string | undefined;
// ─── Stop Other Incubating Blobbi (switch mode only) ───
if (mode === 'switch' && stopOtherD) {
// Fetch the current event for the other Blobbi
const [otherEvent] = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [stopOtherD],
limit: 1,
}]);
if (otherEvent) {
// Get name from the event for the result
const nameTag = otherEvent.tags.find(t => t[0] === 'name');
stoppedOtherName = nameTag?.[1] ?? stopOtherD;
// Stop the other Blobbi's incubation
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
// Parse stats from the event
const getTagValue = (tags: string[][], name: string): number =>
parseInt(tags.find(t => t[0] === name)?.[1] ?? '50', 10);
const otherStats = {
hunger: getTagValue(otherEvent.tags, 'hunger'),
happiness: getTagValue(otherEvent.tags, 'happiness'),
health: getTagValue(otherEvent.tags, 'health'),
hygiene: getTagValue(otherEvent.tags, 'hygiene'),
energy: getTagValue(otherEvent.tags, 'energy'),
};
const otherLastDecayAt = getTagValue(otherEvent.tags, 'last_decay_at') || now;
// Apply decay to the other Blobbi
const otherDecayResult = applyBlobbiDecay({
stage: 'egg',
state: 'incubating',
stats: otherStats,
lastDecayAt: otherLastDecayAt,
now,
});
// Remove task tags and state_started_at from the other Blobbi
const otherCleanedTags = otherEvent.tags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
const otherNewTags = updateBlobbiTags(otherCleanedTags, {
health: otherDecayResult.stats.health.toString(),
hygiene: otherDecayResult.stats.hygiene.toString(),
happiness: otherDecayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// Publish the stop event for the other Blobbi
const stopEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: otherEvent.content,
tags: otherNewTags,
});
// Update the cache for the stopped Blobbi
updateCompanionEvent(stopEvent);
}
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for incubation');
}
// ─── Apply Accumulated Decay ───
// CRITICAL: Apply decay from last_decay_at to now before changing state
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh (for all modes)
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'incubating',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
stateStartedAt: now,
mode,
stoppedOtherName,
};
},
onSuccess: ({ name, mode, stoppedOtherName }) => {
if (mode === 'switch' && stoppedOtherName) {
toast({
title: 'Switched incubation!',
description: `Stopped ${stoppedOtherName}, now incubating ${name}.`,
});
} else if (mode === 'restart') {
toast({
title: 'Incubation restarted!',
description: `${name}'s task progress has been reset.`,
});
} else {
toast({
title: 'Incubation started!',
description: `${name} is now incubating. Complete the tasks to hatch!`,
});
}
},
onError: (error: Error) => {
toast({
title: 'Failed to start incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Incubation Hook ─────────────────────────────────────────────────────
/**
* Parameters for stop incubation hook.
*/
export interface UseStopIncubationParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of stopping incubation.
*/
export interface StopIncubationResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the incubation process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart incubation later, but will need to complete tasks again.
*
* When stopping incubation:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in incubating state
* - User must be logged in
*/
export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopIncubationResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'incubating') {
throw new Error('This Blobbi is not incubating');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Incubation stopped',
description: `${name} is no longer incubating. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Start Evolution Hook ─────────────────────────────────────────────────────
/**
* Parameters for start evolution hook.
*/
export interface UseStartEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of starting evolution.
*/
export interface StartEvolutionResult {
/** The Blobbi's name */
name: string;
/** Timestamp when evolution started */
stateStartedAt: number;
}
/**
* Hook to start the evolution process for a baby Blobbi.
*
* This sets the Blobbi state to 'evolving' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* Requirements:
* - Blobbi must be in baby stage
* - Blobbi must not already be evolving
* - User must be logged in
*/
export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StartEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.stage !== 'baby') {
throw new Error('Only baby Blobbis can evolve');
}
if (companion.state === 'evolving') {
throw new Error('This Blobbi is already evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'evolving',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
stateStartedAt: now,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution started!',
description: `${name} is now working towards evolution. Complete the tasks to evolve!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to start evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Evolution Hook ──────────────────────────────────────────────────────
/**
* Parameters for stop evolution hook.
*/
export interface UseStopEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of stopping evolution.
*/
export interface StopEvolutionResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the evolution process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart evolution later, but will need to complete tasks again.
*
* When stopping evolution:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in evolving state
* - User must be logged in
*/
export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'evolving') {
throw new Error('This Blobbi is not evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution stopped',
description: `${name} is no longer evolving. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Sync Task Completions Hook ───────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_TASK_SYNC = import.meta.env.DEV;
/**
* Parameters for syncing task completions (works for both hatch and evolve).
*/
export interface UseSyncTaskCompletionsParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
/**
* Task completions to sync (from useHatchTasks or useEvolveTasks).
*/
export interface TaskCompletionToSync {
taskId: string;
completed: boolean;
}
/**
* Result of sync operation.
*/
export interface SyncTaskCompletionsResult {
/** Task IDs that were synced (empty if nothing needed) */
synced: string[];
/** Whether sync was skipped (no diff) */
skipped: boolean;
/** Reason for skip (for debugging) */
skipReason?: string;
}
/**
* Hook to sync persistent task completions to kind 31124 tags.
* Works for both hatch (incubating) and evolve (evolving) processes.
*
* CRITICAL: This is a cache-only sync. It must be:
* 1. Fully idempotent - calling multiple times with same data = no-op
* 2. Diff-based - only publish when tags would actually change
* 3. Safe - no last_interaction update (this is cache sync, not user action)
* 4. Only sync PERSISTENT tasks - dynamic tasks must NEVER be synced
*
* Source of truth = computed task state from Nostr events.
* Tags = cache layer for faster access.
*/
export function useSyncTaskCompletions({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseSyncTaskCompletionsParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (tasksToSync: TaskCompletionToSync[]): Promise<SyncTaskCompletionsResult> => {
// ─── Early Guards ───
if (!user?.pubkey) {
return { synced: [], skipped: true, skipReason: 'no_user' };
}
if (!companion) {
return { synced: [], skipped: true, skipReason: 'no_companion' };
}
// Must be in an active task process (incubating or evolving)
if (companion.state !== 'incubating' && companion.state !== 'evolving') {
return { synced: [], skipped: true, skipReason: 'not_in_task_process' };
}
// ─── Compute Diff ───
// Get cached completions from companion.tasksCompleted (parsed from tags)
const cachedCompletions = new Set(companion.tasksCompleted);
// Get computed completions from tasks (works for both hatch and evolve)
const computedCompletions = tasksToSync
.filter(t => t.completed)
.map(t => t.taskId);
// Find tasks that are computed as complete but NOT in cache
const missingFromCache = computedCompletions.filter(id => !cachedCompletions.has(id));
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Diff check:', {
cachedCompletions: Array.from(cachedCompletions),
computedCompletions,
missingFromCache,
});
}
// If no diff, skip entirely
if (missingFromCache.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: no diff between computed and cached');
}
return { synced: [], skipped: true, skipReason: 'no_diff' };
}
// ─── Ensure Canonical ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
return { synced: [], skipped: true, skipReason: 'canonical_failed' };
}
// ─── Build Updated Tags ───
// Re-check against canonical.allTags (may have updated since companion was parsed)
const existingCompletionTags = new Set(
canonical.allTags
.filter(tag => tag[0] === 'task_completed')
.map(tag => tag[1])
);
// Filter to only truly missing tags
const tagsToAdd = missingFromCache.filter(id => !existingCompletionTags.has(id));
if (tagsToAdd.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: all tags already exist in canonical');
}
return { synced: [], skipped: true, skipReason: 'tags_already_exist' };
}
// Add only the missing task_completed tags
// CRITICAL: Do NOT update last_interaction - this is cache sync, not user action
const updatedTags = [
...canonical.allTags,
...tagsToAdd.map(id => ['task_completed', id]),
];
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Publishing:', {
tagsToAdd,
totalTags: updatedTags.length,
});
}
// ─── Publish ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: updatedTags,
});
updateCompanionEvent(event);
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Published successfully:', tagsToAdd);
}
return { synced: tagsToAdd, skipped: false };
},
});
}
@@ -0,0 +1,407 @@
// src/blobbi/actions/hooks/useBlobbiStageTransition.ts
/**
* Hooks for Blobbi stage transitions (hatch, evolve).
*
* Both transitions follow the same decay pattern:
* 1. Apply accumulated decay from `last_decay_at` to `now`
* 2. Use decayed stats as the source of truth for the transition
* 3. Publish new event with decayed stats + new stage
* 4. Reset `last_decay_at` to current timestamp
*
* @see docs/blobbi/decay-system.md
*/
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
STAT_MAX,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
// ─── Content Helpers ──────────────────────────────────────────────────────────
/**
* Generate the content string for a Blobbi at a given stage.
* Format: "{name} is a {stage} Blobbi."
*
* Uses correct grammar: "an egg" vs "a baby/adult"
*/
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of ensuring canonical companion before action.
* This is the same interface used by useBlobbiUseInventoryItem.
*/
export interface CanonicalActionResult {
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
}
/**
* Parameters for stage transition hooks.
*/
export interface UseBlobbiStageTransitionParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
* Result of a stage transition.
*/
export interface StageTransitionResult {
/** Previous stage before transition */
previousStage: BlobbiStage;
/** New stage after transition */
newStage: BlobbiStage;
/** The Blobbi's name */
name: string;
/** Stats after decay was applied (before any transition bonuses) */
decayedStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
}
// ─── Hatch Hook ───────────────────────────────────────────────────────────────
/**
* Hook to hatch an egg into a baby Blobbi.
*
* Transition: egg -> baby
*
* Requirements:
* - Blobbi must be in egg stage
* - Applies accumulated decay before transition
* - Resets stats to healthy baby defaults (inherits health from egg)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiHatch({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to hatch');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be hatched');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for hatching');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Calculate Baby Stats ───
// All stats reset to 100 when hatching — the baby starts fresh
const babyStats = {
hunger: STAT_MAX,
happiness: STAT_MAX,
health: STAT_MAX,
hygiene: STAT_MAX,
energy: STAT_MAX,
};
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Build the updated tags using the central merge function
// Get streak updates (hatching counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'baby',
state: 'active', // Newly hatched babies are awake
hunger: babyStats.hunger.toString(),
happiness: babyStats.happiness.toString(),
health: babyStats.health.toString(),
hygiene: babyStats.hygiene.toString(),
energy: babyStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Hatch] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Baby Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'egg',
newStage: 'baby',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Your egg hatched!',
description: `${name} is now a baby Blobbi! Take good care of them.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to hatch',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Evolve Hook ──────────────────────────────────────────────────────────────
/**
* Hook to evolve a baby Blobbi into an adult.
*
* Transition: baby -> adult
*
* Requirements:
* - Blobbi must be in baby stage
* - Applies accumulated decay before transition
* - Preserves all stats (decay already applied)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiEvolve({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to evolve');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'baby') {
if (companion.stage === 'egg') {
throw new Error('Eggs must hatch before they can evolve');
}
if (companion.stage === 'adult') {
throw new Error('This Blobbi is already fully evolved');
}
throw new Error('Only baby Blobbis can evolve');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Adult Stats ───
// Adult inherits all decayed stats from baby
// No stat reset - evolution preserves current condition
const adultStats = decayResult.stats;
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Get streak updates (evolving counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// Build the updated tags using the central merge function
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'adult',
state: 'active', // Evolution completes with active state
hunger: adultStats.hunger.toString(),
happiness: adultStats.happiness.toString(),
health: adultStats.health.toString(),
hygiene: adultStats.hygiene.toString(),
energy: adultStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Evolve] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Evolve] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Adult Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'baby',
newStage: 'adult',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution complete!',
description: `${name} has evolved into an adult Blobbi!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to evolve',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,466 @@
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
getStageRestrictionMessage,
clampStat,
applyStat,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
incrementInteractionTaskTags,
type InventoryAction,
ACTION_METADATA,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an inventory item
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an inventory item
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
}
/**
* Parameters for the useBlobbiUseInventoryItem hook
*/
export interface UseBlobbiUseInventoryItemParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration (use instead of profile.allTags) */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Update profile event in local cache */
updateProfileEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an inventory item on a Blobbi companion.
*
* This hook:
* 1. Validates the companion stage (eggs can't use items)
* 2. Validates the item exists in storage
* 3. Ensures canonical format before action
* 4. Applies item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
* 6. Decrements item from profile storage (kind 11125)
* 7. Invalidates relevant queries
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
throw new Error(message ?? 'This companion cannot use this item');
}
// Validate item exists in shop catalog
const shopItem = getShopItemById(itemId);
if (!shopItem) {
throw new Error('Item not found in catalog');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
}
// For eggs, validate that items have applicable effects
const isEgg = companion.stage === 'egg';
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
throw new Error('This medicine has no effect on eggs');
}
if (isEgg && action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect)) {
throw new Error('This item has no cleaning effect on eggs');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any user interaction updates stats.
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Validate Play Energy Requirements ───
// For play actions, validate the Blobbi has enough energy AFTER decay
if (action === 'play') {
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
const currentEnergy = statsAfterDecay.energy;
if (energyCost > 0 && currentEnergy < energyCost) {
throw new Error(
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
);
}
// Also check if playing would have any effect at all
// If happiness is maxed AND we can't spend energy, playing is pointless
const happinessGain = shopItem.effect.happiness ?? 0;
const currentHappiness = statsAfterDecay.happiness;
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
if (!wouldGainHappiness && !wouldSpendEnergy) {
throw new Error(
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
);
}
}
// ─── Apply Item Effects ───
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
// won't give more than 100 health total.
//
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
// - clean: count when hygiene or happiness INCREASES
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
//
// Use canonical companion stage for egg checks
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
let effectiveItemCount = 0; // Number of items that produced intended effects
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
// Eggs use the 3-stat model: health, hygiene, happiness
// Medicine with health effect directly affects the egg's health stat
// hunger and energy remain fixed at 100 for eggs
const healthDelta = shopItem.effect.health ?? 0;
// Apply health effect N times in sequence with clamping at each step
// Only count items that actually INCREASED health (positive effect only)
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHealth = currentHealth;
currentHealth = applyStat(currentHealth, healthDelta);
// Only count as effective if health increased (not just changed)
if (healthDelta > 0 && currentHealth > prevHealth) {
effectiveItemCount++;
}
}
statsUpdate.health = currentHealth.toString();
// Track total actual change (may be less than healthDelta * quantity due to clamping)
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
// Apply decayed values for other egg stats
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
// Egg clean/hygiene handling:
// Hygiene items affect the egg's hygiene stat
// Some hygiene items also give happiness (e.g., bubble bath)
// hunger and energy remain fixed at 100 for eggs
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
// Apply effects N times in sequence
// Only count items that INCREASED hygiene or happiness (positive effects only)
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHygiene = currentHygiene;
const prevHappiness = currentHappiness;
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
// Count as effective if hygiene OR happiness increased (positive effects only)
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
if (hygieneIncreased || happinessIncreased) {
effectiveItemCount++;
}
}
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
statsUpdate.happiness = currentHappiness.toString();
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (totalHappinessChange !== 0) {
statsChanged.happiness = totalHappinessChange;
}
// Apply decayed health
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
// Apply item effects N times in sequence ON TOP of decayed stats
// Use action-aware effectiveness checking for XP calculation
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
const effect = shopItem.effect;
for (let i = 0; i < quantity; i++) {
const prevStats = { ...currentStats };
currentStats = applyItemEffects(currentStats, effect);
// Action-aware effectiveness check:
// Only count INTENDED positive effects, not negative side effects
let isEffective = false;
if (action === 'feed') {
// Feed: count when hunger/energy/health/happiness INCREASE
// Do NOT count hygiene decrease (that's a side effect)
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
} else if (action === 'clean') {
// Clean: count when hygiene or happiness INCREASES
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hygieneIncreased || happinessIncreased;
} else if (action === 'medicine') {
// Medicine: count when health/energy/happiness INCREASE
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = healthIncreased || energyIncreased || happinessIncreased;
} else if (action === 'play') {
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
// Playing naturally consumes energy, so energy decrease counts as valid
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
isEffective = happinessIncreased || energyDecreased;
}
if (isEffective) {
effectiveItemCount++;
}
}
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
statsUpdate.energy = clampStat(currentStats.energy).toString();
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
statsUpdate.health = clampStat(currentStats.health).toString();
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (Based on effective item count) ───
// Only grant XP for items that actually changed stats.
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
// This prevents XP farming by mass-using items after stats are already maxed.
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
// instead of profile.storage/profile.allTags to avoid restoring
// stale/legacy values after migration
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
invalidateProfile();
return {
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Failed to use item',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,242 @@
/**
* useClaimMissionReward - Hook for claiming daily mission rewards
*
* Handles:
* - Persisting coin rewards to kind 11125 Blobbonaut profile
* - Updating localStorage mission state
* - Idempotent claiming (prevents double-credit)
* - Optimistic cache updates
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
} from '@/blobbi/core/lib/blobbi';
import {
type DailyMissionsState,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ClaimMissionRequest {
missionId: string;
}
/** Special ID for claiming the bonus mission */
export const BONUS_MISSION_ID = 'bonus_daily_complete';
export interface ClaimMissionResult {
missionId: string;
coinsEarned: number;
newTotalCoins: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useClaimMissionReward] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to claim daily mission rewards.
*
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
* ensuring rewards are stored on-chain rather than just in localStorage.
*
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
* @param updateProfileEvent - Callback to update the profile in the query cache
*/
export function useClaimMissionReward(
currentProfile: BlobbonautProfile | null,
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ missionId }: ClaimMissionRequest): Promise<ClaimMissionResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to claim rewards');
}
if (!currentProfile) {
throw new Error('Profile not found');
}
// Read current missions state from localStorage
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
}
// Handle bonus mission claim
if (missionId === BONUS_MISSION_ID) {
// Check if bonus is available
if (!isBonusMissionAvailable(missionsState!)) {
throw new Error('Bonus mission not available yet');
}
// Check if already claimed
if (isBonusMissionClaimed(missionsState!)) {
throw new Error('Bonus reward already claimed');
}
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
// Publish updated profile event
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache
updateProfileEvent(event);
// Update localStorage to mark bonus as claimed
const updatedState: DailyMissionsState = {
...missionsState!,
bonusClaimed: true,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true, isBonus: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
}
// Handle regular mission claim
const mission = missionsState!.missions.find(m => m.id === missionId);
if (!mission) {
throw new Error('Mission not found');
}
// Check if already claimed (idempotency check)
if (mission.claimed) {
throw new Error('Reward already claimed');
}
// Check if mission is completed
if (!mission.completed) {
throw new Error('Mission not completed yet');
}
const coinsToAdd = mission.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
// Publish updated profile event to kind 11125
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache optimistically
updateProfileEvent(event);
// Now update localStorage to mark mission as claimed
const updatedMissions = missionsState!.missions.map(m =>
m.id === missionId ? { ...m, claimed: true } : m
);
const updatedState: DailyMissionsState = {
...missionsState!,
missions: updatedMissions,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
},
onSuccess: ({ coinsEarned }) => {
// Invalidate profile query to ensure fresh data
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
}
// Show success toast
toast({
title: 'Reward Claimed!',
description: `You earned ${coinsEarned} coins.`,
});
},
onError: (error: Error) => {
// Don't show error for already claimed (user might have double-clicked)
if (error.message === 'Reward already claimed' || error.message === 'Bonus reward already claimed') {
return;
}
toast({
title: 'Failed to Claim Reward',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,201 @@
/**
* useDailyMissions - Hook for managing Blobbi daily missions
*
* Provides:
* - Daily mission state management with localStorage persistence
* - Automatic daily reset
* - Progress tracking functions
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
* - Stage-based filtering (only shows missions user can complete)
* - Bonus mission tracking
*
* Note: Reward claiming should be done via useClaimMissionReward hook,
* which persists coins to the kind 11125 Blobbonaut profile.
*/
import { useMemo, useEffect, useState, useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
areAllMissionsCompleted,
areAllMissionsClaimed,
getTotalPotentialReward,
getTodayClaimedReward,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
getRerollsRemaining,
MAX_DAILY_REROLLS,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseDailyMissionsOptions {
/** Available Blobbi stages the user has (filters eligible missions) */
availableStages?: BlobbiStage[];
}
export interface UseDailyMissionsResult {
/** Current daily missions state */
missions: DailyMission[];
/** Whether all missions are completed */
allCompleted: boolean;
/** Whether all missions are claimed */
allClaimed: boolean;
/** Total potential reward for today (including bonus if available) */
totalPotentialReward: number;
/** Total claimed reward for today */
todayClaimedReward: number;
/** Lifetime total coins earned from daily missions */
lifetimeCoinsEarned: number;
/** Whether the bonus mission is available (all regular missions completed) */
bonusAvailable: boolean;
/** Whether the bonus mission has been claimed */
bonusClaimed: boolean;
/** Bonus mission reward amount */
bonusReward: number;
/** Whether user has no eligible missions (e.g., only eggs) */
noMissionsAvailable: boolean;
/** Number of rerolls remaining for today */
rerollsRemaining: number;
/** Maximum rerolls allowed per day */
maxRerolls: number;
/** Force refresh missions (for testing or manual reset) */
forceReset: () => void;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useDailyMissions] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
const { availableStages } = options;
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
// Read state directly from localStorage, with a version counter to trigger re-reads
const [version, setVersion] = useState(0);
// Read from localStorage on every render when version changes
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
const state = useMemo(() => readMissionsState(), [version]);
// Wrapper to write state and update version
const setState = useCallback((newState: DailyMissionsState) => {
writeMissionsState(newState);
setVersion((v) => v + 1);
}, []);
// Listen for external updates from mutations (reroll, claim, progress tracking)
// This re-reads localStorage when other hooks modify it directly
useEffect(() => {
const handleExternalUpdate = () => {
// Bump version to trigger a re-read from localStorage
setVersion((v) => v + 1);
};
window.addEventListener('daily-missions-updated', handleExternalUpdate);
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
}, []);
// Stable key for availableStages to use in dependencies
const stagesKey = availableStages?.sort().join(',') ?? '';
// Ensure we have valid state for today
const currentState = useMemo(() => {
// Check if we need to reset for a new day
if (needsDailyReset(state)) {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
// Persist the reset state (this will trigger version bump via setState)
writeMissionsState(newState);
return newState;
}
// Migration: ensure rerollsRemaining is set for old state
if (state && state.rerollsRemaining === undefined) {
const migratedState = {
...state,
rerollsRemaining: MAX_DAILY_REROLLS,
};
writeMissionsState(migratedState);
return migratedState;
}
return state!;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, pubkey, stagesKey]);
// Force reset missions (for testing)
const forceReset = () => {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
setState(newState);
};
// Computed values
const missions = currentState.missions;
const allCompleted = areAllMissionsCompleted(currentState);
const allClaimed = areAllMissionsClaimed(currentState);
const bonusAvailable = isBonusMissionAvailable(currentState);
const bonusClaimed = isBonusMissionClaimed(currentState);
const bonusReward = BONUS_MISSION_DEFINITION.reward;
const noMissionsAvailable = missions.length === 0;
const rerollsRemaining = getRerollsRemaining(currentState);
const maxRerolls = MAX_DAILY_REROLLS;
// Total potential includes bonus if regular missions exist
const basePotentialReward = getTotalPotentialReward(currentState);
const totalPotentialReward = missions.length > 0
? basePotentialReward + bonusReward
: 0;
// Today's claimed includes bonus if claimed
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
return {
missions,
allCompleted,
allClaimed,
totalPotentialReward,
todayClaimedReward,
lifetimeCoinsEarned,
bonusAvailable,
bonusClaimed,
bonusReward,
noMissionsAvailable,
rerollsRemaining,
maxRerolls,
forceReset,
};
}
+362
View File
@@ -0,0 +1,362 @@
// src/blobbi/actions/hooks/useEvolveTasks.ts
/**
* Hook to compute evolve task progress from Nostr events and current stats.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
KIND_PROFILE_METADATA,
KIND_SHORT_TEXT_NOTE,
BLOBBI_POST_REQUIRED_HASHTAGS,
sanitizeToHashtag,
type HatchTask,
type TaskType,
} from './useHatchTasks';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for wall edit events */
export const KIND_WALL_EDIT = 16769;
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
/** Required color moments for evolve task */
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
/** Required posts for evolve task (lighter than hatch - just 1 evolve-specific post) */
export const EVOLVE_REQUIRED_POSTS = 1;
/** Required interactions for evolve task */
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
/** Prefix text for Blobbi evolve post */
export const BLOBBI_EVOLVE_POST_PREFIX = 'Hello Nostr! Posting to evolve';
/** Stat threshold for evolve dynamic task (all stats >= 80) */
export const EVOLVE_STAT_THRESHOLD = 80;
// ─── Types ────────────────────────────────────────────────────────────────────
// Re-export task types for convenience
export type { HatchTask as EvolveTask, TaskType };
/**
* Result of computing evolve tasks.
*/
export interface EvolveTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to evolve */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Check if a post is a valid Blobbi evolve post.
* Must contain the evolve prefix and all required hashtags including the Blobbi name.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
*/
export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with evolve prefix
if (!event.content.startsWith(BLOBBI_EVOLVE_POST_PREFIX)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (!hasRequiredHashtags) {
return false;
}
// Blobbi name hashtag must also be present
const blobbiHashtag = sanitizeToHashtag(blobbiName);
return hashtags.includes(blobbiHashtag);
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute evolve task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create 3 Themes (kind 36767)
* 2. Create 3 Color Moments (kind 3367)
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
* 4. Interact 21 times (tracked via companion.tasks cache)
* 5. Edit Wall once (kind 16769)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 6. Maintain All Stats >= 80
*
* @param companion - The Blobbi companion (must be in evolving state)
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useEvolveTasks(
companion: BlobbiCompanion | null,
interactionCount?: number
): EvolveTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isEvolving = companion?.state === 'evolving';
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['evolve-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid evolve posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Only need 1 valid evolve post
},
// Wall edits after start
{
kinds: [KIND_WALL_EDIT],
authors: [pubkey],
since: stateStartedAt,
limit: 1, // Only need 1
},
// Profile metadata after start (for Blobbi shape check)
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
const wallEditEvents = events.filter(e =>
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
);
// Get latest profile after start
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents,
colorMomentEvents,
postEvents,
wallEditEvents,
profileAfter,
};
},
enabled: !!pubkey && !!stateStartedAt && isEvolving,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create 3 Themes (PERSISTENT)
const themeCount = data?.themeEvents?.length ?? 0;
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
tasks.push({
id: 'create_themes',
name: 'Create Themes',
description: `Create ${EVOLVE_REQUIRED_THEMES} custom themes`,
current: Math.min(themeCount, EVOLVE_REQUIRED_THEMES),
required: EVOLVE_REQUIRED_THEMES,
completed: themesCompleted,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// 2. Create 3 Color Moments (PERSISTENT)
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
tasks.push({
id: 'color_moments',
name: 'Color Moments',
description: `Share ${EVOLVE_REQUIRED_COLOR_MOMENTS} color moments on espy`,
current: Math.min(colorMomentCount, EVOLVE_REQUIRED_COLOR_MOMENTS),
required: EVOLVE_REQUIRED_COLOR_MOMENTS,
completed: colorMomentsCompleted,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create 1 Evolve Post (PERSISTENT) - lighter than hatch
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidEvolvePost(e, blobbiName)) ?? [];
const postCount = validPosts.length;
const postsCompleted = postCount >= EVOLVE_REQUIRED_POSTS;
tasks.push({
id: 'create_posts',
name: 'Share Evolution',
description: 'Post about your Blobbi evolving',
current: Math.min(postCount, EVOLVE_REQUIRED_POSTS),
required: EVOLVE_REQUIRED_POSTS,
completed: postsCompleted,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 4. Interact 21 times (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${EVOLVE_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, EVOLVE_REQUIRED_INTERACTIONS),
required: EVOLVE_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// 5. Edit Wall once (PERSISTENT)
const wallEditCount = data?.wallEditEvents?.length ?? 0;
const hasWallEdit = wallEditCount >= 1;
tasks.push({
id: 'edit_wall',
name: 'Edit Your Wall',
description: 'Customize your profile wall',
current: hasWallEdit ? 1 : 0,
required: 1,
completed: hasWallEdit,
type: 'persistent',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Wall',
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
// 7. Maintain All Stats >= 80
const stats = companion?.stats ?? {};
const hunger = stats.hunger ?? 0;
const happiness = stats.happiness ?? 0;
const health = stats.health ?? 0;
const hygiene = stats.hygiene ?? 0;
const energy = stats.energy ?? 0;
const statsOk =
hunger >= EVOLVE_STAT_THRESHOLD &&
happiness >= EVOLVE_STAT_THRESHOLD &&
health >= EVOLVE_STAT_THRESHOLD &&
hygiene >= EVOLVE_STAT_THRESHOLD &&
energy >= EVOLVE_STAT_THRESHOLD;
// Calculate minimum stat for progress display
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
tasks.push({
id: 'maintain_stats',
name: 'Peak Condition',
description: `Keep all stats above ${EVOLVE_STAT_THRESHOLD}`,
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
required: EVOLVE_STAT_THRESHOLD,
completed: statsOk,
type: 'dynamic', // CRITICAL: Never persist this task
// No action - just care for your Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Get the current interaction count for evolve from companion task cache.
*/
export function getEvolveInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
+358
View File
@@ -0,0 +1,358 @@
// src/blobbi/actions/hooks/useHatchTasks.ts
/**
* Hook to compute hatch task progress from Nostr events.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
*
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for theme definition events */
export const KIND_THEME_DEFINITION = 36767;
/** Kind for color moment events (espy.you) */
export const KIND_COLOR_MOMENT = 3367;
/** Kind for profile metadata */
export const KIND_PROFILE_METADATA = 0;
/** Kind for short text notes */
export const KIND_SHORT_TEXT_NOTE = 1;
/** Required interactions to complete the hatch interactions task */
export const HATCH_REQUIRED_INTERACTIONS = 7;
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
/** Prefix text for Blobbi hatch post */
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
// Legacy export for backwards compatibility
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
/**
* Sanitize a name into a valid hashtag format.
* Must match the implementation in BlobbiPostModal.tsx.
*/
export function sanitizeToHashtag(name: string): string {
return name
.toLowerCase()
// Remove emojis and special characters, keep letters, numbers, underscores
.replace(/[^\p{L}\p{N}_]/gu, '')
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
.replace(/^(\d)/, 'blobbi$1')
// Limit length
.slice(0, 30)
// Fallback if empty
|| 'myblobbi';
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Task type classification.
* - persistent: Based on Nostr events, can be cached in tags
* - dynamic: Based on current stats, NEVER stored in tags
*/
export type TaskType = 'persistent' | 'dynamic';
/**
* Individual task definition.
*/
export interface HatchTask {
id: string;
name: string;
description: string;
/** Current progress value */
current: number;
/** Required value for completion */
required: number;
/** Whether the task is complete */
completed: boolean;
/** Task type - persistent (event-based) or dynamic (stat-based) */
type: TaskType;
/** Action to perform (if applicable) */
action?: 'navigate' | 'open_modal' | 'external_link';
/** Target for the action */
actionTarget?: string;
/** Button label */
actionLabel?: string;
}
/**
* Result of computing hatch tasks.
*/
export interface HatchTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to hatch */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Check if a post is a valid Blobbi hatch post.
* Must contain the required prefix and all required hashtags including the Blobbi name.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
*/
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with prefix
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (!hasRequiredHashtags) {
return false;
}
// Blobbi name hashtag must also be present
const blobbiHashtag = sanitizeToHashtag(blobbiName);
return hashtags.includes(blobbiHashtag);
}
// Legacy function name for backwards compatibility
export const isValidBlobbiPost = isValidHatchPost;
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute hatch task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create Theme (kind 36767) - ≥1 event after start
* 2. Color Moment (kind 3367) - ≥1 event after start
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
* 4. Interactions - 7 total (tracked via companion.tasks cache)
*
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
*
* @param companion - The Blobbi companion (must be incubating)
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useHatchTasks(
companion: BlobbiCompanion | null,
interactionCount?: number
): HatchTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isIncubating = companion?.state === 'incubating';
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['hatch-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid Blobbi posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Reasonable limit
},
// Profile metadata - need both before and after start
// Get latest before start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
until: stateStartedAt,
limit: 1,
},
// Get latest after start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
// Separate profile events into before and after
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileBefore = profileEvents
.filter(e => e.created_at < stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents,
colorMomentEvents,
postEvents,
profileBefore,
profileAfter,
};
},
enabled: !!pubkey && !!stateStartedAt && isIncubating,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create Theme (PERSISTENT)
const hasTheme = (data?.themeEvents?.length ?? 0) >= 1;
tasks.push({
id: 'create_theme',
name: 'Create Theme',
description: 'Create a custom theme for your profile',
current: hasTheme ? 1 : 0,
required: 1,
completed: hasTheme,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// 2. Color Moment (PERSISTENT)
const hasColorMoment = (data?.colorMomentEvents?.length ?? 0) >= 1;
tasks.push({
id: 'color_moment',
name: 'Color Moment',
description: 'Share a color moment on espy',
current: hasColorMoment ? 1 : 0,
required: 1,
completed: hasColorMoment,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create Post (PERSISTENT)
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
const hasValidPost = validPosts.length >= 1;
tasks.push({
id: 'create_post',
name: 'Create Post',
description: 'Share a post about hatching your Blobbi',
current: hasValidPost ? 1 : 0,
required: 1,
completed: hasValidPost,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 5. Interactions (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= HATCH_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${HATCH_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, HATCH_REQUIRED_INTERACTIONS),
required: HATCH_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Get the current interaction count from companion task cache.
*/
export function getInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
/**
* Filter tasks to only persistent tasks (for tag sync).
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
@@ -0,0 +1,159 @@
/**
* useRerollMission - Hook for rerolling daily missions
*
* Handles:
* - Replacing a mission with a new one from the pool
* - Tracking reroll usage (max 3 per day)
* - Respecting stage-based mission filtering
* - Persisting state to localStorage
*/
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
rerollMission,
canRerollMission,
getRerollsRemaining,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface RerollMissionRequest {
missionId: string;
availableStages?: BlobbiStage[];
}
export interface RerollMissionResult {
oldMissionId: string;
newMission: DailyMission;
rerollsRemaining: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const state = JSON.parse(stored) as DailyMissionsState;
// Migration: ensure rerollsRemaining is set for old state
if (state.rerollsRemaining === undefined) {
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
}
return state;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useRerollMission] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to reroll a daily mission.
*
* Replaces the specified mission with a new one from the pool,
* respecting stage-based filtering and avoiding duplicates.
*/
export function useRerollMission() {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to reroll missions');
}
// Read current missions state from localStorage
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
}
// Check if reroll is allowed
if (!canRerollMission(missionsState!, missionId)) {
const rerollsLeft = getRerollsRemaining(missionsState!);
if (rerollsLeft <= 0) {
throw new Error('No rerolls remaining today');
}
const mission = missionsState!.missions.find(m => m.id === missionId);
if (mission?.completed || mission?.claimed) {
throw new Error('Cannot reroll completed or claimed missions');
}
throw new Error('Cannot reroll this mission');
}
// Perform the reroll
const result = rerollMission(missionsState!, missionId, availableStages);
if (!result) {
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
}
// Persist the updated state
writeMissionsState(result.state);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: {
missionId,
rerolled: true,
newMissionId: result.newMission.id,
}
}));
return {
oldMissionId: missionId,
newMission: result.newMission,
rerollsRemaining: getRerollsRemaining(result.state),
};
},
onSuccess: ({ newMission, rerollsRemaining }) => {
const rerollText = rerollsRemaining === 1
? '1 reroll left'
: rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} rerolls left`;
toast({
title: 'Mission Replaced',
description: `New mission: ${newMission.title}. ${rerollText}.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to Reroll',
description: error.message,
variant: 'destructive',
});
},
});
}
+190
View File
@@ -0,0 +1,190 @@
// src/blobbi/actions/index.ts
// Components
export { BlobbiActionsModal } from './components/BlobbiActionsModal';
export { BlobbiActionInventoryModal } from './components/BlobbiActionInventoryModal';
export { PlayMusicModal } from './components/PlayMusicModal';
export { SingModal } from './components/SingModal';
export { InlineMusicPlayer } from './components/InlineMusicPlayer';
export { InlineSingCard } from './components/InlineSingCard';
export { HatchTasksPanel } from './components/HatchTasksPanel';
export { TasksPanel } from './components/TasksPanel';
export { BlobbiPostModal } from './components/BlobbiPostModal';
export { StartIncubationDialog } from './components/StartIncubationDialog';
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
// Hooks
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
export type { UseItemRequest, UseItemResult, UseBlobbiUseInventoryItemParams } from './hooks/useBlobbiUseInventoryItem';
export { useBlobbiHatch, useBlobbiEvolve } from './hooks/useBlobbiStageTransition';
export type {
UseBlobbiStageTransitionParams,
StageTransitionResult,
CanonicalActionResult,
} from './hooks/useBlobbiStageTransition';
export {
useStartIncubation,
useStopIncubation,
useStartEvolution,
useStopEvolution,
useSyncTaskCompletions,
} from './hooks/useBlobbiIncubation';
export type {
StartIncubationMode,
StartIncubationRequest,
UseStartIncubationParams,
StartIncubationResult,
UseStopIncubationParams,
StopIncubationResult,
UseStartEvolutionParams,
StartEvolutionResult,
UseStopEvolutionParams,
StopEvolutionResult,
UseSyncTaskCompletionsParams,
TaskCompletionToSync,
} from './hooks/useBlobbiIncubation';
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from './hooks/useActiveTaskProcess';
export {
useHatchTasks,
getInteractionCount,
filterPersistentTasks,
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
REQUIRED_INTERACTIONS, // Legacy export
BLOBBI_POST_PREFIX,
BLOBBI_POST_REQUIRED_HASHTAGS,
} from './hooks/useHatchTasks';
export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTasks';
export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_WALL_EDIT,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_STAT_THRESHOLD,
BLOBBI_EVOLVE_POST_PREFIX,
} from './hooks/useEvolveTasks';
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
export { useBlobbiDirectAction, DIRECT_ACTION_HAPPINESS_EFFECTS } from './hooks/useBlobbiDirectAction';
export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionParams } from './hooks/useBlobbiDirectAction';
export { useAudioPlayback } from './hooks/useAudioPlayback';
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
// Track catalog
export {
BLOBBI_TRACK_CATALOG,
getAllTracks,
getTrackById,
formatTrackDuration,
type BlobbiTrack,
} from './lib/blobbi-track-catalog';
// Activity state
export {
createMusicActivity,
createSingActivity,
createNoActivity,
type InlineActivityType,
type InlineActivityState,
type MusicActivityState,
type SingActivityState,
type NoActivityState,
type BlobbiReactionState,
type SelectedTrack,
} from './lib/blobbi-activity-state';
// Re-export stat bounds from canonical source
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
// Utilities
export {
// Types
type InventoryAction,
type DirectAction,
type BlobbiAction,
type ResolvedInventoryItem,
type EggStatPreview,
type ItemUsabilityResult,
type IncrementInteractionResult,
// Constants
ACTION_TO_ITEM_TYPE,
ACTION_METADATA,
DIRECT_ACTION_METADATA,
ALL_ACTION_METADATA,
GENERAL_ITEM_USABLE_STAGES,
EGG_ALLOWED_ACTIONS,
EGG_ALLOWED_INVENTORY_ACTIONS,
EGG_ALLOWED_DIRECT_ACTIONS,
EGG_VISIBLE_INVENTORY_ACTIONS,
EGG_VISIBLE_ACTIONS,
SHELL_REPAIR_KIT_ID,
// Functions
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
isActionVisibleForStage,
canUseInventoryItems,
getStageRestrictionMessage,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
canUseItemForStage,
getActionForItem,
incrementInteractionTaskTags,
} from './lib/blobbi-action-utils';
// Daily Missions
export { useDailyMissions } from './hooks/useDailyMissions';
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export {
trackDailyMissionProgress,
trackMultipleDailyMissionActions,
} from './lib/daily-mission-tracker';
export type {
DailyMission,
DailyMissionAction,
DailyMissionDefinition,
DailyMissionsState,
} from './lib/daily-missions';
// Streak tracking
export {
calculateStreakUpdate,
getStreakTagUpdates,
needsStreakUpdate,
getStreakStatus,
} from './lib/blobbi-streak';
export type {
StreakUpdateResult,
StreakTagUpdates,
} from './lib/blobbi-streak';
export { useBlobbiCareActivity } from './hooks/useBlobbiCareActivity';
export type {
UseBlobbiCareActivityParams,
CareActivityResult,
} from './hooks/useBlobbiCareActivity';
@@ -0,0 +1,639 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Actions that consume inventory items
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Non-inventory actions that don't consume items
* These actions affect stats directly without using shop items.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (inventory + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
/**
* Mapping from action type to allowed item categories
*/
export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
feed: 'food',
play: 'toy',
clean: 'hygiene',
medicine: 'medicine',
};
/**
* Action metadata for UI display (inventory actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
label: 'Feed',
description: 'Feed your Blobbi',
icon: '🍎',
},
play: {
label: 'Play',
description: 'Play with your Blobbi',
icon: '⚽',
},
clean: {
label: 'Clean',
description: 'Clean your Blobbi',
icon: '🧼',
},
medicine: {
label: 'Medicine',
description: 'Heal your Blobbi',
icon: '💊',
},
};
/**
* Action metadata for direct actions (non-inventory)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
label: 'Play Music',
description: 'Play music for your Blobbi',
icon: '🎵',
},
sing: {
label: 'Sing',
description: 'Sing to your Blobbi',
icon: '🎤',
},
};
/**
* Combined action metadata for all action types
*/
export const ALL_ACTION_METADATA: Record<BlobbiAction, { label: string; description: string; icon: string }> = {
...ACTION_METADATA,
...DIRECT_ACTION_METADATA,
};
// ─── Stat Helpers ─────────────────────────────────────────────────────────────
// STAT_MIN and STAT_MAX are imported from @/lib/blobbi (single source of truth)
/**
* Clamp a stat value between STAT_MIN (1) and STAT_MAX (100).
* Safe for undefined values (returns STAT_MIN).
*
* The minimum of 1 (instead of 0) ensures:
* - Blobbi is never in an unrecoverable state
* - Visual feedback shows critical state without being "dead"
* - Recovery is always possible with any healing item
*/
export function clampStat(value: number | undefined): number {
if (value === undefined) return STAT_MIN;
return Math.max(STAT_MIN, Math.min(STAT_MAX, Math.round(value)));
}
/**
* Apply a delta to a stat, clamping the result to STAT_MIN-STAT_MAX.
*/
export function applyStat(current: number | undefined, delta: number): number {
const currentValue = current ?? STAT_MIN;
return clampStat(currentValue + delta);
}
/**
* Apply item effects to current stats.
* Returns a new partial stats object with all affected stats clamped.
* Only modifies stats that have corresponding effects.
*/
export function applyItemEffects(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect
): Partial<BlobbiStats> {
const newStats: Partial<BlobbiStats> = { ...currentStats };
if (effects.hunger !== undefined) {
newStats.hunger = applyStat(currentStats.hunger, effects.hunger);
}
if (effects.happiness !== undefined) {
newStats.happiness = applyStat(currentStats.happiness, effects.happiness);
}
if (effects.energy !== undefined) {
newStats.energy = applyStat(currentStats.energy, effects.energy);
}
if (effects.hygiene !== undefined) {
newStats.hygiene = applyStat(currentStats.hygiene, effects.hygiene);
}
if (effects.health !== undefined) {
newStats.health = applyStat(currentStats.health, effects.health);
}
return newStats;
}
// ─── Egg-Specific Item Helpers ────────────────────────────────────────────────
/**
* The Shell Repair Kit is a special medicine item only usable by eggs.
*/
export const SHELL_REPAIR_KIT_ID = 'med_shell_repair';
/**
* Result of checking if an item can be used by a specific Blobbi stage.
*/
export interface ItemUsabilityResult {
canUse: boolean;
reason?: string;
}
/**
* Check if a specific item can be used by a companion at the given stage.
*
* This is the centralized item usability logic:
* - Shell Repair Kit: Only usable by eggs
* - Food items: Only usable by baby/adult (not eggs)
* - Toy items: Only usable by baby/adult (not eggs)
* - Medicine items (except Shell Repair Kit): Usable by all stages with health effect
* - Hygiene items: Usable by all stages
*
* @param itemId - The shop item ID
* @param stage - The companion's life stage
* @returns Object with canUse boolean and optional reason string
*/
export function canUseItemForStage(
itemId: string,
stage: 'egg' | 'baby' | 'adult'
): ItemUsabilityResult {
const shopItem = getShopItemById(itemId);
if (!shopItem) {
return { canUse: false, reason: 'Item not found' };
}
const isEgg = stage === 'egg';
// Shell Repair Kit special case: only for eggs
if (itemId === SHELL_REPAIR_KIT_ID) {
if (!isEgg) {
return { canUse: false, reason: 'Only usable for eggs' };
}
return { canUse: true };
}
// Food items: not usable by eggs
if (shopItem.type === 'food') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot eat food' };
}
return { canUse: true };
}
// Toy items: not usable by eggs
if (shopItem.type === 'toy') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot use toys' };
}
return { canUse: true };
}
// Medicine items (except Shell Repair Kit): check for health effect
if (shopItem.type === 'medicine') {
if (!hasMedicineEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This medicine has no effect' };
}
return { canUse: true };
}
// Hygiene items: all stages can use
if (shopItem.type === 'hygiene') {
if (!hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This item has no cleaning effect' };
}
return { canUse: true };
}
// Accessories are disabled
if (shopItem.type === 'accessory') {
return { canUse: false, reason: 'Accessories are not usable yet' };
}
return { canUse: true };
}
/**
* Get the action type for a given item.
*/
export function getActionForItem(itemId: string): InventoryAction | null {
const shopItem = getShopItemById(itemId);
if (!shopItem) return null;
const typeToAction: Record<string, InventoryAction> = {
food: 'feed',
toy: 'play',
hygiene: 'clean',
medicine: 'medicine',
};
return typeToAction[shopItem.type] ?? null;
}
/**
* Check if a medicine item has any effect on an egg.
*
* Eggs use the standard 3-stat model:
* - health
* - hygiene
* - happiness
*
* Medicine with a health effect will directly affect the egg's health stat.
*/
export function hasMedicineEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.health !== undefined && effects.health !== 0;
}
/**
* Check if a hygiene item has any effect on an egg.
* Hygiene items with a hygiene effect will directly affect the egg's hygiene stat.
*/
export function hasHygieneEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.hygiene !== undefined && effects.hygiene !== 0;
}
/**
* Check if an item has a happiness effect for an egg.
* Some items (like bubble bath) give happiness bonus in addition to primary effects.
*/
export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Inventory Helpers ────────────────────────────────────────────────────────
/**
* Resolved inventory item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
quantity: number;
name: string;
icon: string;
type: ShopItemCategory;
effect?: ItemEffect;
}
/**
* Options for filtering inventory by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
stage?: 'egg' | 'baby' | 'adult';
}
/**
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
*
* Filtering rules:
* - Only items matching the action's item type are included
* - Shell Repair Kit only appears in medicine modal for eggs
* - For eggs: only items with egg-compatible effects are returned
* - medicine action: only items with health effect
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
if (shopItem.type !== allowedType) continue;
if (storageItem.quantity <= 0) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
// For eggs, filter items by egg-compatible effects
if (isEgg) {
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
continue; // Skip medicine without health effect
}
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
continue; // Skip hygiene items without hygiene or happiness effect
}
}
result.push({
itemId: storageItem.itemId,
quantity: storageItem.quantity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
effect: shopItem.effect,
});
}
return result;
}
/**
* Decrement item quantity in storage array.
* If quantity becomes 0, removes the item entirely.
* Returns a new storage array (immutable).
*/
export function decrementStorageItem(
storage: StorageItem[],
itemId: string,
amount = 1
): StorageItem[] {
const result: StorageItem[] = [];
for (const item of storage) {
if (item.itemId !== itemId) {
result.push(item);
continue;
}
const newQuantity = item.quantity - amount;
if (newQuantity > 0) {
result.push({ ...item, quantity: newQuantity });
}
// If newQuantity <= 0, we don't add it (remove item)
}
return result;
}
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general inventory items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
/**
* Inventory actions that are allowed for eggs.
* Eggs can use: medicine (health), clean (hygiene)
*/
export const EGG_ALLOWED_INVENTORY_ACTIONS: InventoryAction[] = ['medicine', 'clean'];
/**
* Direct actions that are allowed for eggs.
* All direct actions work on eggs.
*/
export const EGG_ALLOWED_DIRECT_ACTIONS: DirectAction[] = ['play_music', 'sing'];
/**
* Inventory actions visible in the egg UI.
* Note: feed, play, sleep are hidden in the UI for eggs but not hard-blocked.
*/
export const EGG_VISIBLE_INVENTORY_ACTIONS: InventoryAction[] = ['clean', 'medicine'];
/**
* All actions visible in the egg UI.
*/
export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_music', 'sing'];
/**
* @deprecated Use EGG_ALLOWED_INVENTORY_ACTIONS instead
*/
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific inventory action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all inventory actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
/**
* Check if a companion can use a specific direct action.
* Direct actions (play_music, sing) are available for all stages.
*/
export function canUseDirectAction(_companion: BlobbiCompanion, _action: DirectAction): boolean {
// All stages can use direct actions
return true;
}
/**
* Check if an action should be visible in the UI for a given stage.
* This is for UI filtering only - some actions are hidden but not blocked.
*/
export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action: BlobbiAction): boolean {
if (stage === 'egg') {
return EGG_VISIBLE_ACTIONS.includes(action);
}
return true; // baby and adult see all actions
}
/**
* Check if a companion can use general inventory items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
export function canUseInventoryItems(companion: BlobbiCompanion): boolean {
return GENERAL_ITEM_USABLE_STAGES.includes(companion.stage as typeof GENERAL_ITEM_USABLE_STAGES[number]);
}
/**
* Get a user-friendly message explaining why an action can't be used.
*/
export function getStageRestrictionMessage(companion: BlobbiCompanion, action?: InventoryAction): string | null {
if (companion.stage === 'egg') {
if (action && EGG_ALLOWED_INVENTORY_ACTIONS.includes(action)) {
return null; // Medicine and clean are allowed for eggs
}
return 'Eggs cannot use this item. Wait for your Blobbi to hatch!';
}
return null;
}
// ─── Stats Preview ────────────────────────────────────────────────────────────
/**
* Preview stats after applying an item's effects.
* Useful for showing the user what will happen before confirming.
*/
export function previewStatChanges(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect | undefined
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
if (!effects) return [];
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'energy', 'hygiene', 'health'];
for (const stat of statKeys) {
const delta = effects[stat];
if (delta !== undefined && delta !== 0) {
const current = currentStats[stat] ?? 0;
const after = clampStat(current + delta);
changes.push({ stat, current, after, delta });
}
}
return changes;
}
/**
* Preview stat change for an egg.
* Eggs use the 3-stat model: health, hygiene, happiness.
*/
export type EggStatPreview = { stat: 'health' | 'hygiene' | 'happiness'; current: number; after: number; delta: number };
/**
* Preview medicine effects for an egg.
* Medicine directly affects the egg's health stat.
*/
export function previewMedicineForEgg(
currentHealth: number | undefined,
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects || effects.health === undefined || effects.health === 0) {
return [];
}
const current = currentHealth ?? 100;
const delta = effects.health;
const after = clampStat(current + delta);
return [{ stat: 'health', current, after, delta }];
}
/**
* Preview clean (hygiene) effects for an egg.
* Hygiene items directly affect the egg's hygiene stat.
* May also include happiness bonus if the item has one.
*/
export function previewCleanForEgg(
currentStats: { hygiene?: number; happiness?: number },
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects) return [];
const results: EggStatPreview[] = [];
// Hygiene effect
if (effects.hygiene !== undefined && effects.hygiene !== 0) {
const current = currentStats.hygiene ?? 100;
const delta = effects.hygiene;
const after = clampStat(current + delta);
results.push({ stat: 'hygiene', current, after, delta });
}
// Happiness bonus (some hygiene items like bubble bath give happiness)
if (effects.happiness !== undefined && effects.happiness !== 0) {
const current = currentStats.happiness ?? 100;
const delta = effects.happiness;
const after = clampStat(current + delta);
results.push({ stat: 'happiness', current, after, delta });
}
return results;
}
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_INTERACTION_TASK = import.meta.env.DEV;
/**
* Result of incrementing interaction task tags
*/
export interface IncrementInteractionResult {
/** Updated tags array */
updatedTags: string[][];
/** New interaction count after increment */
newCount: number;
/** Whether the task is now complete */
isCompleted: boolean;
/** Previous count before increment */
previousCount: number;
}
/**
* Increment the interaction task counter in the tags array.
*
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
* to track progress on interaction tasks for both hatch and evolve.
*
* CRITICAL: This function is called during actual user actions (not retroactive sync).
* It always increments by 1 because each call represents a real interaction.
*
* Tag format:
* - Progress: ["task", "interactions:N"]
* - Completion: ["task_completed", "interactions"]
*
* Idempotency notes:
* - This is NOT idempotent by design - each call = one interaction
* - Duplicate task_completed tags are prevented by filtering before add
* - Multiple task:interactions tags are prevented by filtering before add
*
* @param currentTags - Current tags array from the Blobbi state
* @param requiredInteractions - Threshold for completion (7 for hatch, 21 for evolve)
* @returns Updated tags array with incremented interaction count
*/
export function incrementInteractionTaskTags(
currentTags: string[][],
requiredInteractions: number
): IncrementInteractionResult {
// Get current interaction count from task tags
const interactionTag = currentTags.find(tag =>
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
);
const previousCount = interactionTag
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
: 0;
const newCount = previousCount + 1;
// Check if already completed (task_completed tag exists)
const alreadyCompleted = currentTags.some(tag =>
tag[0] === 'task_completed' && tag[1] === 'interactions'
);
// Remove old interaction task tag (prevent duplicates) and add new one
let updatedTags = currentTags.filter(tag =>
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
);
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
// Mark as completed if reached required count AND not already marked
const isCompleted = newCount >= requiredInteractions;
if (isCompleted && !alreadyCompleted) {
// Only add if not already present (handled by filter, but double-check)
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
}
if (DEBUG_INTERACTION_TASK) {
console.log('[InteractionTask] Increment:', {
previousCount,
newCount,
requiredInteractions,
isCompleted,
alreadyCompleted,
addedCompletionTag: isCompleted && !alreadyCompleted,
});
}
return { updatedTags, newCount, isCompleted, previousCount };
}
@@ -0,0 +1,81 @@
// src/blobbi/actions/lib/blobbi-activity-state.ts
import type { SelectedTrack } from '../components/PlayMusicModal';
/**
* Types of inline activities that can be displayed in BlobbiPage
*/
export type InlineActivityType = 'none' | 'music' | 'sing';
// Re-export for convenience
export type { SelectedTrack } from '../components/PlayMusicModal';
/**
* State for the music inline activity
*/
export interface MusicActivityState {
type: 'music';
selection: SelectedTrack;
isPublished: boolean;
}
/**
* State for the sing inline activity
*/
export interface SingActivityState {
type: 'sing';
}
/**
* No active inline activity
*/
export interface NoActivityState {
type: 'none';
}
/**
* Union type for all inline activity states
*/
export type InlineActivityState =
| NoActivityState
| MusicActivityState
| SingActivityState;
/**
* Blobbi reaction state - indicates how Blobbi should visually react
*/
export type BlobbiReactionState =
| 'idle' // No special reaction
| 'listening' // Music is playing, Blobbi is listening
| 'swaying' // Blobbi is swaying to music
| 'singing' // User is singing, Blobbi is engaged
| 'happy'; // General happy reaction
/**
* Helper to create a music activity state
*/
export function createMusicActivity(selection: SelectedTrack): MusicActivityState {
return {
type: 'music',
selection,
isPublished: false,
};
}
/**
* Helper to create a sing activity state
*/
export function createSingActivity(): SingActivityState {
return {
type: 'sing',
};
}
/**
* Helper to create no activity state
*/
export function createNoActivity(): NoActivityState {
return {
type: 'none',
};
}
@@ -0,0 +1,121 @@
// src/blobbi/actions/lib/blobbi-random-lyrics.ts
/**
* Random lyrics for the Sing action.
* These are fun, simple lyrics that users can sing to their Blobbi.
*/
export interface LyricsEntry {
id: string;
title: string;
lines: string[];
}
/**
* Collection of placeholder lyrics for singing to a Blobbi.
* Simple, fun, and appropriate for all ages.
*/
export const BLOBBI_LYRICS: LyricsEntry[] = [
{
id: 'lullaby-1',
title: 'Blobbi Lullaby',
lines: [
'Little Blobbi, close your eyes,',
'Dream of stars up in the skies.',
'Safe and warm, you drift away,',
"We'll play again another day.",
],
},
{
id: 'happy-song-1',
title: 'Happy Blobbi Song',
lines: [
'Blobbi, Blobbi, jump around!',
"You're the happiest friend I've found!",
'Dancing, playing, full of cheer,',
"I'm so glad that you are here!",
],
},
{
id: 'adventure-1',
title: 'Adventure Time',
lines: [
"Let's go on an adventure today,",
'Through the clouds and far away!',
'Mountains high and valleys deep,',
'Memories to always keep.',
],
},
{
id: 'breakfast-song',
title: 'Breakfast Song',
lines: [
'Wake up, wake up, sleepy head,',
"Time to get out of your bed!",
"Breakfast's waiting, fresh and yummy,",
'Food to fill your happy tummy!',
],
},
{
id: 'rainy-day',
title: 'Rainy Day',
lines: [
'Pitter patter on the roof,',
'Rainy days can be so nice.',
"We'll stay cozy, me and you,",
'Watching raindrops, one by two.',
],
},
{
id: 'sunshine-song',
title: 'Sunshine Song',
lines: [
'Good morning, sunshine, bright and warm,',
'A brand new day is being born!',
'Blue sky smiling down on me,',
'Happy as can be, so free!',
],
},
{
id: 'bedtime-1',
title: 'Bedtime Blues',
lines: [
'The moon is up, the stars are bright,',
'Time to say a soft goodnight.',
'Snuggle up and close your eyes,',
'Sweet dreams under starry skies.',
],
},
{
id: 'play-time',
title: 'Play Time',
lines: [
"Bounce and jump and run around,",
"Spin and twirl without a sound!",
"Playing games is so much fun,",
"Laughing underneath the sun!",
],
},
];
/**
* Get a random lyrics entry.
*/
export function getRandomLyrics(): LyricsEntry {
const index = Math.floor(Math.random() * BLOBBI_LYRICS.length);
return BLOBBI_LYRICS[index];
}
/**
* Get all available lyrics entries.
*/
export function getAllLyrics(): LyricsEntry[] {
return BLOBBI_LYRICS;
}
/**
* Format lyrics for display (joined with newlines).
*/
export function formatLyrics(lyrics: LyricsEntry): string {
return lyrics.lines.join('\n');
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Blobbi Care Streak Management
*
* This module provides centralized logic for tracking care streaks on Blobbi companions.
* A streak represents consecutive days of care activity (opening Blobbi page, performing
* care actions, etc.).
*
* Streak Rules:
* - Starts at 1 on first activity
* - Increments when activity happens on the NEXT local calendar day
* - Same-day activity does not increment (at most once per day)
* - Missing 2+ days resets streak to 1
*
* Tags managed:
* - care_streak: The current streak count (positive integer)
* - care_streak_last_at: Unix timestamp (seconds) of last streak update
* - care_streak_last_day: Local calendar day string (YYYY-MM-DD) of last update
*/
import {
getLocalDayString,
getDaysDifference,
type BlobbiCompanion,
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of calculating a streak update.
*/
export interface StreakUpdateResult {
/** Whether the streak was updated (incremented or reset) */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** The new timestamp for care_streak_last_at */
newLastAt: number;
/** The new day string for care_streak_last_day */
newLastDay: string;
/** Description of what happened (for debugging/logging) */
action: 'initialized' | 'incremented' | 'reset' | 'same_day';
}
/**
* Tag updates to apply to the Blobbi event.
* Only present if wasUpdated is true.
* Uses index signature for compatibility with updateBlobbiTags.
*/
export interface StreakTagUpdates {
care_streak: string;
care_streak_last_at: string;
care_streak_last_day: string;
[key: string]: string;
}
// ─── Core Logic ───────────────────────────────────────────────────────────────
/**
* Calculate what the streak should be updated to based on current state and activity.
*
* This is a pure function that calculates the new streak state without side effects.
* Use this to determine if/how the streak should be updated.
*
* @param currentStreak - Current streak value (0 or undefined means no streak yet)
* @param lastDay - The last day string (YYYY-MM-DD) when streak was updated, or undefined
* @param now - Current timestamp (defaults to now)
* @returns StreakUpdateResult describing the update
*/
export function calculateStreakUpdate(
currentStreak: number | undefined,
lastDay: string | undefined,
now: Date = new Date()
): StreakUpdateResult {
const nowTimestamp = Math.floor(now.getTime() / 1000);
const todayString = getLocalDayString(now);
// Case 1: No existing streak - initialize to 1
if (currentStreak === undefined || currentStreak === 0 || !lastDay) {
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'initialized',
};
}
// Case 2: Activity on the same day - no update needed
if (lastDay === todayString) {
return {
wasUpdated: false,
newStreak: currentStreak,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'same_day',
};
}
// Calculate days since last activity
const daysMissed = getDaysDifference(lastDay, todayString);
// Case 3: Next day (1 day difference) - increment streak
if (daysMissed === 1) {
return {
wasUpdated: true,
newStreak: currentStreak + 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'incremented',
};
}
// Case 4: Missed 2+ days - reset to 1
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'reset',
};
}
/**
* Get the tag updates to apply to a Blobbi event for a streak update.
* Returns undefined if no update is needed (same day activity).
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns Tag updates to apply, or undefined if no update needed
*/
export function getStreakTagUpdates(
companion: BlobbiCompanion,
now: Date = new Date()
): StreakTagUpdates | undefined {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
if (!result.wasUpdated) {
return undefined;
}
return {
care_streak: result.newStreak.toString(),
care_streak_last_at: result.newLastAt.toString(),
care_streak_last_day: result.newLastDay,
};
}
/**
* Check if a streak update is needed for the companion.
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns true if the streak should be updated
*/
export function needsStreakUpdate(
companion: BlobbiCompanion,
now: Date = new Date()
): boolean {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
return result.wasUpdated;
}
/**
* Get the current streak status for display purposes.
*
* @param companion - The current Blobbi companion state
* @returns Object with streak info for UI display
*/
export function getStreakStatus(companion: BlobbiCompanion): {
streak: number;
lastDay: string | undefined;
isActive: boolean;
daysSinceLastActivity: number | undefined;
} {
const streak = companion.careStreak ?? 0;
const lastDay = companion.careStreakLastDay;
const today = getLocalDayString();
let daysSinceLastActivity: number | undefined;
let isActive = false;
if (lastDay) {
daysSinceLastActivity = getDaysDifference(lastDay, today);
// Streak is "active" if we've had activity today or yesterday
isActive = daysSinceLastActivity <= 1;
}
return {
streak,
lastDay,
isActive,
daysSinceLastActivity,
};
}
@@ -0,0 +1,118 @@
// src/blobbi/actions/lib/blobbi-track-catalog.ts
/**
* Blobbi Track Catalog
*
* Music tracks for the Blobbi "Play Music" action.
* All tracks are hosted on remote Blossom servers and streamed on-demand.
*
* ## Adding New Tracks
*
* 1. Convert the audio file to M4A (AAC-LC):
* `ffmpeg -i input.m4a -c:a aac -b:a 64k -ar 48000 output.m4a`
* 2. Upload the M4A file to a Blossom server
* 3. Add a new entry to `BLOBBI_TRACK_CATALOG` below
* 4. Set `url` to the full Blossom URL
* 5. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
*
* ## Supported Formats
*
* M4A (AAC-LC) is required for iOS/Safari compatibility and small file size.
*/
export interface BlobbiTrack {
/** Unique identifier for the track (used in state/events) */
id: string;
/** Display title shown in the UI */
title: string;
/** Artist or source attribution */
artist: string;
/** Full URL to the remote audio file (Blossom server) */
url: string;
/** Duration in seconds (for display, get via ffprobe) */
durationSeconds: number;
/** Optional cover art URL */
coverArt?: string;
/** Optional tags for categorization/filtering */
tags?: string[];
}
/**
* Blobbi track catalog.
*
* All tracks are royalty-free/Creative Commons licensed.
* Audio files hosted on remote Blossom servers.
*/
export const BLOBBI_TRACK_CATALOG: BlobbiTrack[] = [
{
id: 'nap_in_the_meadow',
title: 'Nap in the Meadow',
artist: 'Chilltape FM',
url: 'https://blossom.ditto.pub/6be1c95e879187f83af2a661ccac2bd96196f7bc334af44529ede6270b2811fc.m4a',
durationSeconds: 240, // 4:00
tags: ['relaxing', 'nature'],
},
{
id: 'happy_kids',
title: 'Happy Kids',
artist: 'Dmitrii Kolesnikov',
url: 'https://blossom.ditto.pub/94d49abd178aa8afb14737a55e0a7143f6b337f618d74858d011232bb2db845d.m4a',
durationSeconds: 129, // 2:09
tags: ['upbeat', 'fun'],
},
{
id: 'soft_piano',
title: 'Soft Piano',
artist: 'Dmitrii Kolesnikov',
url: 'https://blossom.ditto.pub/5367242d3dc555c77f5c637fd153df1166708a24c5a4c222bb4dcaeabf740743.m4a',
durationSeconds: 124, // 2:04
tags: ['calming', 'sleep'],
},
{
id: 'epic_sacred_light',
title: 'Epic Sacred Light',
artist: 'Ura Megis',
url: 'https://blossom.dreamith.to/c22953791d686605958165fd44a84cd7d9fd3d4423ebf786e47891ed3a82c6db.m4a',
durationSeconds: 223, // 3:43
tags: ['energetic', 'adventure'],
},
{
id: 'split_memories',
title: 'Split Memories',
artist: 'ido berg',
url: 'https://blossom.ditto.pub/57ba2e2122a732449880ae531d4bfac9a580bc19693c7dda735afbfa336b35fe.m4a',
durationSeconds: 153, // 2:33
tags: ['ambient', 'relaxing'],
},
{
id: 'minhas_mensagens',
title: 'Minhas Mensagens',
artist: 'PReis',
url: 'https://blossom.ditto.pub/0945064dc8f946f3392be23629b166e72090cafca7cca865a20b5395dd83ff46.m4a',
durationSeconds: 248, // 4:08
tags: ['ambient', 'relaxing'],
},
];
/**
* Get a track by ID from the catalog
*/
export function getTrackById(id: string): BlobbiTrack | undefined {
return BLOBBI_TRACK_CATALOG.find(track => track.id === id);
}
/**
* Get all tracks from the catalog
*/
export function getAllTracks(): BlobbiTrack[] {
return BLOBBI_TRACK_CATALOG;
}
/**
* Format duration in seconds to MM:SS string
*/
export function formatTrackDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
calculateActionXP,
calculateInventoryActionXP,
applyXPGain,
getXPGainSummary,
formatXPGain,
getXPGainMessage,
ACTION_XP,
INVENTORY_ACTION_XP,
DIRECT_ACTION_XP,
} from './blobbi-xp';
describe('calculateActionXP', () => {
it('returns the correct XP for each inventory action', () => {
expect(calculateActionXP('feed')).toBe(5);
expect(calculateActionXP('play')).toBe(8);
expect(calculateActionXP('clean')).toBe(6);
expect(calculateActionXP('medicine')).toBe(10);
});
it('returns the correct XP for each direct action', () => {
expect(calculateActionXP('play_music')).toBe(7);
expect(calculateActionXP('sing')).toBe(9);
});
it('returns 0 for an unknown action', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(calculateActionXP('unknown' as any)).toBe(0);
});
});
describe('calculateInventoryActionXP', () => {
it('returns base XP for quantity 1', () => {
expect(calculateInventoryActionXP('feed', 1)).toBe(5);
expect(calculateInventoryActionXP('medicine', 1)).toBe(10);
});
it('multiplies XP by quantity', () => {
expect(calculateInventoryActionXP('feed', 3)).toBe(15);
expect(calculateInventoryActionXP('play', 5)).toBe(40);
});
it('defaults to quantity 1 when not specified', () => {
expect(calculateInventoryActionXP('clean')).toBe(6);
});
it('returns 0 for quantity less than 1', () => {
expect(calculateInventoryActionXP('feed', 0)).toBe(0);
expect(calculateInventoryActionXP('feed', -1)).toBe(0);
});
});
describe('applyXPGain', () => {
it('adds XP to a current value', () => {
expect(applyXPGain(100, 25)).toBe(125);
});
it('treats undefined current XP as 0', () => {
expect(applyXPGain(undefined, 10)).toBe(10);
});
it('never returns a negative value', () => {
expect(applyXPGain(5, -20)).toBe(0);
expect(applyXPGain(0, -1)).toBe(0);
});
it('handles zero XP gain', () => {
expect(applyXPGain(50, 0)).toBe(50);
});
});
describe('getXPGainSummary', () => {
it('returns the correct xpGained and quantity', () => {
const result = getXPGainSummary('feed', 3);
expect(result).toEqual({ xpGained: 15, quantity: 3 });
});
it('defaults quantity to 1', () => {
const result = getXPGainSummary('sing');
expect(result).toEqual({ xpGained: 9, quantity: 1 });
});
});
describe('formatXPGain', () => {
it('formats positive XP as "+N XP"', () => {
expect(formatXPGain(15)).toBe('+15 XP');
expect(formatXPGain(1)).toBe('+1 XP');
});
it('returns empty string for zero or negative XP', () => {
expect(formatXPGain(0)).toBe('');
expect(formatXPGain(-5)).toBe('');
});
});
describe('getXPGainMessage', () => {
it('formats a message with action and XP earned', () => {
expect(getXPGainMessage('feed', 5)).toBe('+5 XP earned!');
});
it('includes total when provided', () => {
expect(getXPGainMessage('feed', 5, 105)).toBe('+5 XP earned! Total: 105 XP');
});
it('returns empty string for zero or negative XP', () => {
expect(getXPGainMessage('feed', 0)).toBe('');
expect(getXPGainMessage('feed', -1)).toBe('');
});
});
describe('XP constants', () => {
it('ACTION_XP contains all inventory and direct actions', () => {
for (const action of Object.keys(INVENTORY_ACTION_XP)) {
expect(ACTION_XP).toHaveProperty(action);
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
INVENTORY_ACTION_XP[action as keyof typeof INVENTORY_ACTION_XP],
);
}
for (const action of Object.keys(DIRECT_ACTION_XP)) {
expect(ACTION_XP).toHaveProperty(action);
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
DIRECT_ACTION_XP[action as keyof typeof DIRECT_ACTION_XP],
);
}
});
it('all XP values are positive integers', () => {
for (const xp of Object.values(ACTION_XP)) {
expect(xp).toBeGreaterThan(0);
expect(Number.isInteger(xp)).toBe(true);
}
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* Blobbi XP (Experience Points) System
*
* This module defines XP values for all Blobbi care actions and provides
* utilities for calculating and applying XP gains.
*
* Design Philosophy:
* - Different actions award different XP to reflect their complexity/value
* - XP values are balanced to encourage variety in care activities
* - Direct actions (sing, play_music) give moderate XP as they're free
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
* - XP accumulates across all life stages and never resets
*/
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is costly and critical - highest inventory XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't consume items - they're free activities.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
sing: 9, // Singing requires more user effort - higher XP
};
/**
* Combined XP lookup for all action types.
* Use this for a unified XP calculation interface.
*/
export const ACTION_XP: Record<BlobbiAction, number> = {
...INVENTORY_ACTION_XP,
...DIRECT_ACTION_XP,
};
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
/**
* Calculate XP gain for a single action.
*
* @param action - The action performed
* @returns XP points earned
*/
export function calculateActionXP(action: BlobbiAction): number {
return ACTION_XP[action] ?? 0;
}
/**
* Calculate total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
*
* @param action - The action performed
* @param quantity - Number of items used (defaults to 1)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
if (quantity < 1) return 0;
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
return baseXP * quantity;
}
/**
* Apply XP gain to current experience value.
*
* @param currentXP - Current experience points (undefined = 0)
* @param xpGain - XP points to add
* @returns New total XP (never negative)
*/
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
const current = currentXP ?? 0;
const newXP = current + xpGain;
return Math.max(0, newXP);
}
/**
* Get XP gain summary for displaying to the user.
*
* @param action - The action performed
* @param quantity - Number of times the action was performed (for inventory actions)
* @returns Object with xpGained and total quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
quantity: number = 1
): { xpGained: number; quantity: number } {
const baseXP = ACTION_XP[action] ?? 0;
const xpGained = baseXP * quantity;
return { xpGained, quantity };
}
// ─── XP Display Utilities ─────────────────────────────────────────────────────
/**
* Format XP gain for display in toasts/notifications.
*
* @param xpGained - Amount of XP gained
* @returns Formatted string like "+15 XP"
*/
export function formatXPGain(xpGained: number): string {
if (xpGained <= 0) return '';
return `+${xpGained} XP`;
}
/**
* Get a descriptive message about XP gain.
*
* @param action - The action that earned XP
* @param xpGained - Amount of XP gained
* @param newTotal - New total XP (optional, for "You now have X XP" message)
* @returns Formatted message for user feedback
*/
export function getXPGainMessage(
action: BlobbiAction,
xpGained: number,
newTotal?: number
): string {
if (xpGained <= 0) return '';
const xpText = formatXPGain(xpGained);
if (newTotal !== undefined) {
return `${xpText} earned! Total: ${newTotal} XP`;
}
return `${xpText} earned!`;
}
@@ -0,0 +1,109 @@
/**
* Daily Mission Tracker - Standalone progress tracking utility
*
* This module provides a simple way to track daily mission progress
* without requiring React hooks or context. It directly manipulates
* localStorage for immediate persistence.
*
* This approach allows action hooks (which may be called outside of
* the daily missions hook context) to record progress.
*/
import {
type DailyMissionsState,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
updateMissionProgress,
} from './daily-missions';
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
/**
* Read the current daily missions state from localStorage
*/
function readState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
/**
* Write the daily missions state to localStorage
*/
function writeState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[DailyMissionTracker] Failed to write state:', error);
}
}
/**
* Ensure we have a valid state for today, creating one if necessary
*/
function ensureCurrentState(pubkey?: string): DailyMissionsState {
const current = readState();
if (needsDailyReset(current)) {
const previousCoins = current?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
writeState(newState);
return newState;
}
return current!;
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Record progress for a daily mission action.
* This function can be called from anywhere (hooks, event handlers, etc.)
* and will immediately persist to localStorage.
*
* @param action - The action type that was performed
* @param count - Number of times the action was performed (default: 1)
* @param pubkey - Optional user pubkey for personalized mission selection
*/
export function trackDailyMissionProgress(
action: DailyMissionAction,
count: number = 1,
pubkey?: string
): void {
const current = ensureCurrentState(pubkey);
const updated = updateMissionProgress(current, action, count);
writeState(updated);
// Dispatch a custom event so React components can re-render if needed
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
}
/**
* Convenience function to track multiple actions at once.
* Useful when an action should count toward multiple missions.
*
* @param actions - Array of actions to track
* @param pubkey - Optional user pubkey
*/
export function trackMultipleDailyMissionActions(
actions: DailyMissionAction[],
pubkey?: string
): void {
let current = ensureCurrentState(pubkey);
for (const action of actions) {
current = updateMissionProgress(current, action, 1);
}
writeState(current);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
}
+708
View File
@@ -0,0 +1,708 @@
/**
* Daily Missions System for Blobbi
*
* This module defines the daily mission pool, selection logic, and types.
* Daily missions are separate from hatch/evolve missions and provide
* daily engagement loops with coin rewards.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mission action types that can trigger progress
*/
export type DailyMissionAction =
| 'interact' // Any interaction (feed, clean, play, etc.)
| 'feed' // Feeding action specifically
| 'clean' // Cleaning action specifically
| 'sing' // Sing direct action
| 'play_music' // Play music direct action
| 'sleep' // Put Blobbi to sleep
| 'take_photo' // Take a photo of Blobbi
| 'medicine'; // Give medicine to Blobbi
/**
* Blobbi stage type for filtering missions
*/
export type BlobbiStage = 'egg' | 'baby' | 'adult';
/**
* Definition of a daily mission in the pool
*/
export interface DailyMissionDefinition {
/** Unique identifier for this mission type */
id: string;
/** Display title */
title: string;
/** Description of what to do */
description: string;
/** Action that triggers progress */
action: DailyMissionAction;
/** Number of times the action must be performed */
requiredCount: number;
/** Coin reward for completing this mission */
reward: number;
/** Selection weight (higher = more likely to be selected) */
weight: number;
/** Required stages to show this mission (if empty/undefined, requires baby or adult) */
requiredStages?: BlobbiStage[];
}
/**
* A daily mission instance with progress tracking
*/
export interface DailyMission extends DailyMissionDefinition {
/** Current progress (how many times the action has been performed today) */
currentCount: number;
/** Whether the mission has been completed */
completed: boolean;
/** Whether the reward has been claimed */
claimed: boolean;
}
/**
* Stored state for daily missions (persisted in localStorage)
*/
export interface DailyMissionsState {
/** The date string (YYYY-MM-DD) when these missions were generated */
date: string;
/** The selected missions for this day */
missions: DailyMission[];
/** Total coins earned from daily missions (lifetime) */
totalCoinsEarned: number;
/** Whether the bonus mission has been claimed today */
bonusClaimed?: boolean;
/** Number of rerolls remaining for today (resets daily, max 3) */
rerollsRemaining?: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
/** Maximum number of mission rerolls allowed per day */
export const MAX_DAILY_REROLLS = 3;
// ─── Mission Pool ─────────────────────────────────────────────────────────────
/**
* The pool of available daily missions.
* Weights determine selection frequency:
* - High weight (10): Common missions (interact, feed, clean)
* - Medium weight (6): Regular missions (sing, play music, sleep)
* - Low weight (2): Uncommon missions (change shape)
* - Rare weight (1): Rare missions (take photo)
*/
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
// ═══════════════════════════════════════════════════════════════════════════
// BABY/ADULT ONLY MISSIONS
// These actions are NOT available for eggs
// ═══════════════════════════════════════════════════════════════════════════
// ─── Interact Missions (Baby/Adult only) ───────────────────────────────────
{
id: 'interact_3',
title: 'Quick Care',
description: 'Interact with your Blobbi 3 times',
action: 'interact',
requiredCount: 3,
reward: 30,
weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'interact_6',
title: 'Attentive Caretaker',
description: 'Interact with your Blobbi 6 times',
action: 'interact',
requiredCount: 6,
reward: 50,
weight: 8,
requiredStages: ['baby', 'adult'],
},
// ─── Feed Missions (Baby/Adult only) ───────────────────────────────────────
{
id: 'feed_1',
title: 'Snack Time',
description: 'Feed your Blobbi once',
action: 'feed',
requiredCount: 1,
reward: 25,
weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_2',
title: 'Hungry Blobbi',
description: 'Feed your Blobbi 2 times',
action: 'feed',
requiredCount: 2,
reward: 45,
weight: 8,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_3',
title: 'Feast Day',
description: 'Feed your Blobbi 3 times',
action: 'feed',
requiredCount: 3,
reward: 60,
weight: 5,
requiredStages: ['baby', 'adult'],
},
// ─── Sleep Missions (Baby/Adult only) ──────────────────────────────────────
{
id: 'sleep_1',
title: 'Nap Time',
description: 'Put your Blobbi to sleep',
action: 'sleep',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['baby', 'adult'],
},
// ─── Photo Missions (Baby/Adult only) ──────────────────────────────────────
{
id: 'take_photo_1',
title: 'Snapshot',
description: 'Take a polaroid photo of your Blobbi',
action: 'take_photo',
requiredCount: 1,
reward: 55,
weight: 4,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_2',
title: 'Photo Album',
description: 'Take 2 photos of your Blobbi',
action: 'take_photo',
requiredCount: 2,
reward: 70,
weight: 2,
requiredStages: ['baby', 'adult'],
},
// ═══════════════════════════════════════════════════════════════════════════
// EGG + BABY + ADULT MISSIONS
// These actions are available for ALL stages including eggs
// ═══════════════════════════════════════════════════════════════════════════
// ─── Clean Missions (All stages) ───────────────────────────────────────────
{
id: 'clean_1',
title: 'Quick Cleanup',
description: 'Clean your Blobbi once',
action: 'clean',
requiredCount: 1,
reward: 25,
weight: 10,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'clean_2',
title: 'Squeaky Clean',
description: 'Clean your Blobbi 2 times',
action: 'clean',
requiredCount: 2,
reward: 45,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Sing Missions (All stages) ────────────────────────────────────────────
{
id: 'sing_1',
title: 'Sing Along',
description: 'Sing a song to your Blobbi',
action: 'sing',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'sing_2',
title: 'Karaoke Session',
description: 'Sing 2 songs to your Blobbi',
action: 'sing',
requiredCount: 2,
reward: 50,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Play Music Missions (All stages) ──────────────────────────────────────
{
id: 'play_music_1',
title: 'DJ Time',
description: 'Play a song for your Blobbi',
action: 'play_music',
requiredCount: 1,
reward: 30,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'play_music_2',
title: 'Music Marathon',
description: 'Play 2 songs for your Blobbi',
action: 'play_music',
requiredCount: 2,
reward: 50,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Medicine Missions (All stages) ────────────────────────────────────────
// Medicine rewards are higher since medicine costs coins to use
{
id: 'medicine_1',
title: 'Health Check',
description: 'Give medicine to your Blobbi',
action: 'medicine',
requiredCount: 1,
reward: 60,
weight: 5,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'medicine_2',
title: 'Doctor Visit',
description: 'Give medicine to your Blobbi 2 times',
action: 'medicine',
requiredCount: 2,
reward: 70,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
];
// ─── Utility Functions ────────────────────────────────────────────────────────
/**
* Get the current date string in YYYY-MM-DD format (local timezone)
*/
export function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/**
* Generate a seed number from a date string and optional user pubkey.
* Used for deterministic daily mission selection.
*/
function generateDailySeed(dateString: string, pubkey?: string): number {
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Seeded random number generator (Mulberry32)
*/
function seededRandom(seed: number): () => number {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/**
* Check if a mission is available for the given stages.
* Missions with no requiredStages default to requiring baby or adult.
*/
function isMissionAvailableForStages(
mission: DailyMissionDefinition,
availableStages: BlobbiStage[]
): boolean {
const requiredStages = mission.requiredStages ?? ['baby', 'adult'];
return requiredStages.some((stage) => availableStages.includes(stage));
}
/**
* Select N missions from the pool using weighted random selection.
* Uses a seeded random generator for deterministic daily selection.
*
* @param count - Number of missions to select
* @param dateString - Date string for seeding (YYYY-MM-DD)
* @param pubkey - Optional user pubkey for seeding
* @param availableStages - Stages the user has available (filters eligible missions)
*/
export function selectDailyMissions(
count: number,
dateString: string,
pubkey?: string,
availableStages?: BlobbiStage[]
): DailyMissionDefinition[] {
const seed = generateDailySeed(dateString, pubkey);
const random = seededRandom(seed);
// Filter pool by available stages (default to baby/adult if not specified)
const stagesToCheck = availableStages ?? ['baby', 'adult'];
const eligibleMissions = DAILY_MISSION_POOL.filter((m) =>
isMissionAvailableForStages(m, stagesToCheck)
);
// If no missions are available for the user's stages, return empty
if (eligibleMissions.length === 0) {
return [];
}
// Create a copy of the eligible pool
const available = [...eligibleMissions];
const selected: DailyMissionDefinition[] = [];
while (selected.length < count && available.length > 0) {
// Calculate total weight of remaining missions
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
// Pick a random value in [0, totalWeight)
let pick = random() * totalWeight;
// Find the mission that corresponds to this pick
let selectedIndex = 0;
for (let i = 0; i < available.length; i++) {
pick -= available[i].weight;
if (pick <= 0) {
selectedIndex = i;
break;
}
}
// Add to selected and remove from available
selected.push(available[selectedIndex]);
available.splice(selectedIndex, 1);
}
return selected;
}
/**
* Create a fresh DailyMission from a definition
*/
export function createMissionFromDefinition(def: DailyMissionDefinition): DailyMission {
return {
...def,
currentCount: 0,
completed: false,
claimed: false,
};
}
/**
* Create the initial daily missions state for a new day
*/
export function createDailyMissionsState(
dateString: string,
pubkey?: string,
previousTotalCoins: number = 0,
availableStages?: BlobbiStage[]
): DailyMissionsState {
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
return {
date: dateString,
missions: definitions.map(createMissionFromDefinition),
totalCoinsEarned: previousTotalCoins,
rerollsRemaining: MAX_DAILY_REROLLS,
};
}
/**
* Check if the daily missions need to be reset (new day)
*/
export function needsDailyReset(state: DailyMissionsState | null): boolean {
if (!state) return true;
return state.date !== getTodayDateString();
}
/**
* Update mission progress for a given action
*/
export function updateMissionProgress(
state: DailyMissionsState,
action: DailyMissionAction,
incrementBy: number = 1
): DailyMissionsState {
const updatedMissions = state.missions.map((mission) => {
// Skip if not the matching action or already completed
if (mission.action !== action || mission.completed) {
return mission;
}
const newCount = Math.min(mission.currentCount + incrementBy, mission.requiredCount);
const nowCompleted = newCount >= mission.requiredCount;
return {
...mission,
currentCount: newCount,
completed: nowCompleted,
};
});
return {
...state,
missions: updatedMissions,
};
}
/**
* Claim reward for a completed mission
*/
export function claimMissionReward(
state: DailyMissionsState,
missionId: string
): { state: DailyMissionsState; coinsEarned: number } {
let coinsEarned = 0;
const updatedMissions = state.missions.map((mission) => {
if (mission.id !== missionId) return mission;
// Can only claim if completed and not yet claimed
if (!mission.completed || mission.claimed) return mission;
coinsEarned = mission.reward;
return {
...mission,
claimed: true,
};
});
return {
state: {
...state,
missions: updatedMissions,
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
},
coinsEarned,
};
}
/**
* Get the total potential reward for all daily missions
*/
export function getTotalPotentialReward(state: DailyMissionsState): number {
return state.missions.reduce((sum, m) => sum + m.reward, 0);
}
/**
* Get the total claimed reward for today
*/
export function getTodayClaimedReward(state: DailyMissionsState): number {
return state.missions
.filter((m) => m.claimed)
.reduce((sum, m) => sum + m.reward, 0);
}
/**
* Check if all daily missions are completed
*/
export function areAllMissionsCompleted(state: DailyMissionsState): boolean {
return state.missions.every((m) => m.completed);
}
/**
* Check if all daily missions are claimed
*/
export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
return state.missions.every((m) => m.claimed);
}
// ─── Bonus Mission ────────────────────────────────────────────────────────────
/**
* The bonus mission that becomes available after completing all regular missions.
* This is a special mission that rewards extra coins for daily completion.
*/
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
id: 'bonus_daily_complete',
title: 'Daily Champion',
description: 'Complete all daily missions to claim this bonus reward',
action: 'interact', // Not actually used - bonus is auto-completed
requiredCount: 1,
reward: 80,
weight: 0, // Not part of random selection
};
/**
* Check if the bonus mission is available (all regular missions completed)
*/
export function isBonusMissionAvailable(state: DailyMissionsState): boolean {
// Bonus is available if there are regular missions and all are completed
return state.missions.length > 0 && areAllMissionsCompleted(state);
}
/**
* Check if the bonus mission has been claimed today
*/
export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
return state.bonusClaimed ?? false;
}
/**
* Claim the bonus mission reward
*/
export function claimBonusMissionReward(
state: DailyMissionsState
): { state: DailyMissionsState; coinsEarned: number } {
// Can only claim if bonus is available and not yet claimed
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
return { state, coinsEarned: 0 };
}
return {
state: {
...state,
bonusClaimed: true,
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
},
coinsEarned: BONUS_MISSION_DEFINITION.reward,
};
}
// ─── Mission Reroll ───────────────────────────────────────────────────────────
/**
* Get the number of rerolls remaining for today.
* Returns MAX_DAILY_REROLLS if not set (for backward compatibility with old state).
*/
export function getRerollsRemaining(state: DailyMissionsState): number {
// If rerollsRemaining is not set (old state), default to max
if (state.rerollsRemaining === undefined || state.rerollsRemaining === null) {
return MAX_DAILY_REROLLS;
}
return state.rerollsRemaining;
}
/**
* Check if the user can reroll a mission
*/
export function canRerollMission(state: DailyMissionsState, missionId: string): boolean {
const rerollsRemaining = getRerollsRemaining(state);
if (rerollsRemaining <= 0) return false;
// Find the mission
const mission = state.missions.find((m) => m.id === missionId);
if (!mission) return false;
// Cannot reroll completed or claimed missions
if (mission.completed || mission.claimed) return false;
return true;
}
/**
* Select a replacement mission that:
* - Is not already in the current mission list
* - Is not the mission being replaced (avoid immediately giving back the same)
* - Respects the user's available stages
*
* Uses weighted random selection from eligible missions.
*/
export function selectReplacementMission(
currentMissions: DailyMission[],
missionToReplace: DailyMission,
availableStages?: BlobbiStage[]
): DailyMissionDefinition | null {
// Default to baby/adult if no stages provided (most common case)
const stagesToCheck = availableStages && availableStages.length > 0
? availableStages
: ['baby', 'adult'] as BlobbiStage[];
// Get IDs of missions that cannot be selected (current active missions)
const excludedIds = new Set<string>();
// Exclude all current missions EXCEPT the one being replaced
for (const m of currentMissions) {
if (m.id !== missionToReplace.id) {
excludedIds.add(m.id);
}
}
// Filter pool to eligible missions
const eligibleMissions = DAILY_MISSION_POOL.filter((m) => {
// Must not be an already-active mission (except the one being replaced)
if (excludedIds.has(m.id)) return false;
// Must not be the same mission being replaced
if (m.id === missionToReplace.id) return false;
// Must be available for user's stages
if (!isMissionAvailableForStages(m, stagesToCheck)) return false;
return true;
});
// If no eligible missions, return null
if (eligibleMissions.length === 0) {
return null;
}
// Use Math.random() for non-deterministic selection (rerolls should feel random)
const totalWeight = eligibleMissions.reduce((sum, m) => sum + m.weight, 0);
let pick = Math.random() * totalWeight;
for (const mission of eligibleMissions) {
pick -= mission.weight;
if (pick <= 0) {
return mission;
}
}
// Fallback to first eligible (shouldn't happen)
return eligibleMissions[0];
}
/**
* Reroll a mission, replacing it with a new one from the pool.
* Returns the updated state and the new mission, or null if reroll failed.
*/
export function rerollMission(
state: DailyMissionsState,
missionId: string,
availableStages?: BlobbiStage[]
): { state: DailyMissionsState; newMission: DailyMission } | null {
// Check if reroll is allowed
if (!canRerollMission(state, missionId)) {
return null;
}
// Find the mission index
const missionIndex = state.missions.findIndex((m) => m.id === missionId);
if (missionIndex === -1) {
return null;
}
const oldMission = state.missions[missionIndex];
// Select a replacement
const replacement = selectReplacementMission(state.missions, oldMission, availableStages);
if (!replacement) {
return null;
}
// Create the new mission instance
const newMission = createMissionFromDefinition(replacement);
// Update the missions array
const updatedMissions = [...state.missions];
updatedMissions[missionIndex] = newMission;
// Decrement rerolls remaining
const newRerollsRemaining = getRerollsRemaining(state) - 1;
return {
state: {
...state,
missions: updatedMissions,
rerollsRemaining: newRerollsRemaining,
},
newMission,
};
}
@@ -0,0 +1,100 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Grupo das pétalas com rotação -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="10s"
repeatCount="indefinite" />
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
</g>
<!-- Grupo das partículas giratórias -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="20s"
repeatCount="indefinite" />
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.8" />
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.6" />
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.7" />
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.5" />
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.9" />
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="88" cy="105" r="8" fill="white" />
<circle cx="112" cy="105" r="8" fill="white" />
<!-- Pupils (pupil + highlights) -->
<circle cx="88" cy="105" r="5" fill="#1f2937" />
<circle cx="112" cy="105" r="5" fill="#1f2937" />
<circle cx="90" cy="103" r="2" fill="white" />
<circle cx="114" cy="103" r="2" fill="white" />
<!-- Mouth -->
<path d="M 90 120 Q 100 128 110 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Bochechas -->
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<!-- Gradientes -->
<defs>
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fed7d7" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#e0e7ff" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dcfce7" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dbeafe" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

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

After

Width:  |  Height:  |  Size: 4.4 KiB

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

After

Width:  |  Height:  |  Size: 4.3 KiB

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

After

Width:  |  Height:  |  Size: 3.1 KiB

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

After

Width:  |  Height:  |  Size: 3.2 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 4.7 KiB

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

After

Width:  |  Height:  |  Size: 2.2 KiB

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

After

Width:  |  Height:  |  Size: 2.4 KiB

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

After

Width:  |  Height:  |  Size: 4.5 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 3.9 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 5.7 KiB

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

After

Width:  |  Height:  |  Size: 5.5 KiB

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

After

Width:  |  Height:  |  Size: 6.1 KiB

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

After

Width:  |  Height:  |  Size: 6.2 KiB

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

After

Width:  |  Height:  |  Size: 3.3 KiB

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

After

Width:  |  Height:  |  Size: 3.5 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

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

After

Width:  |  Height:  |  Size: 4.2 KiB

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

After

Width:  |  Height:  |  Size: 3.1 KiB

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

After

Width:  |  Height:  |  Size: 3.2 KiB

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

After

Width:  |  Height:  |  Size: 3.7 KiB

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

After

Width:  |  Height:  |  Size: 4.2 KiB

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

After

Width:  |  Height:  |  Size: 4.3 KiB

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

After

Width:  |  Height:  |  Size: 4.2 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

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

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