Introduce kind 3043, a new Nostr event that attests an on-chain Bitcoin
payment against a target event or profile. Because every Nostr pubkey
deterministically maps to a Taproot address, any user can receive an
on-chain zap without configuring lud06/lud16 — the zap button now
appears on every post whose author is not the current user.
Publishing flow: sender builds and broadcasts a Bitcoin transaction
paying the recipient's derived Taproot address, then publishes a
kind 3043 event with an `i` tag (`bitcoin:tx:<txid>`), the recipient's
`p`, the target's `e` / `a`, and a self-reported `amount` in sats.
Before displaying or counting a kind 3043 event clients verify the
referenced transaction on-chain and use the sum of outputs paying the
recipient's address as the authoritative amount, capping the sender's
claim at the verified value to prevent spoofing.
Lightning zaps remain available as an opt-in tab inside the zap dialog
whenever the author has a Lightning address configured; otherwise the
dialog is purely on-chain. Defaults favour on-chain: USD amount presets
($1 / $5 / $10 / $25 / $100), fee-speed selection, and a 3-step
form → confirm → success flow mirroring SendBitcoinDialog.
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).
The dismiss animation only translated the image strip, leaving the top
bar, nav buttons, dot indicators, and bottom bar stationary — visible
as a jarring flicker of controls. The backdrop also flashed back to full
opacity for one frame before the portal unmounted.
Wrap all visible content (everything except the backdrop) in a single
container that receives the translateY transform so the entire UI sweeps
away as one unit. Reorder the setTimeout callback so onClose fires
before clearing the animating lock, and add an unmount-cleanup effect as
a safety net against stuck controls.
Regression-of: cc655891
App.tsx had a useEffect that unconditionally set SystemBarsStyle.Dark
(white icons) on mount, overriding the theme-aware logic in main.tsx
that had already set the correct style. On light themes this produced
white-on-white status bar text.
Remove the hardcoded override entirely — main.tsx handles initial
detection and MutationObservers cover all subsequent theme changes.
The hover wobble animation was triggering on touch devices via the
sticky :hover pseudo-class, rotating the envelope while the user
was trying to tap it. Restrict the wobble to true pointer-hover
devices with @media (hover: hover) and (pointer: fine).
Also tighten the entrance animation: remove rotation so tap targets
stay stable, reduce duration from 0.4s to 0.3s, start closer to
final size (0.85 vs 0.6), and cap stagger delay at 300ms so later
envelopes settle before the user can scroll to them.
Users can now swipe up or down to dismiss the full-screen image
lightbox, matching the native mobile pattern of flicking an image
away instead of reaching for the X button. The image follows the
finger with opacity fade, and commits the dismiss after 15% of
viewport height. When zoomed in the gesture is disabled so it
doesn't conflict with panning.
Applies to both the main Lightbox (feeds, galleries, media collage)
and the ProfileImageLightbox (avatar/banner taps).
When a user typed in the inline ComposeBox then tapped the FAB (which
covers the Post button), the modal opened with the same draft text.
After posting from the modal, the inline ComposeBox still showed the
old text because it was a separate React instance with its own state,
leading to accidental double-posts.
Bump a key on the inline ComposeBox after a successful modal post so
React remounts it, picking up the already-cleared localStorage draft.
The deletion and report queries were unscoped (fetching globally) and the
moderation overlay needs more design work. Strip it out for now and leave
TODOs for a follow-up.
Adds a new autoplayVideos config field and a toggle in Settings > Content >
Video Playback. When enabled, videos auto-play muted in feeds, collage
thumbnails, profile sidebar tiles, the Vines feed, and the VideoPlayer
component. The preference syncs across devices via encrypted settings.
usePlayerControls now listens for the volumechange event to keep the
volume UI in sync when the video is programmatically muted for autoplay.
Eyebrow Y position was calculated as eye.cy + offsetY, which broke on
forms with large eye whites (catti ry=16, froggi/owli r=22, droppi/
pandi/rocky r=12) because the fixed offset did not account for the
distance between eye center and eye top.
Propagate eye white vertical radius through the pipeline: write
data-eye-rx/ry on blink groups in addEyeAnimation, read data-eye-ry
in detectFromProcessedSvg, add eyeWhiteRy to EyePosition type. The
eyebrow formula now rebases recipe offsets from center-relative to
top-relative using the actual eye white radius, producing a consistent
gap above the eye top regardless of eye size.
Remove FORM_EYEBROW_OFFSETS (owli, froggi) — the radius-aware formula
handles all forms correctly without per-form overrides. Baby eyebrow
formula is unchanged.
detectBodyPath() only matched <path> elements, so 10 of 16 adult forms
failed body detection — anger-rise was silently skipped and dirt/dust
fell back to hardcoded positions.
Add data-blobbi-body="true" marker to the primary body element of all
16 forms (base + sleeping = 32 elements). Extend detectBodyPath() with
a marker-first strategy that supports <circle>, <ellipse>, and <rect>
via path synthesis. Update the anger-rise overlay insertion regex in
apply.ts to find the marked element instead of only matching <path>.
Existing gradient-name and comment-based fallbacks remain for backwards
compatibility.
The eyeColor fallback replaced known pupil fills with arbitrary colors,
causing isPupilElement() to no longer recognize them via PUPIL_COLORS.
Inject a data-blobbi-pupil marker during the fill replacement and check
for it first in both isPupilElement() copies so detection is independent
of the actual fill color value.
Regression-of: 9c20102d
eyeColor was silently dropped for 12 of 16 adult forms because they use
hardcoded fill attributes on pupil circles instead of gradient references.
Add a scoped flat-fill fallback in applyPupilGradient() that replaces the
known default pupil color within the <!-- Pupils --> comment block only.
secondaryColor was threaded through the type system but never read by any
adult customizer. Following the baby two-tone pattern (secondaryColor at
center, baseColor at edge), add an optional innerColor parameter to the
gradient builders and pass secondaryColor to each form's main body
gradient. Pandi remains excluded from body color changes by design.