Baby (4 segments):
- hunger -8/hr, happiness -4.5/hr, hygiene -6/hr, energy -9/hr
- health base -0.4/hr (was -0.75)
- health penalty thresholds aligned to segment boundaries:
mild at < 50 (attention), strong at < 25 (urgent)
— was < 70/40, which fired penalties in the 'okay' range
- health regen threshold lowered to 76 (baby good = 4/4 starts at 76)
— was 80
Adult (10 segments):
- hunger -5/hr, happiness -2.5/hr, hygiene -4/hr, energy -5.5/hr
- health base -0.25/hr (was -0.4)
- penalty thresholds unchanged (already align with 10-segment model)
- regen threshold unchanged at 80
Pacing:
- Baby first 'okay' stat at ~2.7hr, first 'attention' at ~5-6hr
- Adult first 'okay' stat at ~5-6hr, first 'attention' at ~7-8hr
- Growing up feels like increased resilience, not more annoyance
Sleep behaviour, item values, careState mapping, segmented UI
rendering, and Nostr persistence are unchanged. Tests updated to
cover new rates, penalty alignment, and regen threshold.
Part 1 — Segmented rings:
Replace the continuous strokeDasharray progress ring with discrete arc
segments driven by the segment display model. Baby/egg shows 4 segments,
adult shows 10. StatIndicator exports a reusable SegmentedRing component.
When the segments prop is absent, the old continuous ring renders as a
backward-compatible fallback.
Part 2 — Ring gap fix:
Switch from strokeLinecap round to butt so gaps are not consumed by cap
extensions. Increase gapDeg to 16/20 (md/sm), bump muted opacity to 0.12.
Offset the start angle by half a gap so the first gap straddles 12-o'clock,
making the ring visually centred and symmetrical.
Part 3 — Sleep modifiers:
Sleeping is now restorative instead of punitive:
- Energy regen: +40/hr (baby), +35/hr (adult) — up from +6/+5
- Hunger/happiness/hygiene: decay at 20% of awake rates
- Health base decay: pauses (0) while sleeping
- Health penalties: reduced to 25% of awake strength
Awake decay rates, health penalty thresholds, item/action values,
hibernating behaviour, and Nostr persistence are unchanged.
Adds 17 focused unit tests covering baby/adult sleeping, awake decay
baseline, and hibernating-is-not-sleeping.
Replace scattered warning/critical status checks with careState from
getBlobbiStatDisplayState in the three UI consumers:
- StatIndicator (shared): new careState prop takes precedence over
deprecated status prop; badge shown for attention/urgent, pulse for
urgent only.
- BlobbiRoomHero (inline indicator): same careState-driven logic.
- BlobbiWidget: passes careState instead of getStatStatus result.
- BlobbiPage companion selector: care badge now shows when any stat is
urgent OR two-plus stats are attention (was: any stat < 40). Eggs are
always protected so they never trigger the badge.
Old threshold constants and getStatStatus are kept — no deletions.
No changes to decay rates, sleep, items, status-reactions,
needDetection, SVG ring rendering, or Nostr persistence.
Introduce getBlobbiStatDisplayState() — a read-only helper that derives
UI segment counts, care states, and badge/pulse flags from internal
1–100 stats without changing any gameplay behaviour.
Egg is always 'protected' with full segments. Baby maps to 4 segments
(urgent/attention/okay/good). Adult maps to 10 segments with wider
threshold bands. Values are clamped to STAT_MIN–STAT_MAX.
Includes 38 unit tests covering all boundary values, clamping, and
flag correctness.
- Extract readSeedUint32() for raw 32-bit reads instead of misusing
deriveIndexFromSeed with max=0x100000000 (which was a no-op modulus).
deriveIndexFromSeed is now only used for bounded array indexing.
- Guard deriveAdultFormFromSeed against NaN from short/invalid seeds.
- Guard adjustSeedForAdultType against indexOf returning -1 for
unknown form values.
Cache getBoundingClientRect() result in OverstimulationBlockOverlay
to avoid four forced reflows on the same element per activation.
Add sanitizeSvgColor() guard in generateAngerRiseEffect() so that
config.color is validated before interpolation into SVG stop-color
attributes, preventing injection if a future caller passes untrusted
color strings.
Scroll to top before applying the zoom transform and restore the
saved scroll position after zoom-out completes. The transform on
#root creates a new containing block that breaks sticky positioning
of the mobile top/bottom nav bars when the user is scrolled down.
- useFillLevelUpdate: shared recipe fingerprint + imperative gradient
stop updates, extracted from BlobbiAdultSvgRenderer and
BlobbiBabySvgRenderer (removed ~60 duplicated lines from each)
- useReactionDrain: shared rAF-based level drain loop with throttled
React state push, extracted from useOverstimulationReaction and
useShakeReaction (removed ~170 duplicated lines from each)
Net reduction: ~285 lines across the 4 consumer files.
The previous approach put a ref on the pointer-events-auto wrapper div,
which is a full-width block element -- getBoundingClientRect returned
the page width, not Blobbi's position. Now we query the companion's
actual fixed-position container via [data-blobbi-companion] to get
the true visual bounding rect.
When Blobbi hits max overstimulation, the entire UI now zooms toward
Blobbi's face (transform on #root) while a radial shockwave expands
from the companion and a red vignette dims the screen edges. On
recovery the zoom eases back out and the vignette fades.
The overlay is portaled onto document.body so it stays at viewport
scale while #root is scaled. Body overflow is hidden during the zoom
to prevent scrollbar flash.
Removes the canvas-based crumble/debris system (crumbleEngine.ts) in
favor of this simpler CSS-driven approach.
Replace the invisible click shield with a dramatic visual sequence when
Blobbi reaches max overstimulation: a radial shockwave expands from
Blobbi, a dark backdrop covers the UI (Blobbi stands alone in the void),
and canvas debris particles rain down like rubble. On recovery the
backdrop fades and debris converges back.
Also fix the debug bypass in useShakeReaction (true || threshold) that
shipped nausea on every shake regardless of hunger stat, and remove the
toast notification replaced by the visual overlay.
Pass lookMode="follow-pointer" to BlobbiStateCard in PostDetailPage,
matching the feed card behavior so Blobbi eyes follow the cursor on the
detail view as well.
Add dir="auto" to NoteContent, BioContent, and DM message bubbles
so the browser's Unicode Bidirectional Algorithm automatically detects
text direction from the first strong directional character.
The path started and ended at the same point (100,105), making it a
triangle instead of a quadrilateral. The bottom-right hexagon vertex
(140,130) was missing entirely. Changed to trace center → right-mid →
bottom-right vertex → inner bottom-right point.
Fixed in all four locations: both .svg source files and both inlined
constants in adult-svg-data.ts.
The Poll/Spoiler popover opened side="bottom" from the toolbar, which
is near the bottom edge of the dialog. Because the dialog container
uses overflow-hidden (needed for flex layout containment) and the
PortalContainerProvider portals content inside the dialog DOM, the
popover was clipped.
Switch to side="top" so the menu opens upward into the visible area
of the modal.
The app renders Blobbi SVGs from inlined string constants in
adult-svg-data.ts, not from the .svg source files. The previous commits
only fixed the source files. This syncs all three fixes into the
inlined CRYSTI_BASE and CRYSTI_SLEEPING constants:
- Add animateTransform groups to CRYSTI_BASE sparkle circles
- Fix self-intersecting pink facet path in both constants
- Match sleeping facet opacities to base values
The sleeping SVG had all six facet opacities reduced by 0.2 compared to
the base, making it look washed out. Only the eyes/mouth should differ
between states — the body colors should stay vibrant, consistent with
how bloomi handles its sleeping variant.
The base SVG sparkle circles were missing animateTransform tags, causing
them to render static instead of orbiting like the sleeping variant.
Wrapped them in two animated groups matching crysti-sleeping.
The top-left pink facet (crystiFacet2) had a self-intersecting path
(bowtie shape) that caused inconsistent fill rendering. Reordered the
vertices to trace a proper convex quadrilateral in both base and sleeping
SVGs.
BlobbiStateCard gains an optional lookMode prop (default: 'forward'),
threaded through to BlobbiStageVisual. Only the NoteCard feed call site
passes 'follow-pointer'; post detail, embedded notes, and embedded naddr
keep the default forward gaze.
The global pointer listener in useBlobbiEyes now also tracks touchstart
and touchmove so the effect works on mobile.
The module-level setupInFlightFor guard had a race condition: the
effect cleanup unconditionally deleted the pubkey from the guard set
even when setup() was already mid-flight. If a parent re-render caused
the component to unmount/remount during the async publish window, the
new instance passed all guards and created a second egg.
Fix: track whether setup() has started in a ref. Cleanup only releases
the guard when the timer was cancelled before setup began; otherwise
setup() releases it in its own finally block.
Also stabilize the eggOnly completion timer by reading onComplete
through a ref, preventing the 1500ms timer from resetting on every
parent re-render that creates a new inline callback reference.
- Remove hexToHslLocal from blobbi.ts; reuse shared hexToHsl from
color-guardrails.ts (eliminates duplicate implementation)
- Add abort flag to useSeedIdentitySync useEffect so the async sync
loop stops on unmount and never calls updateCompanionEvent after
teardown
- Replace relative date wording in compat cutoff comment with
absolute date only
- Fix stale STEP numbering in BlobbiPage.tsx (5/6 → 4/5)
- Unify adult-form derivation: replace charCode hash in
deriveAdultFormFromSeed with the canonical seed-slice algorithm
(offset [40..48]), and remove the duplicate deriveAdultTypeFromSeed
from blobbi.ts (all call sites now use the single canonical function)
- Guard unconditional console.log in parseBlobbiEvent behind
import.meta.env.DEV so it no longer spams production consoles
- Remove dead deriveColorsFromSeed (zero callers, was deprecated on
arrival) and its stale JSDoc reference in adjustSeedForAdultType
- Replace brute-force loop in adjustSeedForAdultType with a direct
O(1) computation: since the derivation is parseInt(slice, 16) % len,
the target index itself is always a valid candidate
- Add fetchFreshEvent to useSeedIdentitySync before each publish,
matching the project convention for replaceable event mutations and
preventing stale-cache overwrites on multi-device usage
Pandi now applies baseColor and secondaryColor instead of staying
hardcoded black and white.
Light areas (body, head): a very soft tinted-white derived from
baseColor's hue at L=95 S=min(baseSat,30). Clearly not pure white,
but stays close — preserves the hue family without going full-
strength. Stroke uses the same hue at L=90 S=20.
Dark areas (ear patches, eye patches, inner ears, arms, legs, nose,
mouth): derived from secondaryColor's hue forced to L=20 S=30
(primary dark) and L=27 S=20 (lighter dark for gradients and inner
fills). Maintains proper panda light-vs-dark contrast.
Eye color: unchanged — still applied via pandiPupil3D gradient
replacement in the existing applyPupilGradient path.
No other adult form customizers were modified.
The adult form dropdown now works with the seed-truth model: selecting
a different form calls adjustSeedForAdultType() and writes the adjusted
seed through the normal update path. syncMirrorTagsToSeed then derives
all mirror tags (adult_type, colors, pattern, etc.) from the new seed.
Previously the dropdown wrote adult_type as a raw tag that was
immediately overwritten by the seed-derived value — effectively a no-op.
Also adds a read-only seed display and a note explaining that changing
the form re-derives the visual identity.
BlobbiCompanion.adultType now derives from the effective seed for adult
Blobbies instead of reading the (potentially stale) stored tag. Falls
back to the tag only for legacy events without a seed.
Renames to reflect the broadened scope of the sync system:
- needsColorSync -> needsSeedIdentitySync
- eventNeedsColorSync -> eventNeedsSeedIdentitySync
No behavior change beyond making adultType consistent with the seed-
truth model and aligning names with what the code already does.
Seed now determines the complete visual identity: colors, pattern,
special_mark, size, and adult_type. All corresponding tags are persisted
mirrors that get overwritten on every republish via syncMirrorTagsToSeed.
Key changes:
deriveAdultTypeFromSeed: new derivation at seed offset [40..48], indexing
into the 16-element ADULT_FORMS array via deriveIndexFromSeed.
deriveSeedIdentity: replaces deriveColorsFromSeed as the single entry
point for the complete seed-derived visual trait set.
Temporary adult-type compatibility (cutoff: 2026-05-01 UTC):
For existing adult Blobbies whose stored adult_type doesn't match the
seed-derived form, adjustSeedForAdultType brute-forces the seed bytes
at offset [40..48] to produce the stored form. This preserves existing
adult forms during the transition. After the cutoff, parseBlobbiEvent
skips this adjustment automatically and the code becomes dead.
eventNeedsColorSync: now checks all mirror tags (colors, pattern, mark,
size, adult_type for adults), not just colors.
syncMirrorTagsToSeed: expanded to overwrite all mirror tags including
adult_type on every republish through the merge pipeline.
useSeedIdentitySync hook: new hook wired into BlobbiPage that checks
filteredCompanions on load and republishes any with stale mirror tags.
Tracks synced d-tags in a ref to avoid loops. Processes sequentially
to avoid relay rate-limiting.
BlobbiPage: calls useSeedIdentitySync(filteredCompanions) after the
existing dedup/filter step, so only visible companions are synced.
When a seed exists, base_color / secondary_color / eye_color are now
always derived from the seed via deriveColorsFromSeed(). Explicit color
tags no longer override seed-derived values -- they are persisted as
mirrors for relay indexing and backward compatibility.
Changes:
- deriveVisualTraits: seed path ignores color tags entirely; no-seed
legacy path unchanged
- deriveColorsFromSeed: new single entry point for canonical color
derivation (seed → HSL → guardrails)
- syncColorTagsToSeed: overwrites stale color tags on every republish
via mergeBlobbiStateTagsForRepublish
- eventNeedsColorSync: detects events whose stored color tags differ
from seed-derived values
- BlobbiCompanion.needsColorSync: lightweight flag for UI-driven
republish of stale events
Existing Blobbies with a seed will change appearance on next render
(seed-derived colors replace old palette-indexed tags). Events are
backfilled on their next republish through the merge pipeline.
Blobbi colors are now derived as full-spectrum HSL values from the seed
hash instead of indexing into fixed 10/10/8-element palette arrays.
Generation changes:
- deriveBaseColorFromSeed: splits 32-bit seed value into H(0-359),
S(30-100), L(30-75) via successive division
- deriveSecondaryColorFromSeed: harmonized from base — same saturation,
hue shifted ±20°, lightness +12..25 above base (guarantees visible
3D gradient)
- deriveEyeColorFromSeed: independent H(0-359), S(40-100), L(10-55)
for dark vivid pupils
Both deriveVisualTraits() and buildEggTags() now pipe seed-derived
colors through applyColorGuardrails() before use. Guardrails are never
applied to explicit tag values — the tag-priority rule is preserved.
Legacy palette arrays are marked @deprecated but kept for reference.
No rendering code, customizers, or Pandi behavior changed.
Pure HSL-based validation/adjustment functions that will make arbitrary
color generation safe in a follow-up step. Guardrails ensure:
- base colors stay within a lightness range where the SVG gradient
pipeline (lighten/darken) produces visible 3D shading
- secondary colors are perceptually distinct from base colors so body
gradients don't collapse into flat fills
- eye colors have enough contrast to remain visible on white sclera
and visually distinct from the body
Generation-side only: no rendering code, customizers, or existing
tagged colors are touched.
Derive the migration petId from sha256(pubkey + legacyD) instead of
crypto.getRandomValues(). The same legacy Blobbi now always produces the
same canonical d-tag, seed, and visual traits regardless of which device
or session triggers the migration.
The equivalence guard (findCanonicalEquivalent) still runs first, so
pre-existing canonicals from the random-petId era are reused and no
duplicate is created.
filterMigratedLegacyCompanions now runs a second pass that groups
canonical companions by their migrated_from tag. Within each group
only the newest event (highest created_at) is kept; the rest are
hidden from the collection UI. Canonical companions without the tag
are never grouped — they pass through untouched.
This closes the remaining duplicate-in-UI gap left intentionally by
the earlier legacy→canonical dedup work.
Replace the name-only equivalence rule with a tiered priority:
1. migrated_from exact match (canonical event's migrated_from tag equals
the legacy d-tag) — strongest signal, written during migration and
preserved across all subsequent Blobbi updates.
2. Same normalized name + same raw base_color tag — covers older canonical
copies created before migrated_from existed, where both events have an
explicit base_color tag that matches.
3. Same normalized name when the legacy event has no base_color tag —
weakest fallback for genuinely old bare legacy events with no visual
tags to compare.
All tiers still require the legacy d-tag to be absent from profile.has
(the migration-completion guard).
Audited that migrated_from survives all Blobbi lifecycle operations:
mergeBlobbiStateTagsForRepublish preserves it as an unknown tag,
validateAndRepairBlobbiTags passes it through (not in schema, not
deprecated), and stage transition cleanup does not touch it.
Legacy Blobbi events (d=blobbi-{name}) persisted on relays after migration
to canonical format (d=blobbi-{hex}-{hex}), causing them to appear alongside
their canonical copies in the UI. Interacting with a still-visible legacy
Blobbi triggered another migration each time, creating unbounded duplicates.
Three changes:
1. Filter migrated legacy Blobbies from the rendered collection. A legacy
Blobbi is hidden only when a canonical Blobbi with the same normalized
name exists AND the legacy d-tag is no longer in profile.has (confirming
migration already occurred).
2. Guard ensureCanonicalBlobbiBeforeAction against re-migration. Before
creating a new canonical event, query all companions and look for an
existing canonical equivalent by normalized name. If found, reuse it
and fix up profile.has/current_companion instead of migrating again.
3. Store a migrated_from tag on newly migrated events for future stronger
equivalence lookups (additive, not depended on by current dedup logic).