- 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
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.
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.
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.
- 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
- 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)
- 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
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.
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.
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
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
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.
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.
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.
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.
- 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
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.
- 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
- 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
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.
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.
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.
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.
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.
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)
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
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.
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.
- 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
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.