Pandi's 4 ear circles and 2 eye-patch circles use flat dark fills
(#1f2937, #374151) that match PUPIL_COLORS, causing the detector to
find 8 "pupils" instead of 2. This produced phantom eye groups,
broke blink/gaze targeting, and left the real pupils orphaned.
Add data-blobbi-skip attribute to the 6 non-pupil dark circles in
Pandi's SVGs, and add an early-return in isPupilElement() to respect
it. No other form has this attribute, so behavior is unchanged for
all non-Pandi adults.
Catti's dual cat-mouth (two mirrored Q-curve paths sharing a center
start point) caused extractMouthPositionFromElements to return only
the left half's coordinates. Every mouth generator then computed
cx=(100+82)/2=91 instead of the true center x=100.
The fix scans all Q-curve paths in the mouth section and computes the
full horizontal extent. For Catti: startX=82, endX=118, center=100.
Single-path mouths are unaffected (the extra-path loop simply doesn't
execute). Froggi's dual-path mouth already had symmetric bounds so its
result is unchanged.
replaceMouthSection() used a global regex matching any Q-curve <path>
with a stroke attribute. Catti's whiskers are Q-curve paths that appear
after the mouth in document order, so they were matched and deleted
whenever a status reaction replaced the mouth.
The fix adds a marker-bounded replacement strategy: when a <!-- Mouth -->
comment marker exists, the regex is scoped to only the section between
that marker and the next SVG section. All 16 adult forms and the baby
SVG already have this marker. The global regex is preserved as a
fallback for SVGs without markers.
The MoreTabContent grid rendered BlobbiStageVisual without a sleeping
recipe, so sleeping companions got the awake base SVG with open eyes.
The sleeping visual (closed clip-rects, closed-eye lines, Zzz) is
entirely recipe-driven — without the recipe the SVG renderer always
produces the awake appearance.
Pass buildSleepingRecipe() and recipeLabel='sleeping' for companions
whose state is 'sleeping' in the tab grid call site.
When Blobbi transitions from sleeping to awake, the eyes now visibly
open over 400ms with an ease-in-out curve before the normal blink/gaze
loop resumes. The wake-up animation mirrors the sleep-entry animation:
- runWakeUpAnimation() queries fresh DOM elements from containerRef
- Sets clip-rects to the closed position (using BLINK_CLOSED_AMOUNT)
- Animates from closed to open, then calls onComplete
- The normal awake animation loop only starts after completion
- This prevents two rAF loops from fighting over clip-rect attributes
The animation only fires on genuine sleeping→awake transitions via
wasSleepingRef, not on mount or refresh when Blobbi is already awake.
If cancelled mid-animation (e.g. quick sleep re-toggle), the onComplete
callback is still invoked so the hook does not get stuck.
When publishing kind 1 or kind 1111 reply events, also deliver them to
the read (inbox) relays of p-tagged users. This follows the NIP-65
recommendation that clients send events to the read relays of each
tagged user so recipients are more likely to see replies.
The inbox delivery is fire-and-forget after the main publish succeeds,
so it does not slow down the UI or block the publish flow.
When Blobbi transitions from awake to sleeping, the eyes now visibly
close over 400ms with an ease-in-out curve before settling into the
stable sleeping state. The closed-eye lines fade in during the last
40% of the animation.
The animation is driven by a standalone rAF loop in runSleepEntryAnimation()
that queries fresh DOM elements from containerRef.current — never from
stale cached refs. A wasSleepingRef tracks the previous isSleeping value
so the animation only fires on genuine awake→sleep transitions, not on
mount or refresh when Blobbi is already sleeping.
At t=1 the DOM exactly matches the recipe's static closed state, so
there is no visual discontinuity when the animation completes.
Clear stale cached DOM refs in useBlobbiEyes when entering sleep.
The awake animation loop caches blink/gaze SVG elements, but
dangerouslySetInnerHTML replaces the entire SVG when the sleeping
recipe is applied. The old refs' open-eye clip geometry was being
used to querySelector into the new sleeping SVG and reset the
clip-paths back to the open position, causing both open eyes and
closed-eye lines to render simultaneously.
The sleeping recipe already sets clip rects to the closed position
in the SVG string, so no JS-side clip-path reset is needed.
Clearing the caches prevents stale operations and lets fresh
caching happen naturally when Blobbi wakes up.
The debounce hook was re-publishing a kind 31124 event with identical
evolution content 5s after every interaction, because the primary
write path already persisted the same data inline. Now compares the
serialized content against the fresh event before publishing and
skips when they match. The hook still fires for the one case where
it is genuinely needed: event-based backfill from Nostr queries.
Five targeted fixes to the evolution mission persistence flow:
1. Inline evolution content into interaction write paths: all three
action hooks (direct action, inventory item, companion item use) now
read the updated evolution from the session store and embed it into
the 31124 content in the same publish. The debounce hook remains as
a safety net for event-based backfill, not the primary persistence.
2. Scope ensuredRef per Blobbi: the 'ensure missions exist' guard in
useHatchTasks and useEvolveTasks was a plain boolean ref that would
not re-run when switching companions. Now keyed by pubkey:d.
3. Add companionD to query keys: hatch-tasks and evolve-tasks queries
were keyed by pubkey only, causing stale cache reuse across Blobbis.
4. Filter persist hook by d-tag: usePersistEvolutionProgress now checks
detail.d against companionD so it only reacts to evolution updates
for the active companion.
5. Clear evolution in switch mode: when switching incubation from one
Blobbi to another, the stopped Blobbi's 31124 content now has its
evolution[] cleared, and its session store entry is removed.
Evolution/hatch mission progress was stored in the shared Blobbonaut
profile (kind 11125) content JSON, causing split-brain state between
the per-user profile and per-Blobbi events. After reload, progress
could disappear or get overwritten across Blobbis because the session
store was keyed by pubkey only and persisted via a debounced write to
the wrong event.
Now:
- Daily missions remain on kind 11125 (per-user, correct)
- Evolution missions live on kind 31124 content JSON (per-Blobbi)
- Session store split: daily keyed by pubkey, evolution by pubkey:d
- usePersistEvolutionProgress writes to 31124 instead of 11125
- useHatchTasks/useEvolveTasks read from companion.evolution
- serializeProfileContent strips legacy evolution from 11125
- Start/stop incubation/evolution seed/clear 31124 content directly
- Interaction tallies pass companion d-tag for per-Blobbi tracking
- StartIncubationDialog: display progressionState instead of state in
restart dialog text (was showing 'active' instead of 'incubating')
- blobbi-tag-schema: update deprecated tag replacedBy and category
comment to reference progression_started_at instead of state_started_at
- blobbi.ts: update deprecation comments for incubation_time and
start_incubation to reference progression_started_at
Update JSDoc comments in useBlobbiIncubation to reference
progression_state/progression_started_at instead of the deprecated
state/state_started_at. Rename stateStartedAt to progressionStartedAt
in StartIncubationResult and StartEvolutionResult interfaces.
Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently
destroying evolution progress. The root cause was using a single state
tag for two orthogonal concerns: activity (active/sleeping/hibernating)
and progression (incubating/evolving).
Introduce progression_state and progression_started_at as new tags
orthogonal to the activity state tag. Sleep, wake, and hibernation
changes now never touch progression. Parser auto-migrates legacy
events that stored progression in the state tag on read.
- ExternalContentPage: new URL() on non-URL NIP-73 identifiers (isbn:,
iso3166:) threw TypeError crashing the page; now URL is only created
for url-type content and #-prefixed strings are used for other types
- ExternalContentPage & RelayPage: decodeURIComponent on malformed
percent-encoded URL params threw URIError; now wrapped in try/catch
- useMastodonPost: new URL() on invalid URLs inside queryFn now returns
null instead of letting the error propagate
- colorUtils/themeEvent: malformed hex color values from theme events
produced NaN CSS variables; added isValidHex guard in parseColorTags
Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently
destroying evolution progress. The root cause was using a single state
tag for two orthogonal concerns: activity (active/sleeping/hibernating)
and progression (incubating/evolving).
Introduce progression_state and progression_started_at as new tags
orthogonal to the activity state tag. Sleep, wake, and hibernation
changes now never touch progression. Parser auto-migrates legacy
events that stored progression in the state tag on read.
Validate blurhash hashes before passing them to react-blurhash's
<Blurhash> component, which throws when the encoded length doesn't
match the component count header. Malformed hashes from third-party
events (e.g. length 92 instead of 94) now gracefully fall back to a
skeleton placeholder instead of crashing the page.
- Remove temporary `true ||` debug bypasses that made nausea trigger
regardless of hunger stat. Nausea now correctly requires hunger >= 90.
- Track `cycleHadNausea` so the recipe resolver uses a consistent
nauseated face recipe for the entire reaction cycle, even after the
green fill drains to 0. This prevents a structural SVG rebuild
mid-reaction that killed SMIL spiral eye animations.
- Make shake reactions additive: starting a new shake during dizzy or
recovering no longer resets the reaction. Instead, the phase
transitions back to shaking, nausea level can only rise (max of
current and new), and the dizzy hold timer extends.
Regression-of: 91de4f80
Shake reaction system
---------------------
Introduce a reusable shake detection + reaction pipeline that triggers
dizzy visuals when Blobbi is shaken during drag, with progressive green
nausea body fill when hunger is high (>= 90, currently debug-bypassed).
Architecture follows the same phase/level/profile pattern as click
overstimulation for future extensibility (personality variants,
additional physical-stress reactions).
New files:
- shakeDetection.ts: pure motion sampling (direction reversals,
speed accumulation, energy integral)
- useShakeReaction.ts: 4-phase state machine (idle → shaking → dizzy
→ recovering), profile system, recipe resolution with nausea fill
Phases:
- idle: no shake reaction active
- shaking: user actively shaking (dizzy face + live green fill rise)
- dizzy: post-release hold (3-8s scaled by intensity, fill drains)
- recovering: nausea draining via rAF, then back to idle
SMIL animation stability fix
-----------------------------
The nausea fill level changes ~12×/sec during drain, creating a new
recipe object each tick. This broke the React.memo barrier on
MemoizedBlobbiVisual (reference equality), triggering full SVG DOM
replacement via dangerouslySetInnerHTML — killing all SMIL animations
(dizzy spirals, sleepy blinks) on every update.
Fix: both SvgRenderers now compute a structural recipe fingerprint
that clones the recipe and strips only bodyEffects.angerRise.level.
The customizedSvg useMemo depends on this string (compared by value),
so level-only changes skip the SVG rebuild. The fill level is applied
imperatively via gradient stop setAttribute() in a separate useEffect,
preserving the existing DOM and all running SMIL animations.
Reaction retrigger fix
----------------------
Both shake and overstimulation reactions had cycle-scoped refs that
were not cleaned up when the reaction drained to idle via the natural
rAF path, preventing immediate retrigger:
- Overstimulation: clicksRef (stale timestamps) now cleared at idle
- Shake: toastShownRef now reset at idle
Body fill improvements
----------------------
- angerRise generator now accepts caller-controlled bottomOpacity and
edgeOpacity so nausea (strong green) and anger (moderate red) can
have different visual intensity through the same shared generator
- Static-level fill mode uses real body bounds from path detection
instead of hardcoded coordinates
- Nausea fill drains during the dizzy hold (not only after it ends)
for a smooth continuous descent
Temporary debug bypass still active:
true || hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD
Must be removed before final merge.
Revert the interval/update-in-place approach and replace with a single
toast call that displays the chosen blocked duration (e.g. 'calm down
for 3s'). No interval, no toast handle refs, no countdown state.
Removed from previous version:
- toastHandleRef and countdownIntervalRef refs
- clearCountdown() helper
- setInterval countdown loop with toast.update() calls
- clearCountdown() calls in block-end, deactivation, and unmount paths
Regression-of: 6d9e7502
Show remaining seconds in the blocked toast (e.g. 'calm down for 4s')
and update it every second via the toast update mechanism. The toast is
automatically dismissed when the blocked phase ends. All timer state is
owned by useOverstimulationReaction so the UI layer stays simple.
Add a transparent fullscreen overlay (fixed, inset 0, z-index 99999) that
renders only while phase === 'blocked'. This prevents clicks on buttons,
links, inputs, feeds, menus, etc. while Blobbi is overwhelmed. The overlay
is removed automatically when the blocked phase ends.
Update the module doc comment and inline constant comments to accurately
describe the real escalation and cooling timelines:
- 4 clicks: mild angry face
- 6 clicks: red body fill begins (level crosses 0.2)
- 15 clicks: max level, blocked for 2-4s
- 1.5s delay + ~4s drain = ~5.5s total recovery from max
Two bugs prevented the overstimulation system from working:
1. Phase never entered 'rising' because phaseRef was mutated before
calling pushVisible(), making the phaseChanged check always false.
pushVisible is now the single owner of phase transitions — callers
must not pre-mutate phaseRef. Same bug existed in the rising→cooling
transition inside rafTick.
2. applyVisualRecipe() dropped the 'level' field when building the
bodySpec for angerRise, so the level-controlled static gradient
path was never reached.
Implement a profile-based overstimulation system that reacts to rapid
repeated clicks anywhere in the app. The system tracks a continuous
level (0-1) that rises with rapid clicks and cools down gradually when
clicks stop, with support for temporary click blocking at max level.
Architecture:
- useOverstimulationReaction hook with OverstimulationProfile interface
for future personality-based branching (angry, confused, nervous, etc.)
- Level-controlled anger-rise body effect via new 'level' parameter on
the existing angerRise spec (preserves SMIL path for existing callers)
- Visible state throttled to ~6-10fps via delta threshold to avoid
SVG re-render churn
Behavior:
- 5+ rapid clicks in 2s window triggers mild angry face
- Additional clicks progressively fill the red body effect from bottom
- 2s of no clicks starts gradual cooling (level decreases over time)
- Clicking during cooldown resumes from current level
- At max level: Blobbi blocks clicks for 2-4s with a toast notification
- After block ends: level cools naturally from 1.0 back to idle
Replace the manual parent-walk loop with closest('a, button,
[role="button"]') — simpler, handles role="button" elements, and
returns null cleanly when no clickable ancestor exists (so only the
raw pointer fallback is used).
Clamp the resolved click-origin Y to 55% of viewport height so Blobbi
looks across toward the sidebar rather than sharply downward when the
clicked item is near the bottom of the screen.
Store the nearest clickable ancestor (<a>/<button>) on pointerdown
alongside the raw coordinates. At route-change time, re-read the
element's getBoundingClientRect() to get its current visible center,
which accounts for any scroll shifts between the click and the React
effect. Falls back to the raw pointer coordinates if the element has
been unmounted.
Track the last pointerdown position in a ref. On route change, if a
recent click exists (<1s), glance at that position for ~700ms first,
then look at the center-top of the new page for 2-6s. Programmatic
navigation (no recent click) falls back to immediate center-top.
Single-file change in useRouteReaction.ts. No changes to attention,
gaze, or state hooks.
Guard makeDecision() so it bails out while attentionTarget is active.
Previously the decision loop could fire during the attending window,
transitioning Blobbi to walking/idle and breaking the attend-ui gaze
lock. The attention-end handler already resumes decisions when it
clears, so this guard is safe.
On route change, immediately trigger a preliminary attention target at
viewport center-top before the 250ms DOM-mount delay. This ensures the
gaze system never falls to random or mouse-follow mode during the gap,
which previously caused Blobbi to drift leftward toward the sidebar.
The delayed reaction still fires at 250ms and replaces the preliminary
target with a precise DOM-measured position.
Keep previous attention alive during the 250ms route-reaction delay
instead of clearing it immediately. This prevents the gaze system from
falling to random mode (which could point toward the sidebar) while
waiting for the new page's DOM to mount.
- Split cancelReaction into cancelPendingTimeouts (timeouts only) and
full cancel (timeouts + attention); route changes use the former
- Add bypassCooldown option to triggerAttention so the delayed reaction
can override the kept-alive attention without being blocked by cooldown
- Stabilize triggerAttention via uiAttentionRef instead of stale closure
clearAttention() now resets lastAttentionTimeRef to 0 so that a
triggerAttention call immediately after a forced clear is not silently
rejected by the 1500ms cooldown guard.
Remove right-sidebar detection and multi-target chaining. The generic
reaction now fires a single attention target at the top-center of the
main content area for a random 2-6 seconds.
- Remove findRightSidebarPosition() and all sidebar scan logic
- Replace fixed 1200ms duration with random 2000-6000ms
- Compute target position at reaction start via live DOM query
and window.innerWidth/Height fallback (no stale closure)
- Cancel both pending timeouts and active attention on drag
- Remove viewport prop from hook options (no longer needed)
- Update docstrings to describe the simplified behavior
On page navigation the companion now briefly pauses and scans the
layout areas that changed. Center content is always scanned first,
followed by the right sidebar if a non-placeholder sidebar is
detected in the DOM.
Implementation:
- useRouteReaction.ts: thin orchestration hook that watches pathname,
determines changed areas, and chains triggerAttention calls via
setTimeout. Cancels on new route change, drag, or unmount.
- useBlobbiCompanion.ts: wires the new hook with existing
triggerAttention/clearAttention from useBlobbiAttention.
No changes to the attention system, state machine, gaze hook, motion
hook, or entry animation. The existing attending state and attend-ui
gaze mode handle all the visual behavior.
Includes an empty ROUTE_REACTIONS map for future per-route overrides.
window.location.origin resolves to capacitor://localhost on iOS and
https://localhost on Android, which produces broken QR codes, broken
copy-link actions, and a broken remote-login callback URL on native
builds.
Add an optional shareOrigin field to AppConfig and a useShareOrigin
hook that falls back to window.location.origin when unset. Replace
all 13 call sites that build shareable URLs.
The origin can be configured three ways, in order of precedence:
user localStorage > ditto.json > VITE_SHARE_ORIGIN env var. Native
deployments can set VITE_SHARE_ORIGIN=https://ditto.pub at build time
so that shared URLs resolve correctly when opened on another device
(and get caught by DeepLinkHandler when opened on the same app via
Universal/App Links).
Regression-of: a12d5db5
- Remove dead export useMusicTracksByGenre from useMusicData
- Sanitize metadata?.picture through sanitizeUrl() in ProfileCard,
MusicHeroCard, MusicTrackCard, and MusicTrackRow
- Fix MusicDiscoverTab featured section where skeleton and loaded
content could render simultaneously (make mutually exclusive)
- Add error state handling to all 4 music tab components
- Move genre filtering from client-side to relay-level #t filtering
in MusicTracksTab via new genre param on useMusicFeed