Compare commits

...

9 Commits

Author SHA1 Message Date
filemon 471916f2b5 Rewrite guide as choreography-driven actor with emerge/edge-look/target-stop behaviors
Replace the generic GuideMovement type with GuideIntent — high-level
choreography intents that describe the guide's role in the walkthrough,
not low-level animation states.

GuideIntent values:
- hidden: not rendered
- emerge_onto_modal: rise from behind modal top edge → peek → look → climb
- pace_on_modal: walk freely on modal top with edge-look-down at edges
- fall_from_surface: fall off current surface downward (gravity easeIn)
- emerge_onto_bar: rise from behind bar top edge → peek → climb
- walk_to_target: walk along bar to specific targetX center, then stop
- inspect_target: stopped above target, leaning forward 8°

MiniBlobbiGuide rewrite:
- Internal SubPhase state machine drives sequenced animations:
  emerge_peek → emerge_look → emerge_climb → pacing/walking_to_target
- Edge behavior: when guide reaches surface edge, stops, leans forward
  12° for 900ms ('oops, a drop!'), then turns around and resumes
- Emerge sequence: starts fully hidden below surface (yOffset=GUIDE_SIZE),
  rises to 50% visible (peek), pauses to look around, climbs fully up
- clip-path clips the guide below the surface during emerge so it appears
  to come from behind the modal/bar, not from empty space
- Walk-to-target: walks at 45px/s toward targetX, stops when center-
  aligned above the highlighted item, transitions to at_target with 8° lean
- Fall: easeInQuad gravity, yOffset goes negative (downward from surface)
- Float animation from companion module applied throughout for organic feel

UITourOverlay rewrite:
- Derives GuideIntent from step + transition phase:
  welcome first render → emerge_onto_modal
  welcome after emerge → pace_on_modal
  welcome Next click → fall_from_surface → emerge_onto_bar → walk_to_target
  bar_item Next/Back → walk_to_target (guide walks to new item, no teleport)
- barTargetX computed from the highlighted anchor's center point
- Separate modalSurface and barSurface calculations; activeSurface switches
  based on which context the guide is in
- emergeComplete state tracks whether the guide has finished its entrance
- TransitionPhase includes 'emerging_bar' for the modal→bar handoff
- onEmergeComplete callback switches between modal emerge and bar emerge

Type changes:
- GuideMovement → GuideIntent in ui-tour-types.ts
- MiniBlobbiGuideProps: movement → intent, added targetX, onEmergeComplete;
  removed onRiseComplete (replaced by emerge callbacks)
- Barrel export updated: GuideMovement → GuideIntent
2026-04-03 23:13:02 -03:00
filemon 560ceb6fa0 Refine UI tour guide movement, backdrop scoping, surface alignment, and dev reset
MiniBlobbiGuide rewrite:
- Replace per-frame WALK_SPEED with deltaTime-based physics at 45 px/s
  (matching the companion's mid-energy range of 20-80 px/s)
- Import and use calculateFloatAnimation from the companion module for
  organic walking bob and idle breathing, scaled to 40-50% for mini size
- Add edge pause behavior: guide stops for 800ms at each edge before
  turning, creating a natural 'look around then turn' rhythm
- Remove CSS animation classes (bounce-gentle, walk-bob, look-down) in
  favor of the companion-style float animation applied via inline styles
- Position feet directly on surfaceY with float offset applied on top

UITourOverlay changes:
- Dark backdrop only renders on the welcome step (isWelcome guard).
  Bar item steps have no full-screen overlay — just the modal, highlight,
  and guide integrated into the page.
- For bar_item steps, the guide walks across the entire bottom bar width
  (bar-item-0 left edge to rightmost bar-item right edge), not just
  the single highlighted item's bounds.
- Use ResizeObserver to accurately measure modal rect for guide surface
  calculation, avoiding stale getBoundingClientRect after layout shifts.
- Modal container uses pointer-events-none with inner pointer-events-auto
  so clicks on the backdrop don't block page interaction on non-welcome steps.

Dev reset:
- resetTourWithProfile now also resets blobbi_ui_tour_done to 'false'
  on the Kind 11125 profile alongside blobbi_first_hatch_tour_done.
- In-memory UI tour state is reset via uiTourResetRef (ref bridge avoids
  circular dependency between the dev reset callback and the UI tour hook
  which is defined later in the component).
2026-04-03 22:12:19 -03:00
filemon a765b3a7c2 Add UI walkthrough tour architecture: guide actor, anchor system, step machine
Phase 1 of the Blobbi UI tour — teaches new users the interface after
their first hatch. Activated automatically when the first hatch tour
completes and blobbi_ui_tour_done is not set.

New architecture:

- ui-tour-types.ts: Step definitions with GuideMovement states (idle,
  walking, looking_down, falling, rising, hidden), GuideAnchorTarget
  (modal/element/offscreen), and UITourStepDef (id, guideTarget,
  modalPlacement, highlightAnchor, title, body). buildUITourSteps()
  generates steps dynamically from visible bar item preferences.

- useUITour: In-memory state machine with forward/backward navigation.
  Supports start, next, prev, complete, reset. No localStorage.

- TourAnchorContext: Registry where UI elements register DOM refs by
  anchor ID. The overlay reads these to position guide/highlights
  relative to real page elements via getAnchorRect().

- MiniBlobbiGuide: Miniature animated Blobbi that walks left/right on
  a surface, idles with gentle bounce, looks down at items, falls off
  modals, and rises from the screen bottom. Uses requestAnimationFrame
  for smooth walking. Positioned via fixed coordinates.

- GuidedModal: Reusable card with title, body, Back/Next/Skip buttons.
  Supports enter/exit transitions. Does not position itself.

- UITourOverlay: Orchestrator that reads the current step and positions
  the guide on the correct surface (modal top or anchor element top),
  renders the modal at center or bottom placement, highlights the
  active anchor with a pulsing glow, and manages fall/rise transitions
  between center and bottom steps.

Integration:
- TourAnchorProvider wraps BlobbiDashboard in BlobbiContent
- BottomBarButton accepts optional anchorRef for tour registration
- BlobbiBottomBar registers bar items as 'bar-item-0', 'bar-item-1', etc.
- blobbi_ui_tour_done added to blobbi.ts (parsed, managed tags, defaults)
  but NOT persisted yet — tour can be tested repeatedly without resetting

Implemented steps: welcome (centered modal) + bar_item_0 (first bar item
highlight). Future phases add bar_item_1/2, bar_center, bar_more, and
More menu items by extending the steps array.

Tailwind: Added animations for bounce-gentle, walk-bob, look-down,
guide-fall, guide-rise, and tour-highlight-pulse.
2026-04-03 21:44:49 -03:00
filemon d0a5ed2f1e Art-direct reveal particles with stable radial layout instead of random scatter
Replace Math.random()-based particle placement with a deterministic
radial layout computed at module level (REVEAL_PARTICLES constant).

15 particles are distributed across 3 concentric rings:
- Inner ring (4 particles, r=18%): small, fast, bright — close energy
- Mid ring (5 particles, r=30%): medium size and speed
- Outer ring (6 particles, r=42%): larger, slower, subtler — atmosphere

Angles are offset from the 8 ray directions (0/45/90/...) so particles
sit between rays rather than on top of them. Each ring uses a different
angular stride (90°, 72°, 60°) to avoid alignment between rings.

Per-particle size, opacity, delay, and duration vary by ring to create
visual depth — inner particles feel close and energetic, outer ones
feel ambient and atmospheric.

The layout is a module-level constant (no Math.random in render), so
particles never jump on re-render.
2026-04-03 20:49:40 -03:00
filemon 50b0265462 Fix reveal particle spread and use companionsReady semantic for activation
- Fix particle cramping in reveal overlay: give the particle field an
  explicit 500px container (matching the glow radius) so percentage-based
  positions resolve across a wide area instead of the Blobbi's small
  bounding box. Widen the range from 20-80% to 10-90% for better spread.
  Rays and glow remain anchored to the Blobbi center.
- Replace isLoading: boolean with companionsReady: boolean in the
  activation hook input. The semantic is now positive ('data is resolved
  and ready for evaluation') rather than a generic loading/fetching flag.
  The call site passes !companionLoading (TanStack Query isLoading),
  which is false only during the initial load — background refetches
  do not block activation.
2026-04-03 20:41:30 -03:00
filemon 60747de33c Clean up first-hatch tour: real loading state, dev naming, reveal centering
- Wire real companionsFetching prop into BlobbiDashboard and pass it to
  useFirstHatchTourActivation instead of hardcoded isLoading: false
- Rename skipPostRequirement to skipToEggGlow in both BlobbiPage and
  BlobbiDevEditor (no post requirement exists in the simplified flow)
- Update dev button labels: 'Skip to Glow', 'Restart Tour',
  'Reset to Egg + Tour' with accurate titles/descriptions
- Handle async resetTour consistently with void in all onClick handlers
- Fix reveal overlay centering: move rays/glow/particles inside the
  Blobbi visual container (relative positioning) instead of a separate
  viewport-centered absolute layer, so the composition is anchored to
  the Blobbi regardless of header/naming section height
2026-04-03 20:22:17 -03:00
filemon f408f2424f Remove localStorage from first hatch tour; use profile tag as sole persisted truth
- Rewrite useFirstHatchTour to use pure in-memory useState instead of
  useLocalStorage. The Kind 11125 profile tag blobbi_first_hatch_tour_done
  is now the only persisted completion signal. Refreshing mid-tour simply
  re-enters the tour if activation conditions are still met.
- Remove TourPersistedState, FirstHatchTourPersistedState, and
  FIRST_HATCH_TOUR_DEFAULT_STATE from tour-types (no longer needed).
- Remove pubkey parameter from useFirstHatchTour (no localStorage to scope).
- Remove profileOnboardingDone from activation input (unused after
  localStorage removal; blobbi_first_hatch_tour_done is the only check).
- Make dev reset (Restart Tour button) also republish the Kind 11125
  profile with blobbi_first_hatch_tour_done=false so the tour can
  immediately re-activate for testing.
2026-04-03 19:57:02 -03:00
filemon b132e03481 Fix first hatch tour reliability, reveal content, and stale data bugs
- User-scope tour localStorage key by pubkey so different users on the
  same device get independent tour state (fixes tour not starting)
- Set current_companion when auto-creating first egg so the Blobbi is
  immediately active as the floating companion
- Centralize generateBlobbiContent in blobbi.ts and use it in the reveal
  rename flow instead of publishing content: '' (fixes invalid event)
- Gate reveal overlay on companion.stage !== 'egg' so stale cache data
  never shows the egg visual instead of the newly hatched baby
- Remove duplicate generateBlobbiContent from useBlobbiStageTransition
  and useBlobbiDevUpdate in favor of the shared export
2026-04-03 18:56:37 -03:00
filemon 514dd82ad3 Simplify first-time Blobbi experience: auto-create profile + egg with instant hatch flow
Restructure the first Blobbi onboarding to feel instant, magical, and effortless:

- Add useFirstEggExperience hook: auto-creates Blobbonaut profile (kind 11125) and first egg (kind 31124) without any user confirmation, cost, or reroll
- Remove post-mission requirement from hatch tour (no 'show_hatch_card' step)
- Simplified tour flow: idle -> glowing -> crack stages -> opening -> hatching -> reveal
- Add BlobbiRevealOverlay: celebratory full-screen overlay after hatch with light rays, particles, and naming input
- Add 'blobbi_first_hatch_tour_done' profile tag for future-proof tour completion tracking (separate from blobbi_onboarding_done)
- Update useFirstHatchTourActivation to use the new dedicated tag
- Keep existing adoption flow intact for subsequent Blobbis
- Backward compatible: old users with blobbi_onboarding_done=true can still enter the hatch tour if they have a single unhatched egg
2026-04-03 16:01:41 -03:00
20 changed files with 2333 additions and 471 deletions
@@ -24,24 +24,12 @@ import {
KIND_BLOBBI_STATE,
STAT_MAX,
updateBlobbiTags,
generateBlobbiContent,
} 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 ────────────────────────────────────────────────────────────────────
/**
+24 -1
View File
@@ -96,6 +96,21 @@ export function getDaysDifference(dayA: string, dayB: string): number {
export type BlobbiStage = 'egg' | 'baby' | 'adult';
export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving';
// ─── Content Helpers ──────────────────────────────────────────────────────────
/**
* Generate the canonical `content` string for a Blobbi event at a given stage.
*
* Format: "{name} is an egg Blobbi." / "{name} is a baby Blobbi."
*
* Use this whenever publishing or republishing a Kind 31124 event so the
* content field is never left empty or inconsistent.
*/
export function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
}
export interface BlobbiStats {
hunger: number;
happiness: number;
@@ -308,6 +323,10 @@ export interface BlobbonautProfile {
currentCompanion: string | undefined;
/** Whether onboarding/tutorial is complete */
onboardingDone: boolean;
/** Whether the first hatch tour has been completed */
firstHatchTourDone: boolean;
/** Whether the UI walkthrough tour has been completed */
uiTourDone: boolean;
/** Display name for the Blobbonaut */
name: string | undefined;
/** List of owned Blobbi d-tags */
@@ -978,6 +997,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
currentCompanion: getTagValue(tags, 'current_companion'),
onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false)
|| parseBooleanTag(tags, 'onboarding_done', false),
firstHatchTourDone: parseBooleanTag(tags, 'blobbi_first_hatch_tour_done', false),
uiTourDone: parseBooleanTag(tags, 'blobbi_ui_tour_done', false),
name: getTagValue(tags, 'name'),
has: getTagValues(tags, 'has'),
coins: parseNumericTag(tags, 'coins') ?? 0,
@@ -998,6 +1019,8 @@ export function buildBlobbonautTags(pubkey: string): string[][] {
['d', getCanonicalBlobbonautD(pubkey)],
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
['blobbi_onboarding_done', 'false'],
['blobbi_first_hatch_tour_done', 'false'],
['blobbi_ui_tour_done', 'false'],
['pettingLevel', '0'],
];
}
@@ -1139,7 +1162,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
* These tags are controlled by the application and may be overwritten.
*/
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'blobbi_first_hatch_tour_done', 'blobbi_ui_tour_done', 'onboarding_done', 'has', 'storage',
// Legacy player progress tags (preserved for compatibility)
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
+17 -17
View File
@@ -29,13 +29,13 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
/** Tour dev actions for the first-hatch tour */
interface FirstHatchTourDevActions {
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
skipPostRequirement: () => void;
/** Reset the entire first-hatch tour so it can be tested again from scratch */
resetTour: () => void;
/** Jump from idle directly to egg_glowing_waiting_click (skip the auto-advance) */
skipToEggGlow: () => void;
/** Reset in-memory tour state AND Kind 11125 profile tags so the tour can re-activate */
resetTour: () => Promise<void>;
/** Current tour step id, or null if not active */
currentStepId: string | null;
/** Whether the tour has been completed */
/** Whether the tour has been completed (this session) */
isCompleted: boolean;
}
@@ -561,50 +561,50 @@ export function BlobbiDevEditor({
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Test the first-hatch tour flow without needing to create a real post.
Test the first-hatch tour. Profile tag <code>blobbi_first_hatch_tour_done</code> is the persisted signal.
</p>
<div className="flex flex-wrap gap-2">
{/* A. Skip Post Requirement */}
{/* A. Skip idle → egg glow */}
<Button
variant="outline"
size="sm"
onClick={() => {
tourDevActions.skipPostRequirement();
tourDevActions.skipToEggGlow();
}}
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
disabled={tourDevActions.currentStepId !== 'idle'}
className="gap-2 text-xs"
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
title="Jump from idle to egg_glowing_waiting_click"
>
<SkipForward className="size-3.5" />
Skip Post
Skip to Glow
</Button>
{/* B. Restart First-Hatch Tour */}
{/* B. Restart tour (resets profile tags + in-memory state) */}
<Button
variant="outline"
size="sm"
onClick={() => {
tourDevActions.resetTour();
void tourDevActions.resetTour();
}}
className="gap-2 text-xs"
title="Reset the entire first-hatch tour state so it can be tested again"
title="Reset profile tags + in-memory state so the tour can re-activate"
>
<RefreshCw className="size-3.5" />
Restart Tour
</Button>
{/* C. Reset Blobbi to Egg */}
{/* C. Reset Blobbi to egg + restart tour */}
<Button
variant="outline"
size="sm"
onClick={() => {
setStage('egg');
setState('active');
tourDevActions.resetTour();
void tourDevActions.resetTour();
}}
disabled={companion.stage === 'egg'}
className="gap-2 text-xs"
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
title="Set stage to egg + reset tour tags — click Apply to publish, then tour auto-starts"
>
<Egg className="size-3.5" />
Reset to Egg + Tour
+1 -12
View File
@@ -15,7 +15,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString, generateBlobbiContent } from '@/blobbi/core/lib/blobbi';
import type { BlobbiDevUpdates } from './BlobbiDevEditor';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -34,17 +34,6 @@ interface DevUpdateResult {
changedFields: string[];
}
// ─── Content Helper ───────────────────────────────────────────────────────────
/**
* Generate the content string for a Blobbi at a given stage.
* Format: "{name} is a {stage} Blobbi."
*/
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
}
// ─── Hook Implementation ──────────────────────────────────────────────────────
export function useBlobbiDevUpdate({
+3 -5
View File
@@ -30,7 +30,6 @@ export interface EggStatusEffects {
* Driven by the tour orchestration layer, not by EggGraphic itself.
*
* - idle: no tour effects
* - show_hatch_card: initial crack visible + auto-wiggle every 2.5s
* - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap
* - crack_stage_1: crack expands (click 1)
* - crack_stage_2: crack expands more (click 2)
@@ -40,7 +39,6 @@ export interface EggStatusEffects {
*/
export type EggTourVisualState =
| 'idle'
| 'show_hatch_card'
| 'glowing_waiting_click'
| 'crack_stage_1'
| 'crack_stage_2'
@@ -195,8 +193,8 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
setIsTapWiggling(false);
}, []);
// Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states
const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click';
// Tour: auto-wiggle effect for glowing_waiting_click state
const shouldAutoWiggle = tourVisualState === 'glowing_waiting_click';
const autoWiggleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!shouldAutoWiggle) {
@@ -228,7 +226,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching';
// Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding)
// Level 0: small central crack (show_hatch_card, glowing_waiting_click)
// Level 0: small central crack (glowing_waiting_click)
// Level 1: crack expands left/right with small branches (crack_stage_1)
// Level 2: crack expands further toward edges, more branches (crack_stage_2)
// Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening)
@@ -0,0 +1,341 @@
/**
* BlobbiRevealOverlay - Full-screen celebration overlay shown after hatching.
*
* Features:
* - Darkened backdrop
* - Newly hatched Blobbi in the center with light rays + particles
* - Naming input so the user can rename immediately
* - Click outside or press Escape to dismiss
*
* This component is reusable for future reveals:
* "Blobbi evolved", "Blobbi hatched", "special reward", etc.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Particle Layout ──────────────────────────────────────────────────────────
/**
* Pre-computed particle positions for the reveal effect.
*
* Particles are placed in polar coordinates (angle + radius from center)
* and converted to percentage offsets within a 500px field. This produces
* a radial distribution that complements the ray/glow composition instead
* of a uniform square scatter.
*
* Layout strategy:
* - 3 concentric rings at different radii (inner, mid, outer)
* - Particles staggered across rings so they don't align with the 8 rays
* - Varied sizes and animation timing per ring for depth
* - Deterministic: no Math.random() in render, stable across re-renders
*/
interface ParticleDef {
/** X offset as percentage of the 500px field (0100) */
x: number;
/** Y offset as percentage of the 500px field (0100) */
y: number;
/** Particle diameter in px */
size: number;
/** Animation delay in seconds */
delay: number;
/** Animation duration in seconds */
duration: number;
/** Opacity 01 */
opacity: number;
}
/**
* Convert polar coordinates (angle in degrees, radius as 050 percent of field)
* to x/y percentages within a square field centered at 50%/50%.
*/
function polar(angleDeg: number, radiusPct: number): { x: number; y: number } {
const rad = (angleDeg * Math.PI) / 180;
return {
x: 50 + radiusPct * Math.cos(rad),
y: 50 + radiusPct * Math.sin(rad),
};
}
/**
* The 8 rays are at 0°, 45°, 90°, … 315°. Particles are offset from
* those angles so they sit *between* rays, not on top of them.
*/
const REVEAL_PARTICLES: ParticleDef[] = (() => {
const particles: ParticleDef[] = [];
// Inner ring — 4 particles, small, fast, bright
// Angles offset ~22° from rays so they sit in the gaps
for (let i = 0; i < 4; i++) {
const angle = 22 + i * 90; // 22°, 112°, 202°, 292°
const { x, y } = polar(angle, 18);
particles.push({ x, y, size: 3, delay: i * 0.4, duration: 2.2, opacity: 0.7 });
}
// Mid ring — 5 particles, medium, moderate speed
// Staggered at ~36° increments starting at 10°
for (let i = 0; i < 5; i++) {
const angle = 10 + i * 72; // 10°, 82°, 154°, 226°, 298°
const { x, y } = polar(angle, 30);
particles.push({ x, y, size: 4, delay: 0.2 + i * 0.35, duration: 2.8, opacity: 0.55 });
}
// Outer ring — 6 particles, larger, slower, subtler
// Distributed at 60° increments starting at 35°
for (let i = 0; i < 6; i++) {
const angle = 35 + i * 60; // 35°, 95°, 155°, 215°, 275°, 335°
const { x, y } = polar(angle, 42);
particles.push({ x, y, size: 5, delay: 0.1 + i * 0.3, duration: 3.4, opacity: 0.4 });
}
return particles;
})();
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiRevealOverlayProps {
/** The companion to reveal (should be in baby stage after hatching) */
companion: BlobbiCompanion;
/** Whether the overlay is visible */
open: boolean;
/** Called when the overlay is dismissed (click outside or confirm) */
onDismiss: () => void;
/** Called when user confirms a name */
onNameConfirm: (name: string) => Promise<void>;
/** Whether the name update is in progress */
isNaming?: boolean;
/** Whether to respect reduced-motion preferences */
reducedMotion?: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRevealOverlay({
companion,
open,
onDismiss,
onNameConfirm,
isNaming = false,
reducedMotion = false,
}: BlobbiRevealOverlayProps) {
const [name, setName] = useState(companion.name === 'Egg' ? '' : companion.name);
const [isVisible, setIsVisible] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Animate in
useEffect(() => {
if (open) {
// Small delay for the animation to feel intentional
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
} else {
setIsVisible(false);
}
}, [open]);
// Focus the input when overlay opens
useEffect(() => {
if (isVisible) {
const timer = setTimeout(() => inputRef.current?.focus(), 400);
return () => clearTimeout(timer);
}
}, [isVisible]);
// Handle backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (contentRef.current && !contentRef.current.contains(e.target as Node)) {
onDismiss();
}
}, [onDismiss]);
// Handle escape key
useEffect(() => {
if (!open) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [open, onDismiss]);
const handleConfirm = async () => {
const trimmed = name.trim();
if (trimmed.length > 0) {
await onNameConfirm(trimmed);
} else {
// If empty, just dismiss without renaming
onDismiss();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isNaming) {
e.preventDefault();
handleConfirm();
}
};
if (!open) return null;
return (
<div
className={cn(
'fixed inset-0 z-50 flex items-center justify-center',
'transition-opacity duration-500',
isVisible ? 'opacity-100' : 'opacity-0',
)}
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label="Your Blobbi has hatched!"
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
{/* Center content */}
<div
ref={contentRef}
className={cn(
'relative flex flex-col items-center gap-6 z-10 px-6 max-w-sm w-full',
'transition-all duration-700 ease-out',
isVisible ? 'translate-y-0 scale-100' : 'translate-y-8 scale-95',
)}
>
{/* Celebration header */}
<div className={cn(
'flex items-center gap-2 text-amber-300',
'transition-opacity duration-500 delay-300',
isVisible ? 'opacity-100' : 'opacity-0',
)}>
<Sparkles className="size-5" />
<span className="text-lg font-semibold tracking-wide uppercase">
Hatched!
</span>
<Sparkles className="size-5" />
</div>
{/* Blobbi visual — rays/glow are positioned relative to this container
so the composition is always centered on the Blobbi itself */}
<div className={cn(
'relative flex items-center justify-center',
'transition-all duration-700 delay-200',
isVisible ? 'translate-y-0 scale-100' : 'translate-y-4 scale-90',
)}>
{/* Light rays + glow + particles (centered on the Blobbi) */}
{!reducedMotion && (
<>
{/* Radial glow — anchored to Blobbi center */}
<div
className={cn(
'absolute size-[500px] rounded-full pointer-events-none',
'transition-transform duration-1000 ease-out',
isVisible ? 'scale-100' : 'scale-50',
)}
style={{
background: 'radial-gradient(circle, rgba(251,191,36,0.3) 0%, rgba(251,191,36,0.1) 35%, transparent 70%)',
}}
/>
{/* Rotating rays — anchored to Blobbi center */}
<div
className={cn(
'absolute size-[600px] pointer-events-none',
isVisible ? 'animate-spin-slow' : '',
)}
style={{ animationDuration: '20s' }}
>
{[...Array(8)].map((_, i) => (
<div
key={i}
className="absolute top-1/2 left-1/2 h-[300px] w-[2px] -translate-x-1/2 origin-top"
style={{
transform: `translateX(-50%) rotate(${i * 45}deg)`,
background: 'linear-gradient(to bottom, rgba(251,191,36,0.4), transparent)',
}}
/>
))}
</div>
{/* Floating particles — radially distributed across a 500px field */}
<div className="absolute size-[500px] pointer-events-none">
{REVEAL_PARTICLES.map((p, i) => (
<div
key={i}
className="absolute rounded-full bg-amber-300 animate-float-particle"
style={{
left: `${p.x}%`,
top: `${p.y}%`,
width: p.size,
height: p.size,
opacity: p.opacity,
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
/>
))}
</div>
</>
)}
{/* The actual Blobbi */}
<BlobbiStageVisual
companion={companion}
size="lg"
animated
className="relative z-10 size-48 sm:size-56 drop-shadow-[0_0_30px_rgba(251,191,36,0.3)]"
/>
</div>
{/* Naming section */}
<div className={cn(
'w-full space-y-4 transition-all duration-500 delay-500',
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4',
)}>
<div className="text-center">
<p className="text-white/80 text-sm">
Give your new Blobbi a name
</p>
</div>
<Input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter a name..."
maxLength={32}
disabled={isNaming}
className="text-center font-medium text-lg bg-white/10 border-white/20 text-white placeholder:text-white/40 focus:border-amber-400/50 focus:ring-amber-400/20"
/>
<div className="flex gap-3">
<Button
variant="ghost"
className="flex-1 text-white/60 hover:text-white hover:bg-white/10"
onClick={onDismiss}
disabled={isNaming}
>
Skip
</Button>
<Button
className="flex-1 bg-amber-500 hover:bg-amber-600 text-white shadow-lg shadow-amber-500/20"
onClick={handleConfirm}
disabled={isNaming || name.trim().length === 0}
>
{isNaming ? 'Saving...' : 'Confirm'}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -1,133 +1,52 @@
/**
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
* FirstHatchTourCard - DEPRECATED
*
* Rendered directly in the BlobbiPage layout so the experience feels
* focused and guided. Adapts its messaging based on the current tour step.
* This component was used for the post-mission step of the first hatch tour.
* The simplified first-egg experience no longer requires a post mission.
*
* When the post mission is completed, the card stays visible with a
* celebratory completed state for ~2s (the parent auto-advances after
* that delay). This ensures the user sees the checkmark before the
* flow progresses to the egg-tap phase.
* Kept for backwards compatibility but no longer exported from the tour barrel.
* The inline tap-hint messaging is now rendered directly in BlobbiPage.
*
* @deprecated Use inline tap hints in BlobbiPage instead
*/
import { Send, Check, MousePointerClick } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MousePointerClick } from 'lucide-react';
import type { FirstHatchTourStepId } from '../lib/tour-types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourCardProps {
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Current tour step id for adaptive messaging */
currentStep: FirstHatchTourStepId | null;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourCard({
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
currentStep,
}: FirstHatchTourCardProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
// Determine which phase of the card to show
const isPostStep = currentStep === 'show_hatch_card';
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|| currentStep === 'egg_crack_stage_1'
|| currentStep === 'egg_crack_stage_2'
|| currentStep === 'egg_crack_stage_3';
if (!isClickStep) return null;
return (
<div className="w-full max-w-sm mx-auto space-y-4">
{/* Title + description */}
<div className="text-center space-y-1.5">
<h3 className="text-lg font-semibold">
{isClickStep
? `Tap ${capitalizedName} to hatch!`
: postCompleted && isPostStep
? `${capitalizedName} heard you!`
: `${capitalizedName} is ready to hatch!`}
Tap {capitalizedName} to hatch!
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{isClickStep
? `Tap the egg to help ${capitalizedName} break free.`
: postCompleted && isPostStep
? 'Your post was shared. Get ready to hatch...'
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
Tap the egg to help {capitalizedName} break free.
</p>
</div>
{/* Mission card - only during post step */}
{isPostStep && (
<div className="rounded-xl border bg-card p-4 space-y-3">
{postCompleted ? (
/* ── Completed state — celebratory, stays visible ── */
<div className="flex flex-col items-center gap-2 py-2">
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
<Check className="size-5 text-emerald-500" />
</div>
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
Post shared!
</p>
<p className="text-xs text-muted-foreground">
Continuing in a moment...
</p>
</div>
) : (
/* ── Pending state — post mission ── */
<>
<div className="flex items-start gap-3">
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium">Share a hatch post</p>
<p className="text-xs text-muted-foreground">
Your post must include:
</p>
<p className="text-xs font-medium text-primary break-words">
{requiredPhrase}
</p>
</div>
</div>
<Button
size="sm"
className="w-full"
onClick={onCreatePost}
>
<Send className="size-3.5 mr-2" />
Create Post
</Button>
</>
)}
</div>
)}
{/* Tap hint during click steps */}
{isClickStep && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<MousePointerClick className="size-4" />
<span>Tap the egg</span>
</div>
)}
{/* Extra hint for post step */}
{isPostStep && !postCompleted && (
<p className="text-xs text-center text-muted-foreground">
You can add extra text before or after the required phrase.
</p>
)}
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<MousePointerClick className="size-4" />
<span>Tap the egg</span>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
/**
* GuidedModal - Reusable tour explanation card.
*
* Renders a card with:
* - Title and body text
* - Previous / Next navigation buttons
* - Optional close/skip action
*
* Used by the UI tour overlay for each step's explanation.
* Does not position itself — the parent controls placement.
*/
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface GuidedModalProps {
/** Title text */
title: string;
/** Body / explanation text */
body: string;
/** Whether a "Previous" button should be shown */
showPrev: boolean;
/** Whether a "Next" button should be shown */
showNext: boolean;
/** Label for the next button (defaults to "Next") */
nextLabel?: string;
/** Called when user clicks Previous */
onPrev?: () => void;
/** Called when user clicks Next */
onNext?: () => void;
/** Called when user clicks Skip/Close */
onSkip?: () => void;
/** Additional className for the outer container */
className?: string;
/** Whether the modal is visible (for enter/exit transitions) */
visible?: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function GuidedModal({
title,
body,
showPrev,
showNext,
nextLabel = 'Next',
onPrev,
onNext,
onSkip,
className,
visible = true,
}: GuidedModalProps) {
return (
<div
className={cn(
'w-full max-w-sm rounded-2xl border border-border bg-card/95 backdrop-blur-md shadow-2xl',
'transition-all duration-500 ease-out',
visible
? 'opacity-100 translate-y-0 scale-100'
: 'opacity-0 translate-y-4 scale-95 pointer-events-none',
className,
)}
>
<div className="p-5 space-y-3">
{title && (
<h3 className="text-base font-semibold leading-tight">
{title}
</h3>
)}
{body && (
<p className="text-sm text-muted-foreground leading-relaxed">
{body}
</p>
)}
</div>
<div className="flex items-center justify-between px-5 pb-4">
{/* Left: Previous or Skip */}
<div>
{showPrev ? (
<Button
variant="ghost"
size="sm"
onClick={onPrev}
className="gap-1 text-muted-foreground"
>
<ChevronLeft className="size-4" />
Back
</Button>
) : onSkip ? (
<Button
variant="ghost"
size="sm"
onClick={onSkip}
className="text-muted-foreground"
>
Skip
</Button>
) : (
<div />
)}
</div>
{/* Right: Next */}
<div>
{showNext && (
<Button
size="sm"
onClick={onNext}
className="gap-1"
>
{nextLabel}
<ChevronRight className="size-4" />
</Button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,372 @@
/**
* MiniBlobbiGuide - Choreography-driven miniature Blobbi tour guide.
*
* This is NOT a generic walker. It is a character actor that performs
* staged sequences based on the GuideIntent from the orchestrator:
*
* emerge_onto_modal → peek from behind modal top → climb up → start pacing
* pace_on_modal → walk left/right, edge-look-down at edges
* fall_from_surface → fall off current surface downward
* emerge_onto_bar → peek from behind bar top → climb up
* walk_to_target → walk along bar to targetX, stop when aligned
* inspect_target → idle above target, leaning forward / looking down
*
* Each intent drives internal animation phases. The component manages
* its own sub-phase progression with deltaTime-based physics.
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { calculateFloatAnimation } from '@/blobbi/companion/utils/animation';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { GuideIntent } from '../lib/ui-tour-types';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Size of the mini guide in pixels */
export const GUIDE_SIZE = 48;
/** Walking speed in pixels per second (companion mid-energy range) */
const WALK_SPEED = 45;
/** How close to edge before triggering edge-look (px from surface edge) */
const EDGE_THRESHOLD = 12;
// Timing constants (ms)
const EMERGE_PEEK_DURATION = 600;
const EMERGE_LOOK_DURATION = 800;
const EMERGE_CLIMB_DURATION = 500;
const EDGE_LOOK_PAUSE = 900;
const FALL_DURATION = 500;
// ─── Internal Sub-Phases ──────────────────────────────────────────────────────
type SubPhase =
| 'hidden'
| 'emerge_peek' // Partially visible behind surface, rising slowly
| 'emerge_look' // Paused at peek, looking around
| 'emerge_climb' // Climbing up onto the surface
| 'pacing' // Walking freely on surface
| 'edge_looking' // Stopped at edge, leaning forward
| 'walking_to_target' // Walking toward a specific X
| 'at_target' // Stopped above target, inspecting
| 'falling'; // Falling off surface
// ─── Types ────────────────────────────────────────────────────────────────────
export interface MiniBlobbiGuideProps {
companion: BlobbiCompanion;
/** The high-level choreography intent from the orchestrator */
intent: GuideIntent;
/** Left edge of the walking surface (viewport px) */
surfaceLeft: number;
/** Right edge of the walking surface (viewport px) */
surfaceRight: number;
/** Y position of the surface top edge (viewport px) */
surfaceY: number;
/** Target X position to walk to (viewport px, center of target item) */
targetX?: number;
/** Called when an emerge sequence completes (guide is on surface) */
onEmergeComplete?: () => void;
/** Called when a fall animation completes (guide is off screen) */
onFallComplete?: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function MiniBlobbiGuide({
companion,
intent,
surfaceLeft,
surfaceRight,
surfaceY,
targetX,
onEmergeComplete,
onFallComplete,
}: MiniBlobbiGuideProps) {
// ── Imperative animation state ──
const subPhaseRef = useRef<SubPhase>('hidden');
const xRef = useRef(0); // Local X offset from surfaceLeft
const yOffsetRef = useRef(GUIDE_SIZE); // Y offset below surface (positive = hidden below)
const facingRightRef = useRef(true);
const phaseStartRef = useRef(0); // Timestamp when current subPhase began
const lastTimeRef = useRef(0);
const animRef = useRef(0);
// React render state (synced from animation loop)
const [render, setRender] = useState({
x: 0, yOffset: GUIDE_SIZE, facingRight: true,
floatX: 0, floatY: 0, floatRotation: 0,
visible: false,
});
// ── Intent → SubPhase transitions ──
const prevIntentRef = useRef<GuideIntent>('hidden');
useEffect(() => {
const prev = prevIntentRef.current;
prevIntentRef.current = intent;
const now = performance.now();
phaseStartRef.current = now;
switch (intent) {
case 'hidden':
subPhaseRef.current = 'hidden';
yOffsetRef.current = GUIDE_SIZE;
break;
case 'emerge_onto_modal':
case 'emerge_onto_bar': {
// Start hidden below surface, center of surface
const surfaceWidth = surfaceRight - surfaceLeft;
xRef.current = (surfaceWidth - GUIDE_SIZE) / 2;
yOffsetRef.current = GUIDE_SIZE; // Fully hidden
subPhaseRef.current = 'emerge_peek';
break;
}
case 'pace_on_modal':
// If already on surface from emerge, just start pacing
if (prev === 'emerge_onto_modal' || prev === 'pace_on_modal') {
subPhaseRef.current = 'pacing';
} else {
subPhaseRef.current = 'pacing';
const surfaceWidth = surfaceRight - surfaceLeft;
xRef.current = (surfaceWidth - GUIDE_SIZE) / 2;
yOffsetRef.current = 0;
}
break;
case 'fall_from_surface':
subPhaseRef.current = 'falling';
break;
case 'walk_to_target':
// Keep current X position, start walking toward target
subPhaseRef.current = 'walking_to_target';
yOffsetRef.current = 0;
break;
case 'inspect_target':
subPhaseRef.current = 'at_target';
yOffsetRef.current = 0;
break;
}
// Reset animation timing
lastTimeRef.current = 0;
}, [intent, surfaceLeft, surfaceRight]);
// ── Animation loop ──
const loop = useCallback((timestamp: number) => {
if (lastTimeRef.current === 0) lastTimeRef.current = timestamp;
const deltaMs = Math.min(timestamp - lastTimeRef.current, 50);
lastTimeRef.current = timestamp;
const dt = deltaMs / 1000;
const elapsed = timestamp - phaseStartRef.current;
const subPhase = subPhaseRef.current;
const surfaceWidth = surfaceRight - surfaceLeft - GUIDE_SIZE;
let isMoving = false;
switch (subPhase) {
// ── Emerge sequence ──
case 'emerge_peek': {
// Rise from fully hidden (yOffset = GUIDE_SIZE) to peek (yOffset ≈ GUIDE_SIZE * 0.5)
const progress = Math.min(elapsed / EMERGE_PEEK_DURATION, 1);
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
yOffsetRef.current = GUIDE_SIZE * (1 - eased * 0.5);
if (progress >= 1) {
subPhaseRef.current = 'emerge_look';
phaseStartRef.current = timestamp;
}
break;
}
case 'emerge_look': {
// Stay at peek position, companion visual does the looking
if (elapsed >= EMERGE_LOOK_DURATION) {
subPhaseRef.current = 'emerge_climb';
phaseStartRef.current = timestamp;
}
break;
}
case 'emerge_climb': {
// Rise from peek to fully on surface (yOffset = 0)
const progress = Math.min(elapsed / EMERGE_CLIMB_DURATION, 1);
const eased = 1 - Math.pow(1 - progress, 3);
yOffsetRef.current = GUIDE_SIZE * 0.5 * (1 - eased);
if (progress >= 1) {
yOffsetRef.current = 0;
subPhaseRef.current = 'pacing';
phaseStartRef.current = timestamp;
onEmergeComplete?.();
}
break;
}
// ── Pacing with edge-look ──
case 'pacing': {
isMoving = true;
let x = xRef.current;
const move = WALK_SPEED * dt;
if (facingRightRef.current) {
x += move;
if (x >= surfaceWidth - EDGE_THRESHOLD) {
x = surfaceWidth - EDGE_THRESHOLD;
subPhaseRef.current = 'edge_looking';
phaseStartRef.current = timestamp;
}
} else {
x -= move;
if (x <= EDGE_THRESHOLD) {
x = EDGE_THRESHOLD;
subPhaseRef.current = 'edge_looking';
phaseStartRef.current = timestamp;
}
}
xRef.current = x;
break;
}
case 'edge_looking': {
// Pause at edge, lean forward (floatRotation handled below)
if (elapsed >= EDGE_LOOK_PAUSE) {
// Turn around and resume pacing
facingRightRef.current = !facingRightRef.current;
subPhaseRef.current = 'pacing';
phaseStartRef.current = timestamp;
}
break;
}
// ── Walk to specific target ──
case 'walking_to_target': {
if (targetX === undefined) break;
const targetLocal = targetX - surfaceLeft - GUIDE_SIZE / 2;
const distToTarget = targetLocal - xRef.current;
if (Math.abs(distToTarget) < 2) {
// Arrived at target
xRef.current = targetLocal;
subPhaseRef.current = 'at_target';
phaseStartRef.current = timestamp;
facingRightRef.current = true; // Face right (default rest)
} else {
isMoving = true;
const direction = distToTarget > 0 ? 1 : -1;
facingRightRef.current = direction > 0;
const step = Math.min(WALK_SPEED * dt, Math.abs(distToTarget));
xRef.current += direction * step;
}
break;
}
// ── Inspecting target ──
case 'at_target': {
// Idle — organic breathing applied below
break;
}
// ── Falling ──
case 'falling': {
const progress = Math.min(elapsed / FALL_DURATION, 1);
const eased = progress * progress; // easeInQuad (gravity)
yOffsetRef.current = -eased * window.innerHeight * 0.5;
// yOffset goes negative = moves downward from surface
if (progress >= 1) {
subPhaseRef.current = 'hidden';
onFallComplete?.();
}
break;
}
case 'hidden':
break;
}
// ── Apply float animation ──
const float = calculateFloatAnimation(timestamp, isMoving);
const floatScale = isMoving ? 0.4 : 0.5;
// Edge-look: override rotation to lean forward
let rotation = float.rotation * floatScale;
if (subPhase === 'edge_looking') {
const leanProgress = Math.min(elapsed / 300, 1);
rotation = leanProgress * 12; // Lean forward 12°
}
if (subPhase === 'at_target') {
const leanProgress = Math.min(elapsed / 400, 1);
rotation = leanProgress * 8; // Gentle lean 8°
}
setRender({
x: xRef.current,
yOffset: yOffsetRef.current,
facingRight: facingRightRef.current,
floatX: float.x * floatScale,
floatY: float.y * floatScale,
floatRotation: rotation,
visible: subPhase !== 'hidden',
});
animRef.current = requestAnimationFrame(loop);
}, [surfaceLeft, surfaceRight, targetX, onEmergeComplete, onFallComplete]);
// Start/stop loop
useEffect(() => {
lastTimeRef.current = 0;
animRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(animRef.current);
}, [loop]);
// ── Render ──
if (!render.visible) return null;
// Position: surfaceLeft + local X, surfaceY - GUIDE_SIZE + yOffset
// yOffset > 0 means hidden below surface (emerge)
// yOffset < 0 means fallen below surface (fall)
const left = surfaceLeft + render.x + render.floatX;
const top = surfaceY - GUIDE_SIZE + render.yOffset + render.floatY;
// Clip: during emerge, the guide should be clipped by the surface edge.
// We use clip-path to hide the portion below surfaceY.
const isEmerging = subPhaseRef.current === 'emerge_peek'
|| subPhaseRef.current === 'emerge_look'
|| subPhaseRef.current === 'emerge_climb';
// How much of the guide is above the surface (in px)
const visibleHeight = GUIDE_SIZE - Math.max(render.yOffset, 0);
const clipTop = isEmerging ? Math.max(GUIDE_SIZE - visibleHeight, 0) : 0;
return (
<div
className="fixed pointer-events-none z-[60]"
style={{
left,
top,
width: GUIDE_SIZE,
height: GUIDE_SIZE,
transform: `scaleX(${render.facingRight ? 1 : -1}) rotate(${render.floatRotation}deg)`,
clipPath: clipTop > 0 ? `inset(${clipTop}px 0 0 0)` : undefined,
}}
>
<BlobbiStageVisual
companion={companion}
size="sm"
animated
reaction={subPhaseRef.current === 'pacing' || subPhaseRef.current === 'walking_to_target' ? 'happy' : 'idle'}
className="size-full"
/>
</div>
);
}
@@ -0,0 +1,285 @@
/**
* UITourOverlay - Orchestrator for the Blobbi UI walkthrough tour.
*
* This component translates the current tour step into choreography
* intents for the MiniBlobbiGuide and positions all UI elements.
*
* Step → Intent mapping:
* welcome (first render) → emerge_onto_modal → pace_on_modal
* welcome → Next click → fall_from_surface → emerge_onto_bar → walk_to_target → inspect_target
* bar_item_N → Next → walk_to_target (next item) → inspect_target
* bar_item_N → Back → walk_to_target (prev item) → inspect_target
* bar_item_N → Back to welcome → emerge_onto_modal → pace_on_modal
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { cn } from '@/lib/utils';
import { useTourAnchors } from '../lib/TourAnchorContext';
import { GuidedModal } from './GuidedModal';
import { MiniBlobbiGuide } from './MiniBlobbiGuide';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { GuideIntent } from '../lib/ui-tour-types';
import type { UITourState, UITourActions } from '../hooks/useUITour';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UITourOverlayProps {
tourState: UITourState;
tourActions: UITourActions;
companion: BlobbiCompanion;
onComplete: () => void;
}
/**
* Transition phases between steps.
* 'none' = stable on current step, 'falling' = guide falling off surface,
* 'emerging_bar' = guide emerging onto bar after fall.
*/
type TransitionPhase =
| 'none'
| 'falling'
| 'emerging_bar';
// ─── Component ────────────────────────────────────────────────────────────────
export function UITourOverlay({
tourState,
tourActions,
companion,
onComplete,
}: UITourOverlayProps) {
const { getAnchorRect } = useTourAnchors();
const [transition, setTransition] = useState<TransitionPhase>('none');
const [isVisible, setIsVisible] = useState(false);
const [highlightRect, setHighlightRect] = useState<DOMRect | null>(null);
const [modalRect, setModalRect] = useState<DOMRect | null>(null);
const [emergeComplete, setEmergeComplete] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const step = tourState.currentStep;
const isWelcome = step?.id === 'welcome';
const isBarStep = step?.id.startsWith('bar_item_') ?? false;
// ─── Fade in ──────────────────────────────────────────────────────────
useEffect(() => {
if (tourState.isActive) {
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
} else {
setIsVisible(false);
}
}, [tourState.isActive]);
// ─── Measure modal rect ───────────────────────────────────────────────
useEffect(() => {
if (!modalRef.current) { setModalRect(null); return; }
const observer = new ResizeObserver(() => {
if (modalRef.current) setModalRect(modalRef.current.getBoundingClientRect());
});
observer.observe(modalRef.current);
setModalRect(modalRef.current.getBoundingClientRect());
return () => observer.disconnect();
}, [step]);
// ─── Highlight rect ──────────────────────────────────────────────────
useEffect(() => {
if (!step?.highlightAnchor) { setHighlightRect(null); return; }
const update = () => setHighlightRect(getAnchorRect(step.highlightAnchor!) ?? null);
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [step, getAnchorRect]);
// Reset emergeComplete when step changes
useEffect(() => {
setEmergeComplete(false);
}, [step?.id]);
// ─── Guide intent derivation ──────────────────────────────────────────
const guideIntent: GuideIntent = useMemo(() => {
// Transition overrides
if (transition === 'falling') return 'fall_from_surface';
if (transition === 'emerging_bar') return 'emerge_onto_bar';
if (!step) return 'hidden';
if (step.guideTarget.type === 'offscreen') return 'hidden';
// Welcome step: emerge then pace
if (isWelcome) {
return emergeComplete ? 'pace_on_modal' : 'emerge_onto_modal';
}
// Bar item steps: after emerging, walk to target then inspect
if (isBarStep) {
return emergeComplete ? 'walk_to_target' : 'walk_to_target';
}
return 'hidden';
}, [step, isWelcome, isBarStep, transition, emergeComplete]);
// ─── Surface calculation ──────────────────────────────────────────────
// Modal surface: guide walks on modal top edge
const modalSurface = useMemo(() => {
if (!modalRect) return { left: 0, right: 0, y: 0 };
return {
left: modalRect.left + 12,
right: modalRect.right - 12,
y: modalRect.top,
};
}, [modalRect]);
// Bar surface: full width of the bottom bar items
const barSurface = useMemo(() => {
const firstRect = getAnchorRect('bar-item-0');
if (!firstRect) return { left: 0, right: 0, y: 0 };
let rightEdge = firstRect.right;
for (let i = 1; i <= 5; i++) {
const r = getAnchorRect(`bar-item-${i}`);
if (r) rightEdge = Math.max(rightEdge, r.right);
else break;
}
return { left: firstRect.left, right: rightEdge, y: firstRect.top };
}, [getAnchorRect, step]); // eslint-disable-line react-hooks/exhaustive-deps
// Active surface depends on current context
const activeSurface = useMemo(() => {
if (transition === 'falling') return modalSurface; // Falling from modal
if (transition === 'emerging_bar') return barSurface;
if (isWelcome) return modalSurface;
if (isBarStep) return barSurface;
return modalSurface;
}, [isWelcome, isBarStep, transition, modalSurface, barSurface]);
// Target X for bar steps: center of the highlighted anchor item
const barTargetX = useMemo((): number | undefined => {
if (!step?.highlightAnchor) return undefined;
const rect = getAnchorRect(step.highlightAnchor);
if (!rect) return undefined;
return rect.left + rect.width / 2;
}, [step, getAnchorRect]);
// ─── Guide callbacks ──────────────────────────────────────────────────
const handleEmergeComplete = useCallback(() => {
setEmergeComplete(true);
}, []);
const handleFallComplete = useCallback(() => {
// After falling from modal, start emerging onto bar and advance step
setTransition('emerging_bar');
tourActions.next();
}, [tourActions]);
const handleBarEmergeComplete = useCallback(() => {
setTransition('none');
setEmergeComplete(true);
}, []);
// ─── Navigation ───────────────────────────────────────────────────────
const handleNext = useCallback(() => {
if (!step) return;
if (step.modalPlacement === 'center') {
// Welcome → first bar item: fall off modal
setTransition('falling');
setEmergeComplete(false);
} else {
// Bar item → next bar item: just advance, guide will walk to new target
tourActions.next();
}
}, [step, tourActions]);
const handlePrev = useCallback(() => {
setTransition('none');
tourActions.prev();
}, [tourActions]);
const handleSkip = useCallback(() => {
onComplete();
}, [onComplete]);
// ─── Render ───────────────────────────────────────────────────────────
if (!tourState.isActive || !step) return null;
const isCentered = step.modalPlacement === 'center';
return (
<>
{/* Dark backdrop — welcome step only */}
{isWelcome && transition === 'none' && (
<div
className={cn(
'fixed inset-0 z-50',
'transition-opacity duration-400',
isVisible ? 'opacity-100' : 'opacity-0',
)}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px]" />
</div>
)}
{/* Highlight pulse on active anchor */}
{highlightRect && (
<div
className="fixed rounded-xl animate-tour-highlight-pulse pointer-events-none z-[51]"
style={{
left: highlightRect.left - 4,
top: highlightRect.top - 4,
width: highlightRect.width + 8,
height: highlightRect.height + 8,
}}
/>
)}
{/* Guided modal */}
<div
className={cn(
'fixed z-[52] flex justify-center px-4 pointer-events-none',
isCentered ? 'inset-0 items-center' : 'bottom-28 left-0 right-0',
)}
>
<div ref={modalRef} className="pointer-events-auto">
<GuidedModal
title={step.title}
body={step.body}
showPrev={!tourState.isFirstStep}
showNext={true}
nextLabel={tourState.isLastContentStep ? 'Got it' : 'Next'}
onPrev={handlePrev}
onNext={tourState.isLastContentStep ? onComplete : handleNext}
onSkip={handleSkip}
visible={isVisible && transition !== 'falling'}
/>
</div>
</div>
{/* Mini Blobbi guide */}
<MiniBlobbiGuide
companion={companion}
intent={guideIntent}
surfaceLeft={activeSurface.left}
surfaceRight={activeSurface.right}
surfaceY={activeSurface.y}
targetX={barTargetX}
onEmergeComplete={
transition === 'emerging_bar' ? handleBarEmergeComplete : handleEmergeComplete
}
onFallComplete={handleFallComplete}
/>
</>
);
}
@@ -0,0 +1,337 @@
/**
* useFirstEggExperience - State machine for the first-time Blobbi experience.
*
* This hook orchestrates the full first-egg journey:
* 1. Auto-create Blobbonaut profile (if missing)
* 2. Auto-generate & publish the first egg (no confirmation, no cost)
* 3. Immediately enter the hatch interaction flow
* 4. Show a reveal overlay after hatching for naming
*
* The hook is designed to be consumed by a single orchestrating component.
* It manages state transitions but does NOT render anything.
*
* ────────────────────────────────────────────────────────────────
* This flow is ONLY for the user's first Blobbi.
* Subsequent Blobbis use the standard adoption flow.
* ────────────────────────────────────────────────────────────────
*/
import { useState, useMemo, useEffect, useRef } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
INITIAL_BLOBBONAUT_COINS,
buildBlobbonautTags,
type BlobbonautProfile,
type BlobbiCompanion,
} from '@/blobbi/core/lib/blobbi';
import {
generateEggPreview,
previewToEventTags,
} from '@/blobbi/onboarding/lib/blobbi-preview';
import { updateBlobbonautTags } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Steps of the first egg experience:
*
* - 'idle': Not yet active (preconditions not met or already completed)
* - 'creating_profile': Auto-creating the Blobbonaut profile
* - 'creating_egg': Auto-generating and publishing the first egg
* - 'ready': Egg created, waiting for hatch tour to run (handed off to tour system)
* - 'done': Experience is fully complete
*/
export type FirstEggStep =
| 'idle'
| 'creating_profile'
| 'creating_egg'
| 'ready'
| 'done';
export interface FirstEggExperienceState {
/** Current step */
step: FirstEggStep;
/** Whether auto-creation is in progress */
isProcessing: boolean;
/** Whether the experience is active (not idle or done) */
isActive: boolean;
}
export interface UseFirstEggExperienceOptions {
/** Current profile (null if doesn't exist yet) */
profile: BlobbonautProfile | null;
/** Whether the profile is still loading */
profileLoading: boolean;
/** All resolved companions */
companions: BlobbiCompanion[];
/** Whether companions are still loading */
companionsLoading: boolean;
/** Cache update callbacks */
updateProfileEvent: (event: NostrEvent) => void;
updateCompanionEvent: (event: NostrEvent) => void;
invalidateProfile: () => void;
invalidateCompanion: () => void;
setStoredSelectedD: (d: string) => void;
}
export interface UseFirstEggExperienceResult {
state: FirstEggExperienceState;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useFirstEggExperience({
profile,
profileLoading,
companions,
companionsLoading,
updateProfileEvent,
updateCompanionEvent,
invalidateProfile,
invalidateCompanion,
setStoredSelectedD,
}: UseFirstEggExperienceOptions): UseFirstEggExperienceResult {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { data: authorData } = useAuthor(user?.pubkey);
const [step, setStep] = useState<FirstEggStep>('idle');
const [isProcessing, setIsProcessing] = useState(false);
// Prevent double-execution with refs
const profileCreationAttempted = useRef(false);
const eggCreationAttempted = useRef(false);
// Suggested name from kind 0 metadata
const suggestedName = useMemo(() => {
if (!authorData?.metadata) return undefined;
return authorData.metadata.display_name || authorData.metadata.name || undefined;
}, [authorData?.metadata]);
// ─── Determine if the experience should be active ──────────────────────────
// Case A: No profile → need to create profile + egg
// Case B: Profile exists, no companions loaded yet, but profile.has is empty → need to create egg
// Case C: Profile + companions loaded, exactly 1 egg → hatch tour handles it, we're 'ready'
// Otherwise: idle (user already has Blobbis, or has baby/adult)
useEffect(() => {
// Still loading? Don't decide yet.
if (profileLoading) return;
// If we're already processing, don't re-evaluate
if (isProcessing) return;
// If already done or in an active step, don't re-evaluate
if (step === 'done') return;
if (step === 'creating_profile' || step === 'creating_egg') return;
// Case A: No profile at all
if (!profile && !profileCreationAttempted.current) {
setStep('creating_profile');
return;
}
// Profile exists, check companion state
if (profile) {
// If profile has no companions listed at all → create egg
if (profile.has.length === 0 && !profile.currentCompanion && !eggCreationAttempted.current) {
setStep('creating_egg');
return;
}
// If companions are still loading, wait
if (companionsLoading) return;
// If companions loaded but none found and profile.has is empty → create egg
if (companions.length === 0 && profile.has.length === 0 && !eggCreationAttempted.current) {
setStep('creating_egg');
return;
}
// If exactly 1 companion that is an egg → flow is ready (tour takes over)
if (companions.length === 1 && companions[0].stage === 'egg') {
const noBabyOrAdult = !companions.some(c => c.stage === 'baby' || c.stage === 'adult');
if (noBabyOrAdult && !profile.firstHatchTourDone) {
setStep('ready');
return;
}
}
// Otherwise: user has existing Blobbis, not a first-egg scenario
setStep('idle');
}
}, [profileLoading, profile, companionsLoading, companions, isProcessing, step]);
// ─── Auto Profile Creation ────────────────────────────────────────────────
useEffect(() => {
if (step !== 'creating_profile') {
profileCreationAttempted.current = false;
return;
}
if (profileCreationAttempted.current || !user?.pubkey || profile) return;
if (isProcessing) return;
profileCreationAttempted.current = true;
const createProfile = async () => {
setIsProcessing(true);
try {
const name = suggestedName || 'Blobbonaut';
const baseTags = buildBlobbonautTags(user.pubkey);
const tagsWithName = [
...baseTags,
['name', name],
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
];
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: tagsWithName,
});
updateProfileEvent(event);
invalidateProfile();
// Profile created → next step is egg creation
setStep('creating_egg');
} catch (error) {
console.error('[FirstEggExperience] Failed to create profile:', error);
toast({
title: 'Failed to create profile',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
profileCreationAttempted.current = false;
setStep('idle');
} finally {
setIsProcessing(false);
}
};
createProfile();
}, [step, user?.pubkey, profile, isProcessing, suggestedName, publishEvent, updateProfileEvent, invalidateProfile]);
// ─── Auto Egg Creation ────────────────────────────────────────────────────
useEffect(() => {
if (step !== 'creating_egg') {
eggCreationAttempted.current = false;
return;
}
if (eggCreationAttempted.current || !user?.pubkey) return;
if (isProcessing) return;
// We need a profile to exist before creating an egg.
// It may not be in the cache yet if we just created it.
// Wait for next render cycle when profile is available.
// If profileLoading, also wait.
if (profileLoading) return;
eggCreationAttempted.current = true;
const createFirstEgg = async () => {
setIsProcessing(true);
try {
// Generate the egg
const preview = generateEggPreview(user.pubkey, 'Egg');
const eggTags = previewToEventTags(preview);
// Publish the egg event
const eggEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: 'A new Blobbi egg!',
tags: eggTags,
created_at: preview.createdAt,
});
updateCompanionEvent(eggEvent);
// Update profile: add to has[] + set current_companion, NO coin deduction for first egg
// Use profile from latest state or build minimal tags
const currentProfile = profile;
if (currentProfile) {
const newHas = [...currentProfile.has, preview.d];
const updatedProfileTags = updateBlobbonautTags(currentProfile.allTags, {
has: newHas,
current_companion: preview.d,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedProfileTags,
});
updateProfileEvent(profileEvent);
} else {
// Profile was just created but not yet in cache: build fresh tags
const baseTags = buildBlobbonautTags(user.pubkey);
const name = suggestedName || 'Blobbonaut';
const freshTags = [
...baseTags,
['name', name],
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
['has', preview.d],
['current_companion', preview.d],
];
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: freshTags,
});
updateProfileEvent(profileEvent);
}
// Set localStorage selection
setStoredSelectedD(preview.d);
// Invalidate queries
invalidateProfile();
invalidateCompanion();
// Egg created, flow is ready for hatch tour
setStep('ready');
} catch (error) {
console.error('[FirstEggExperience] Failed to create first egg:', error);
toast({
title: 'Failed to create egg',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
eggCreationAttempted.current = false;
setStep('idle');
} finally {
setIsProcessing(false);
}
};
createFirstEgg();
}, [step, user?.pubkey, profile, profileLoading, isProcessing, suggestedName, publishEvent, updateProfileEvent, updateCompanionEvent, invalidateProfile, invalidateCompanion, setStoredSelectedD]);
// ─── Derived State ──────────────────────────────────────────────────────────
const isActive = step !== 'idle' && step !== 'done';
const state: FirstEggExperienceState = useMemo(() => ({
step,
isProcessing,
isActive,
}), [step, isProcessing, isActive]);
return { state };
}
+65 -91
View File
@@ -4,51 +4,32 @@
* Orchestration only -- no rendering, no animations.
* The hook manages:
* - Ordered step progression
* - Persisted state via localStorage (survives refresh / close)
* - In-memory session state (React useState)
* - Derived booleans for UI consumption
* - Safe advance / goTo / complete / reset actions
*
* Persistence strategy:
* - The tour does NOT persist to localStorage.
* - The Kind 11125 profile tag `blobbi_first_hatch_tour_done` is the
* sole authoritative persisted signal.
* - If the user refreshes mid-tour, the tour re-enters from the
* beginning when activation conditions are still met.
*
* Activation is handled separately by useFirstHatchTourActivation,
* which calls `start()` when all preconditions are met.
*
* ────────────────────────────────────────────────────────────────
* Future integration points
* ────────────────────────────────────────────────────────────────
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
* to decide whether to start the tour.
* 2. UI components read `state.currentStepId` and render overlays,
* spotlights, modals, or animation cues accordingly.
* 3. Animation components call `actions.advance()` when their
* sequence finishes (for autoAdvance steps).
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
* on the user interaction.
* 5. EggGraphic receives a visual-state prop derived from
* `state.currentStepId` -- it does NOT own the tour logic.
*/
import { useMemo, useCallback, useRef } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useState, useMemo, useCallback, useRef } from 'react';
import {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
type FirstHatchTourStepId,
type FirstHatchTourPersistedState,
type TourState,
type TourActions,
} from '../lib/tour-types';
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* localStorage key for the first hatch tour state.
* Not user-scoped because onboarding state is device-local and the tour
* is inherently tied to "first ever egg on this device". If multi-user
* support on the same device becomes a concern, scope by pubkey.
*/
const STORAGE_KEY = 'blobbi:tour:first-hatch';
/** Pre-computed lookup: stepId -> index */
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
@@ -57,6 +38,20 @@ const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
// ─── In-Memory State Shape ────────────────────────────────────────────────────
interface TourSessionState {
/** Current step id, or null when not started */
currentStepId: FirstHatchTourStepId | null;
/** Whether the tour was completed this session */
completed: boolean;
}
const INITIAL_SESSION_STATE: TourSessionState = {
currentStepId: null,
completed: false,
};
// ─── Result Type ──────────────────────────────────────────────────────────────
export interface UseFirstHatchTourResult {
@@ -83,112 +78,91 @@ export interface UseFirstHatchTourResult {
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useFirstHatchTour(): UseFirstHatchTourResult {
// ── Persisted state ──
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
STORAGE_KEY,
FIRST_HATCH_TOUR_DEFAULT_STATE,
);
// ── In-memory session state ──
const [session, setSession] = useState<TourSessionState>(INITIAL_SESSION_STATE);
// Stable ref to current persisted state so callbacks never go stale.
const persistedRef = useRef(persisted);
persistedRef.current = persisted;
// ── Helpers ──
const updatePersisted = useCallback(
(patch: Partial<FirstHatchTourPersistedState>) => {
setPersisted((prev) => ({
...prev,
...patch,
updatedAt: Date.now(),
}));
},
[setPersisted],
);
// Stable ref so callbacks never go stale.
const sessionRef = useRef(session);
sessionRef.current = session;
// ── Actions ──
const start = useCallback(() => {
const p = persistedRef.current;
// No-op if already active or completed
if (p.completed || p.currentStepId !== null) return;
const s = sessionRef.current;
// No-op if already active or completed this session
if (s.completed || s.currentStepId !== null) return;
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
if (!firstStep) return;
updatePersisted({ currentStepId: firstStep.id });
}, [updatePersisted]);
setSession({ currentStepId: firstStep.id, completed: false });
}, []);
const advance = useCallback(() => {
const p = persistedRef.current;
if (p.completed || p.currentStepId === null) return;
const s = sessionRef.current;
if (s.completed || s.currentStepId === null) return;
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
const currentIndex = STEP_INDEX_MAP.get(s.currentStepId);
if (currentIndex === undefined) return;
const nextIndex = currentIndex + 1;
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
// Past the end -- complete
updatePersisted({ currentStepId: null, completed: true });
setSession({ currentStepId: null, completed: true });
return;
}
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
if (nextStep.id === 'complete') {
// Reaching the 'complete' terminal step means the tour is done
updatePersisted({ currentStepId: null, completed: true });
setSession({ currentStepId: null, completed: true });
} else {
updatePersisted({ currentStepId: nextStep.id });
setSession((prev) => ({ ...prev, currentStepId: nextStep.id }));
}
}, [updatePersisted]);
}, []);
const goTo = useCallback(
(stepId: FirstHatchTourStepId) => {
if (!STEP_INDEX_MAP.has(stepId)) {
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
}
const goTo = useCallback((stepId: FirstHatchTourStepId) => {
if (!STEP_INDEX_MAP.has(stepId)) {
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
}
if (stepId === 'complete') {
updatePersisted({ currentStepId: null, completed: true });
} else {
updatePersisted({ currentStepId: stepId, completed: false });
}
},
[updatePersisted],
);
if (stepId === 'complete') {
setSession({ currentStepId: null, completed: true });
} else {
setSession({ currentStepId: stepId, completed: false });
}
}, []);
const complete = useCallback(() => {
updatePersisted({ currentStepId: null, completed: true });
}, [updatePersisted]);
setSession({ currentStepId: null, completed: true });
}, []);
const reset = useCallback(() => {
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
}, [setPersisted]);
setSession(INITIAL_SESSION_STATE);
}, []);
// ── Derived state ──
const currentStepIndex = persisted.currentStepId !== null
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
const currentStepIndex = session.currentStepId !== null
? (STEP_INDEX_MAP.get(session.currentStepId) ?? -1)
: -1;
const state = useMemo((): TourState<FirstHatchTourStepId> => {
const isActive = persisted.currentStepId !== null && !persisted.completed;
const isActive = session.currentStepId !== null && !session.completed;
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
return {
isActive,
currentStepId: persisted.currentStepId,
currentStepId: session.currentStepId,
currentStepIndex,
totalSteps,
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
isCompleted: persisted.completed,
progress: persisted.completed
isCompleted: session.completed,
progress: session.completed
? 1
: currentStepIndex >= 0
? currentStepIndex / LAST_REAL_STEP_INDEX
: 0,
};
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
}, [session.currentStepId, session.completed, currentStepIndex]);
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
start,
@@ -201,15 +175,15 @@ export function useFirstHatchTour(): UseFirstHatchTourResult {
// ── Convenience helpers ──
const isStep = useCallback(
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
[persisted.currentStepId],
(stepId: FirstHatchTourStepId) => session.currentStepId === stepId,
[session.currentStepId],
);
const isAnyStep = useCallback(
(...stepIds: FirstHatchTourStepId[]) => {
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
return session.currentStepId !== null && stepIds.includes(session.currentStepId);
},
[persisted.currentStepId],
[session.currentStepId],
);
const currentStepDef = currentStepIndex >= 0
@@ -15,13 +15,16 @@
* 2. The user has exactly 1 Blobbi.
* 3. That Blobbi is in the egg stage.
* 4. No Blobbi is in baby or adult stage.
* 5. The tour has not been completed yet (checked via profile tag
* AND localStorage fallback).
* 5. The `blobbi_first_hatch_tour_done` profile tag is NOT true.
* 6. The tour is not already active or completed this session.
*
* Completion is authoritative from the Blobbonaut profile event
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
* is a secondary signal for in-progress UI state and as a fallback
* when the profile hasn't been updated yet.
* Persistence: The Kind 11125 profile tag `blobbi_first_hatch_tour_done`
* is the sole authoritative persisted signal. No localStorage is used.
*
* MIGRATION NOTE: `blobbi_onboarding_done` is intentionally ignored
* when the user is in the single-egg state. This ensures old accounts
* that were migrated before the hatch tour existed can still experience
* it. The `blobbi_first_hatch_tour_done` tag is the dedicated signal.
* ────────────────────────────────────────────────────────────────
*/
@@ -36,16 +39,21 @@ import type { UseFirstHatchTourResult } from './useFirstHatchTour';
export interface FirstHatchTourActivationInput {
/** The full list of the user's Blobbi companions */
companions: BlobbiCompanion[];
/** Whether the companions list is still loading */
isLoading: boolean;
/** The tour hook result (localStorage-based state machine) */
/**
* Whether the companion data has been resolved at least once and is
* ready for activation evaluation. When false, the hook defers all
* evaluation. This should reflect initial data readiness, not
* background refetch activity.
*/
companionsReady: boolean;
/** The tour hook result (in-memory state machine) */
tour: UseFirstHatchTourResult;
/**
* Whether onboarding is already marked complete in the Blobbonaut profile
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
* When true, the tour will not activate regardless of localStorage state.
* Whether the first hatch tour is already marked complete in the
* Blobbonaut profile event (`blobbi_first_hatch_tour_done` tag).
* This is the sole authoritative persisted signal.
*/
profileOnboardingDone?: boolean;
profileFirstHatchTourDone?: boolean;
}
export interface FirstHatchTourActivationResult {
@@ -53,7 +61,7 @@ export interface FirstHatchTourActivationResult {
* Whether all preconditions for activating the tour are met right now.
* This is a derived boolean -- it does NOT mean the tour IS active,
* just that it SHOULD be activated. The tour may already be active
* from a previous render or a persisted state.
* from a previous render.
*/
shouldActivate: boolean;
/**
@@ -73,28 +81,33 @@ export interface FirstHatchTourActivationResult {
* const tour = useFirstHatchTour();
* const activation = useFirstHatchTourActivation({
* companions,
* isLoading: companionsLoading,
* companionsReady: !companionsLoading,
* tour,
* profileOnboardingDone: profile?.onboardingDone,
* profileFirstHatchTourDone: profile?.firstHatchTourDone,
* });
* ```
*/
export function useFirstHatchTourActivation({
companions,
isLoading,
companionsReady,
tour,
profileOnboardingDone: _profileOnboardingDone = false,
profileFirstHatchTourDone = false,
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
// ── Precondition evaluation ──
const { shouldActivate, isEligible } = useMemo(() => {
// Can't evaluate until data is loaded
if (isLoading) {
// Defer until companion data has been resolved at least once
if (!companionsReady) {
return { shouldActivate: false, isEligible: false };
}
// localStorage tour already completed — this is always authoritative
if (tour.state.isCompleted) {
// Profile tag is the sole persisted completion signal
if (profileFirstHatchTourDone) {
return { shouldActivate: false, isEligible: false };
}
// Tour already completed or active this session — don't re-activate
if (tour.state.isCompleted || tour.state.isActive) {
return { shouldActivate: false, isEligible: false };
}
@@ -111,7 +124,7 @@ export function useFirstHatchTourActivation({
}
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
// but kept explicit for clarity and future-proofing if rules change)
// but kept explicit for clarity and future-proofing)
const hasBabyOrAdult = companions.some(
(c) => c.stage === 'baby' || c.stage === 'adult',
);
@@ -119,41 +132,11 @@ export function useFirstHatchTourActivation({
return { shouldActivate: false, isEligible: false };
}
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
// Some older accounts had `onboarding_done` migrated to
// `blobbi_onboarding_done=true` before the first-hatch tour
// existed, so they never experienced it. When the user is in the
// exact single-egg/no-evolved-companions state (all checks above
// passed), we intentionally ignore `profileOnboardingDone` so
// those accounts can still enter the tour.
//
// This is safe because:
// - The localStorage `tour.state.isCompleted` check above
// already prevents re-triggering for users who HAVE finished
// the tour.
// - The egg-stage + single-companion guard means this only
// fires for users who genuinely haven't hatched yet.
//
// TODO: Replace `blobbi_onboarding_done` with a dedicated
// `blobbi_first_hatch_tour_done` tag so onboarding completion
// and tour completion are tracked independently. Once that tag
// is in place, remove this safeguard and gate activation on the
// new tag instead.
// ───────────────────────────────────────────────────────────────
// (profileOnboardingDone is intentionally NOT checked here)
// All preconditions met
const eligible = true;
// Only activate if the tour is not already running
const activate = !tour.state.isActive;
return { shouldActivate: activate, isEligible: eligible };
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]);
// All preconditions met — activate
return { shouldActivate: true, isEligible: true };
}, [companionsReady, companions, tour.state.isCompleted, tour.state.isActive, profileFirstHatchTourDone]);
// ── Auto-start effect ──
// When all preconditions are met and the tour hasn't started yet,
// start it. This fires once and then `shouldActivate` flips to false
// because `tour.state.isActive` becomes true.
useEffect(() => {
if (shouldActivate) {
tour.actions.start();
+124
View File
@@ -0,0 +1,124 @@
/**
* useUITour - State machine for the Blobbi UI walkthrough tour.
*
* In-memory only. The Kind 11125 profile tag `blobbi_ui_tour_done`
* is the sole persisted completion signal (set externally by the caller
* when the tour completes). This hook never writes to localStorage or
* Nostr — it only manages the step progression.
*
* Supports forward and backward navigation so the user can revisit
* previous steps.
*/
import { useState, useMemo, useCallback, useRef } from 'react';
import type { UITourStepDef } from '../lib/ui-tour-types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface UITourSessionState {
currentStepIndex: number | null;
completed: boolean;
}
const INITIAL_STATE: UITourSessionState = {
currentStepIndex: null,
completed: false,
};
export interface UITourState {
isActive: boolean;
currentStep: UITourStepDef | null;
currentStepIndex: number;
totalSteps: number;
isFirstStep: boolean;
isLastContentStep: boolean;
isCompleted: boolean;
}
export interface UITourActions {
/** Start the tour from the first step */
start: () => void;
/** Advance to the next step. Completes if on the last content step. */
next: () => void;
/** Go back to the previous step. No-op if on the first step. */
prev: () => void;
/** Mark the tour as completed */
complete: () => void;
/** Reset the tour (dev only) */
reset: () => void;
}
export interface UseUITourResult {
state: UITourState;
actions: UITourActions;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useUITour(steps: UITourStepDef[]): UseUITourResult {
const [session, setSession] = useState<UITourSessionState>(INITIAL_STATE);
const sessionRef = useRef(session);
sessionRef.current = session;
// Last content step is the one before 'complete'
const lastContentIndex = useMemo(() => {
const completeIdx = steps.findIndex(s => s.id === 'complete');
return completeIdx > 0 ? completeIdx - 1 : steps.length - 1;
}, [steps]);
const start = useCallback(() => {
const s = sessionRef.current;
if (s.completed || s.currentStepIndex !== null) return;
if (steps.length === 0) return;
setSession({ currentStepIndex: 0, completed: false });
}, [steps.length]);
const next = useCallback(() => {
const s = sessionRef.current;
if (s.completed || s.currentStepIndex === null) return;
const nextIdx = s.currentStepIndex + 1;
// If next step is 'complete' or past the end, mark completed
if (nextIdx >= steps.length || steps[nextIdx].id === 'complete') {
setSession({ currentStepIndex: null, completed: true });
} else {
setSession(prev => ({ ...prev, currentStepIndex: nextIdx }));
}
}, [steps]);
const prev = useCallback(() => {
const s = sessionRef.current;
if (s.completed || s.currentStepIndex === null || s.currentStepIndex === 0) return;
setSession(prev => ({ ...prev, currentStepIndex: prev.currentStepIndex! - 1 }));
}, []);
const complete = useCallback(() => {
setSession({ currentStepIndex: null, completed: true });
}, []);
const reset = useCallback(() => {
setSession(INITIAL_STATE);
}, []);
// Derived state
const currentStep = session.currentStepIndex !== null
? (steps[session.currentStepIndex] ?? null)
: null;
const state = useMemo((): UITourState => ({
isActive: session.currentStepIndex !== null && !session.completed,
currentStep,
currentStepIndex: session.currentStepIndex ?? -1,
totalSteps: steps.length,
isFirstStep: session.currentStepIndex === 0,
isLastContentStep: session.currentStepIndex === lastContentIndex,
isCompleted: session.completed,
}), [session.currentStepIndex, session.completed, currentStep, steps.length, lastContentIndex]);
const actions = useMemo((): UITourActions => ({
start, next, prev, complete, reset,
}), [start, next, prev, complete, reset]);
return { state, actions };
}
+47 -13
View File
@@ -2,22 +2,21 @@
* Blobbi Tour Module
*
* Provides the orchestration layer for guided tours / tutorials.
* Currently implements the first-egg hatch tour.
*
* Architecture:
* - tour-types.ts: Step definitions, persisted state shape, generic types
* - useFirstHatchTour: State machine (step progression, persistence, actions)
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
* Currently implements:
* - First-egg hatch tour (tap crack animation + reveal)
* - First-egg experience (auto profile + egg creation)
* - UI walkthrough tour (bottom bar, guide actor)
*
* UI components import from this barrel and read tour state to decide
* what to render. They call tour actions (advance, goTo, complete) in
* response to user interactions or animation completions.
* Persistence:
* - `blobbi_first_hatch_tour_done` — sole signal for hatch tour
* - `blobbi_ui_tour_done` — sole signal for UI tour (not persisted yet)
* - No localStorage is used for any tour.
*/
// ── Types (generic tour infrastructure) ──
// ── Types (hatch tour infrastructure) ──
export type {
TourStepDef,
TourPersistedState,
TourState,
TourActions,
} from './lib/tour-types';
@@ -25,11 +24,9 @@ export type {
// ── First Hatch Tour - Types & Constants ──
export {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
} from './lib/tour-types';
export type {
FirstHatchTourStepId,
FirstHatchTourPersistedState,
} from './lib/tour-types';
// ── First Hatch Tour - Hooks ──
@@ -42,5 +39,42 @@ export type {
FirstHatchTourActivationResult,
} from './hooks/useFirstHatchTourActivation';
// ── First Egg Experience - Hook ──
export { useFirstEggExperience } from './hooks/useFirstEggExperience';
export type {
FirstEggStep,
FirstEggExperienceState,
UseFirstEggExperienceOptions,
UseFirstEggExperienceResult,
} from './hooks/useFirstEggExperience';
// ── First Hatch Tour - Components ──
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
export { BlobbiRevealOverlay } from './components/BlobbiRevealOverlay';
// ── UI Tour - Types & Constants ──
export type {
GuideIntent,
GuideAnchorTarget,
UITourStepDef,
UITourStepId,
} from './lib/ui-tour-types';
export {
buildUITourSteps,
BAR_ITEM_TOUR_DESCRIPTIONS,
} from './lib/ui-tour-types';
// ── UI Tour - Hooks ──
export { useUITour } from './hooks/useUITour';
export type {
UITourState,
UITourActions,
UseUITourResult,
} from './hooks/useUITour';
// ── UI Tour - Components ──
export { UITourOverlay } from './components/UITourOverlay';
export { GuidedModal } from './components/GuidedModal';
export { MiniBlobbiGuide, GUIDE_SIZE } from './components/MiniBlobbiGuide';
// ── Tour Anchor System ──
export { TourAnchorProvider, useTourAnchors } from './lib/TourAnchorContext';
+75
View File
@@ -0,0 +1,75 @@
/**
* TourAnchorContext - Registry for UI anchor elements.
*
* UI components register their DOM elements by anchor ID. The tour
* overlay reads these to position the guide, highlights, and modals
* relative to real page elements.
*
* Usage in an anchor component:
* ```tsx
* const { registerAnchor } = useTourAnchors();
* <div ref={(el) => registerAnchor('bar-item-0', el)}>...</div>
* ```
*
* Usage in the tour overlay:
* ```tsx
* const { getAnchorRect } = useTourAnchors();
* const rect = getAnchorRect('bar-item-0'); // DOMRect | null
* ```
*/
import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react';
// ─── Types ────────────────────────────────────────────────────────────────────
interface TourAnchorContextValue {
/** Register or update an anchor element. Pass null to unregister. */
registerAnchor: (id: string, element: HTMLElement | null) => void;
/** Get the current bounding rect for an anchor. Returns null if not registered. */
getAnchorRect: (id: string) => DOMRect | null;
/** Get the raw element for an anchor. Returns null if not registered. */
getAnchorElement: (id: string) => HTMLElement | null;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const TourAnchorCtx = createContext<TourAnchorContextValue | null>(null);
// ─── Provider ─────────────────────────────────────────────────────────────────
export function TourAnchorProvider({ children }: { children: ReactNode }) {
const anchorsRef = useRef<Map<string, HTMLElement>>(new Map());
const registerAnchor = useCallback((id: string, element: HTMLElement | null) => {
if (element) {
anchorsRef.current.set(id, element);
} else {
anchorsRef.current.delete(id);
}
}, []);
const getAnchorRect = useCallback((id: string): DOMRect | null => {
const el = anchorsRef.current.get(id);
return el ? el.getBoundingClientRect() : null;
}, []);
const getAnchorElement = useCallback((id: string): HTMLElement | null => {
return anchorsRef.current.get(id) ?? null;
}, []);
return (
<TourAnchorCtx.Provider value={{ registerAnchor, getAnchorRect, getAnchorElement }}>
{children}
</TourAnchorCtx.Provider>
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useTourAnchors(): TourAnchorContextValue {
const ctx = useContext(TourAnchorCtx);
if (!ctx) {
throw new Error('useTourAnchors must be used within a TourAnchorProvider');
}
return ctx;
}
+13 -40
View File
@@ -5,7 +5,7 @@
* The tour system is designed to be:
* - Easy to extend with new tours (define steps + config)
* - Easy to reorder steps (change the STEPS array)
* - Persistent across page refreshes (localStorage)
* - In-memory session state (profile tags are the persisted truth)
* - Decoupled from rendering (UI reads state, doesn't own it)
*/
@@ -28,19 +28,6 @@ export interface TourStepDef<StepId extends string = string> {
autoAdvance?: boolean;
}
/**
* Persisted state for a tour.
* Stored in localStorage so tours survive refresh / close / return.
*/
export interface TourPersistedState<StepId extends string = string> {
/** Current step id, or null when the tour is not yet started */
currentStepId: StepId | null;
/** Whether the tour has been completed */
completed: boolean;
/** Unix ms timestamp of last state change (for debugging / analytics) */
updatedAt: number;
}
/**
* Full runtime state exposed by a tour hook.
*/
@@ -55,7 +42,7 @@ export interface TourState<StepId extends string = string> {
totalSteps: number;
/** Whether the current step is the last one before completion */
isLastStep: boolean;
/** Whether the tour has been completed (persisted) */
/** Whether the tour has been completed (this session) */
isCompleted: boolean;
/** Progress as a fraction 0..1 */
progress: number;
@@ -73,7 +60,7 @@ export interface TourActions<StepId extends string = string> {
goTo: (stepId: StepId) => void;
/** Mark the tour as completed and reset to idle. */
complete: () => void;
/** Reset the tour entirely (clears persisted state). For dev/testing. */
/** Reset the tour entirely (clears in-memory state). For dev/testing. */
reset: () => void;
}
@@ -82,15 +69,15 @@ export interface TourActions<StepId extends string = string> {
/**
* Step ids for the first-egg hatch tour.
*
* Flow:
* Simplified flow (no post mission required):
* 1. idle — initial state (auto-advances immediately)
* 2. show_hatch_card — egg with initial crack + wiggle + inline card
* 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click
* 4. egg_crack_stage_1 — click 1: crack expands
* 5. egg_crack_stage_2 — click 2: crack expands further
* 6. egg_crack_stage_3 — click 3: crack reaches edges
* 7. egg_opening shell opens (auto-advance after animation)
* 8. egg_hatching — bright light + baby reveal (auto-advance)
* 2. egg_glowing_waiting_click — egg glows, waiting for user click
* 3. egg_crack_stage_1 — click 1: crack appears
* 4. egg_crack_stage_2 — click 2: crack expands
* 5. egg_crack_stage_3 — click 3: crack reaches edges
* 6. egg_opening — shell opens (auto-advance after animation)
* 7. egg_hatching — bright light + baby emerges (auto-advance)
* 8. reveal — show reveal overlay with naming
* 9. complete — terminal, marks tour done
*
* The order here matches the intended flow. To reorder steps,
@@ -98,13 +85,13 @@ export interface TourActions<StepId extends string = string> {
*/
export type FirstHatchTourStepId =
| 'idle'
| 'show_hatch_card'
| 'egg_glowing_waiting_click'
| 'egg_crack_stage_1'
| 'egg_crack_stage_2'
| 'egg_crack_stage_3'
| 'egg_opening'
| 'egg_hatching'
| 'reveal'
| 'complete';
/**
@@ -115,26 +102,12 @@ export type FirstHatchTourStepId =
*/
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
{ id: 'idle' },
{ id: 'show_hatch_card' },
{ id: 'egg_glowing_waiting_click' },
{ id: 'egg_crack_stage_1' },
{ id: 'egg_crack_stage_2' },
{ id: 'egg_crack_stage_3' },
{ id: 'egg_opening', autoAdvance: true },
{ id: 'egg_hatching', autoAdvance: true },
{ id: 'reveal' },
{ id: 'complete' },
];
/**
* Persisted state shape for the first hatch tour.
*/
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
/**
* Default persisted state for a brand-new first hatch tour.
*/
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
currentStepId: null,
completed: false,
updatedAt: 0,
};
+129
View File
@@ -0,0 +1,129 @@
/**
* UI Tour - Types and Step Definitions
*
* The UI tour teaches new users the Blobbi interface after their first
* hatch. It is separate from the first-hatch tour and uses its own
* completion tag: `blobbi_ui_tour_done`.
*
* Architecture:
* - Steps are identified by string IDs and ordered in an array.
* - Each step declares what UI anchor it targets, what modal to show,
* and where the mini Blobbi guide should be.
* - The orchestrator reads the current step and positions everything.
* - Future phases add new steps to the array without changing the
* state machine or rendering infrastructure.
*/
// ─── Guide Choreography ───────────────────────────────────────────────────────
/**
* High-level choreography intents for the MiniBlobbiGuide.
*
* These are NOT low-level animation states. They describe the guide's
* role in the current moment of the walkthrough. The guide component
* internally sequences sub-phases (peek, climb, walk, edge-look, etc.)
* based on the active intent.
*/
export type GuideIntent =
| 'hidden' // Not rendered
| 'emerge_onto_modal' // Rise from behind modal top edge → peek → climb up
| 'pace_on_modal' // Walk back and forth on modal top with edge-look behavior
| 'fall_from_surface' // Fall off current surface downward
| 'emerge_onto_bar' // Rise from behind bar top edge → peek → climb up
| 'walk_to_target' // Walk along bar to a specific targetX, then stop
| 'inspect_target'; // Stopped centered above target, looking down
/**
* Where the guide is positioned relative to.
*/
export type GuideAnchorTarget =
| { type: 'modal' }
| { type: 'element'; anchorId: string }
| { type: 'offscreen' };
// ─── Step Definitions ─────────────────────────────────────────────────────────
/**
* A single UI tour step definition.
*/
export interface UITourStepDef {
/** Unique step identifier */
id: UITourStepId;
/** Where the mini Blobbi guide should be during this step */
guideTarget: GuideAnchorTarget;
/**
* Modal placement:
* - 'center': centered overlay modal (e.g. welcome screen)
* - 'bottom': anchored near the bottom bar area
*/
modalPlacement: 'center' | 'bottom';
/** If set, this anchor element receives a highlight glow */
highlightAnchor?: string;
/** Title text for the guided modal */
title: string;
/** Body text for the guided modal */
body: string;
}
// ─── Step IDs ─────────────────────────────────────────────────────────────────
export type UITourStepId =
| 'welcome'
| 'bar_item_0'
| 'bar_item_1'
| 'bar_item_2'
| 'bar_center'
| 'bar_more'
| 'complete';
/**
* Build the step definitions for the UI tour.
*
* Dynamic because bar item descriptions depend on which items are
* visible in the current bar preferences.
*/
export function buildUITourSteps(
barItemLabels: string[],
barItemDescriptions: string[],
): UITourStepDef[] {
const steps: UITourStepDef[] = [
{
id: 'welcome',
guideTarget: { type: 'modal' },
modalPlacement: 'center',
title: 'Welcome to the world of Blobbi!',
body: 'Congratulations on hatching your first Blobbi! These little creatures are here to keep you company while you explore this social world. Let me show you around.',
},
];
for (let i = 0; i < Math.min(barItemLabels.length, 3); i++) {
const stepId = `bar_item_${i}` as UITourStepId;
steps.push({
id: stepId,
guideTarget: { type: 'element', anchorId: `bar-item-${i}` },
modalPlacement: 'bottom',
highlightAnchor: `bar-item-${i}`,
title: barItemLabels[i],
body: barItemDescriptions[i],
});
}
steps.push({
id: 'complete',
guideTarget: { type: 'offscreen' },
modalPlacement: 'center',
title: '',
body: '',
});
return steps;
}
/** Descriptions for each BarItemId used in the tour steps */
export const BAR_ITEM_TOUR_DESCRIPTIONS: Record<string, string> = {
blobbies: 'Check on all your Blobbies here. You can see their health, switch between them, or adopt new ones later.',
missions: 'Complete missions to help your Blobbi grow. Missions refresh regularly and keep things interesting.',
items: 'Browse the shop for food, toys, and other goodies. Use items to keep your Blobbi happy and healthy.',
take_photo: 'Snap a photo of your Blobbi to share with friends. Capture their best moments!',
set_companion: 'Set this Blobbi as your floating companion. They\'ll follow you around while you browse.',
};
+277 -125
View File
@@ -36,6 +36,7 @@ import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
generateBlobbiContent,
type BlobbiCompanion,
type BlobbonautProfile,
} from '@/blobbi/core/lib/blobbi';
@@ -94,6 +95,7 @@ import { ActionBarEditor } from '@/blobbi/ui/components/ActionBarEditor';
import {
type ActionBarPreferences,
type BarItemId,
BAR_ITEM_LABELS,
getVisibleSlots,
getVisibleBarIds,
loadPreferences,
@@ -101,10 +103,11 @@ import {
loadMissionCardVisible,
saveMissionCardVisible,
} from '@/blobbi/ui/lib/action-bar-preferences';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourCard } from '@/blobbi/tour';
import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions';
import {
useFirstHatchTour, useFirstHatchTourActivation, useFirstEggExperience, BlobbiRevealOverlay,
useUITour, UITourOverlay, TourAnchorProvider, useTourAnchors,
buildUITourSteps, BAR_ITEM_TOUR_DESCRIPTIONS,
} from '@/blobbi/tour';
import type { EggTourVisualState } from '@/blobbi/egg';
/**
@@ -250,18 +253,30 @@ function BlobbiContent() {
// State for showing the adoption flow (for "Adopt another Blobbi")
const [showAdoptionFlow, setShowAdoptionFlow] = useState(false);
// Combine loading/fetching states
const companionLoading = collectionLoading;
const companionFetching = collectionFetching;
const invalidateCompanion = invalidateCollection;
// ─── First Egg Experience ────────────────────────────────────────────────────
// Handles auto profile + egg creation for brand-new users.
// This replaces the old BlobbiOnboardingFlow for first-time users.
const firstEggExperience = useFirstEggExperience({
profile,
profileLoading,
companions,
companionsLoading: companionLoading || (companionFetching && companions.length === 0),
updateProfileEvent,
updateCompanionEvent,
invalidateProfile,
invalidateCompanion: invalidateCollection,
setStoredSelectedD,
});
// STEP 6: Selection Priority
// 1) localStorage selection (if valid and exists in collection) - USER SELECTION ALWAYS WINS
// 2) first item from profile.has that exists in companionsByD - DEFAULT ONLY, never persisted
// 3) undefined (show selector)
//
// CRITICAL: Default selection must NEVER overwrite localStorage.
// User selection persists only via handleSelectBlobbi, not via this computed value.
const selectedD = useMemo(() => {
if (!profile) return undefined;
// Priority 1: localStorage selection (if it exists in loaded collection)
// USER SELECTION ALWAYS WINS - this is the authoritative source
if (storedSelectedD && companionsByD[storedSelectedD]) {
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: using localStorage selection:', storedSelectedD);
@@ -269,34 +284,21 @@ function BlobbiContent() {
return storedSelectedD;
}
// Priority 2: First item from profile.has that exists in companionsByD
// This is a DEFAULT - it should NOT be persisted to localStorage
for (const d of profile.has) {
if (companionsByD[d]) {
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: using default from profile.has:', d,
'(storedSelectedD was:', storedSelectedD,
storedSelectedD ? (companionsByD[storedSelectedD] ? 'exists' : 'NOT in companionsByD') : 'null', ')');
console.log('[BlobbiPage] selectedD: using default from profile.has:', d);
}
return d;
}
}
// Priority 3: No valid selection
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: no valid selection available');
}
return undefined;
}, [profile, storedSelectedD, companionsByD]);
// NOTE: We intentionally do NOT auto-save the computed selectedD to localStorage.
// This prevents the default selection from overwriting user selections during:
// - WebSocket updates
// - Query refetches
// - Race conditions where storedSelectedD is not yet in companionsByD
//
// User selections are only persisted via handleSelectBlobbi (line ~232).
// Get the selected companion from the collection
const companion = selectedD ? companionsByD[selectedD] ?? null : null;
@@ -313,11 +315,6 @@ function BlobbiContent() {
}
}, [selectedD, companion]);
// Combine loading/fetching states
const companionLoading = collectionLoading;
const companionFetching = collectionFetching;
const invalidateCompanion = invalidateCollection;
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
// Handler for selecting a Blobbi from the selector
@@ -564,10 +561,37 @@ function BlobbiContent() {
return <DashboardLoadingState />;
}
// ─── CASE B: No profile exists ───
// Show profile creation onboarding
// ─── CASE B & C: First egg experience is active (creating profile / egg) ───
// The useFirstEggExperience hook handles auto-creating profile + first egg.
// While it's working, show a loading state.
if (firstEggExperience.state.isActive && firstEggExperience.state.step !== 'ready') {
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: first egg experience -', firstEggExperience.state.step);
return (
<DashboardShell>
<div className="flex flex-col items-center justify-center min-h-[400px] gap-6 p-8">
<div className="relative">
<div className="size-28 rounded-3xl bg-gradient-to-br from-amber-500/20 via-orange-500/10 to-yellow-500/5 flex items-center justify-center shadow-lg">
<Egg className="size-14 text-amber-500 animate-pulse" />
</div>
</div>
<div className="text-center space-y-2 max-w-sm">
<h2 className="text-xl font-bold">
{firstEggExperience.state.step === 'creating_profile'
? 'Setting up your profile...'
: 'Preparing your first egg...'}
</h2>
<p className="text-sm text-muted-foreground">
This will only take a moment.
</p>
</div>
</div>
</DashboardShell>
);
}
// ─── CASE B2: No profile and first egg experience is idle (error case) ───
if (!profile) {
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: profile creation onboarding');
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: profile creation onboarding (fallback)');
return (
<DashboardShell>
<BlobbiOnboardingFlow
@@ -582,10 +606,10 @@ function BlobbiContent() {
);
}
// ─── CASE C: Profile exists but has no pets (empty has[] and no current_companion) ───
// Show adoption onboarding
// ─── CASE C2: Profile exists but has no pets and experience is idle ───
// This only happens when the auto-create fails or for subsequent adopts
if (!dList || dList.length === 0) {
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: adoption onboarding (profile exists, no pets)');
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: adoption onboarding (fallback)');
return (
<DashboardShell>
<BlobbiOnboardingFlow
@@ -728,6 +752,7 @@ function BlobbiContent() {
// Note: Item use registration is handled by useBlobbiActionsRegistration hook above
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: dashboard');
return (
<TourAnchorProvider>
<BlobbiDashboard
companion={companion}
companions={companions}
@@ -754,12 +779,14 @@ function BlobbiContent() {
invalidateCompanion={invalidateCompanion}
setStoredSelectedD={setStoredSelectedD}
ensureCanonicalBeforeAction={ensureCanonicalBeforeAction}
companionsReady={!companionLoading}
// DEV ONLY: State editor props
showDevEditor={showDevEditor}
setShowDevEditor={setShowDevEditor}
onDevEditorApply={handleDevEditorApply}
isDevUpdating={isDevUpdating}
/>
</TourAnchorProvider>
);
}
@@ -818,6 +845,8 @@ interface BlobbiDashboardProps {
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Whether the initial companion data load has completed (not background refetches) */
companionsReady: boolean;
// DEV ONLY: State editor props
showDevEditor: boolean;
setShowDevEditor: (show: boolean) => void;
@@ -851,6 +880,7 @@ function BlobbiDashboard({
invalidateCompanion,
setStoredSelectedD,
ensureCanonicalBeforeAction,
companionsReady,
// DEV ONLY
showDevEditor,
setShowDevEditor,
@@ -974,62 +1004,20 @@ function BlobbiDashboard({
const firstHatchTour = useFirstHatchTour();
useFirstHatchTourActivation({
companions,
isLoading: false, // companions are already loaded at this point
companionsReady,
tour: firstHatchTour,
profileOnboardingDone: profile?.onboardingDone,
profileFirstHatchTourDone: profile?.firstHatchTourDone,
});
const isFirstHatchTourActive = firstHatchTour.state.isActive;
// The required phrase for the first-hatch post
const firstHatchPhrase = useMemo(() => buildHatchPhrase(companion.name), [companion.name]);
// Auto-advance from idle -> show_hatch_card (immediately)
// Auto-advance from idle -> egg_glowing_waiting_click (immediately)
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('idle')) {
firstHatchTour.actions.advance(); // -> show_hatch_card
firstHatchTour.actions.advance(); // -> egg_glowing_waiting_click
}
}, [isFirstHatchTourActive, firstHatchTour]);
// Show the inline first-hatch card for all pre-hatch steps
const showFirstHatchCard = isFirstHatchTourActive && firstHatchTour.isAnyStep(
'show_hatch_card', 'egg_glowing_waiting_click',
'egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3',
);
// Detect hatch post completion for the first-hatch tour
const { user } = useCurrentUser();
const { nostr } = useNostr();
const tourAwaitingPost = isFirstHatchTourActive && firstHatchTour.isStep('show_hatch_card');
const { data: tourPostFound } = useQuery({
queryKey: ['first-hatch-tour-post', user?.pubkey, companion.name],
queryFn: async () => {
if (!user?.pubkey) return false;
const events = await nostr.query([{
kinds: [1],
authors: [user.pubkey],
limit: 20,
}]);
return events.some(e => isValidHatchPost(e, companion.name));
},
enabled: tourAwaitingPost && !!user?.pubkey,
refetchInterval: 5000,
staleTime: 3000,
});
// When the post is found during show_hatch_card, show the completed state
// for 2 seconds so the user sees the checkmark, then auto-advance to glowing.
useEffect(() => {
if (!tourPostFound || !isFirstHatchTourActive) return;
if (firstHatchTour.isStep('show_hatch_card')) {
const timer = setTimeout(() => {
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
}, 2000);
return () => clearTimeout(timer);
}
}, [tourPostFound, isFirstHatchTourActive, firstHatchTour]);
// Fake pointer hint: after 10s on glowing_waiting_click, show hint; repeat every 5s
const [showClickHint, setShowClickHint] = useState(false);
useEffect(() => {
@@ -1057,7 +1045,7 @@ function BlobbiDashboard({
}
}, [isFirstHatchTourActive, firstHatchTour]);
// Auto-advance for opening -> hatching -> complete (with hatch mutation)
// Auto-advance for opening -> hatching (with hatch mutation + reveal)
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('egg_opening')) {
@@ -1068,46 +1056,102 @@ function BlobbiDashboard({
}
}, [isFirstHatchTourActive, firstHatchTour]);
// When we reach egg_hatching, execute the actual hatch mutation then
// advance to the reveal step (NOT complete — reveal overlay handles completion)
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('egg_hatching')) {
// Execute the actual hatch mutation, mark onboarding complete on the
// profile event, then complete the tour's local state.
const doHatch = async () => {
try {
await onHatch();
// Persist blobbi_onboarding_done to the Blobbonaut profile (authoritative)
if (profile) {
try {
const updatedTags = updateBlobbonautTags(profile.allTags, {
blobbi_onboarding_done: 'true',
});
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(event);
} catch (e) {
console.error('[FirstHatchTour] Failed to persist onboarding completion to profile:', e);
}
}
} finally {
firstHatchTour.actions.complete();
} catch (e) {
console.error('[FirstHatchTour] Hatch mutation failed:', e);
}
// Advance to reveal step regardless of success/failure
// (the user should still see the naming overlay)
firstHatchTour.actions.advance(); // -> reveal
};
const timer = setTimeout(doHatch, 1200);
return () => clearTimeout(timer);
}
}, [isFirstHatchTourActive, firstHatchTour, onHatch, profile, publishEvent, updateProfileEvent]);
}, [isFirstHatchTourActive, firstHatchTour, onHatch]);
// Show reveal overlay when tour reaches the 'reveal' step AND the companion
// data has been updated to reflect the hatched baby. This avoids rendering
// the overlay with stale egg data while the cache is still updating.
const showRevealOverlay = isFirstHatchTourActive
&& firstHatchTour.isStep('reveal')
&& companion.stage !== 'egg';
// State for reveal naming
const [isNamingBlobbi, setIsNamingBlobbi] = useState(false);
// Complete the tour: persist to profile + complete in-memory session state
const completeFirstHatchTour = useCallback(async () => {
// Persist both blobbi_onboarding_done and blobbi_first_hatch_tour_done
if (profile) {
try {
const updatedTags = updateBlobbonautTags(profile.allTags, {
blobbi_onboarding_done: 'true',
blobbi_first_hatch_tour_done: 'true',
});
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(event);
} catch (e) {
console.error('[FirstHatchTour] Failed to persist completion to profile:', e);
}
}
firstHatchTour.actions.complete();
}, [profile, publishEvent, updateProfileEvent, firstHatchTour]);
// Handle naming from the reveal overlay
const handleRevealNameConfirm = useCallback(async (newName: string) => {
setIsNamingBlobbi(true);
try {
// Update the Blobbi event with the new name and correct content
if (companion) {
const updatedTags = updateBlobbiTags(companion.allTags, {
name: newName,
});
const content = generateBlobbiContent(newName, companion.stage);
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content,
tags: updatedTags,
});
updateCompanionEvent(event);
invalidateCompanion();
}
// Complete the tour and mark on profile
await completeFirstHatchTour();
} catch (e) {
console.error('[FirstHatchTour] Failed to rename:', e);
toast({
title: 'Failed to save name',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setIsNamingBlobbi(false);
}
}, [companion, publishEvent, updateCompanionEvent, invalidateCompanion, completeFirstHatchTour]);
// Handle dismiss (skip naming) from reveal overlay
const handleRevealDismiss = useCallback(async () => {
await completeFirstHatchTour();
}, [completeFirstHatchTour]);
// Derive tourVisualState for the egg visual
const tourVisualState = useMemo((): EggTourVisualState => {
if (!isFirstHatchTourActive) return 'idle';
const step = firstHatchTour.state.currentStepId;
switch (step) {
case 'show_hatch_card': return 'show_hatch_card';
case 'egg_glowing_waiting_click': return 'glowing_waiting_click';
case 'egg_crack_stage_1': return 'crack_stage_1';
case 'egg_crack_stage_2': return 'crack_stage_2';
@@ -1119,18 +1163,88 @@ function BlobbiDashboard({
}, [isFirstHatchTourActive, firstHatchTour.state.currentStepId]);
// DEV ONLY: Build tour dev actions for the state editor
// (uiTour is declared below but referenced here — the callback only runs on click,
// by which time the ref is populated. We use a ref to avoid a circular dependency.)
const uiTourResetRef = useRef<(() => void) | null>(null);
const resetTourWithProfile = useCallback(async () => {
// Reset in-memory state for both tours
firstHatchTour.actions.reset();
uiTourResetRef.current?.();
// Reset all tour-related profile tags
if (profile) {
try {
const updatedTags = updateBlobbonautTags(profile.allTags, {
blobbi_first_hatch_tour_done: 'false',
blobbi_ui_tour_done: 'false',
blobbi_onboarding_done: 'false',
});
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(event);
invalidateProfile();
} catch (e) {
console.error('[DevReset] Failed to reset tour profile tags:', e);
}
}
}, [firstHatchTour, profile, publishEvent, updateProfileEvent, invalidateProfile]);
const tourDevActions = useMemo(() => ({
skipPostRequirement: () => {
if (firstHatchTour.isStep('show_hatch_card')) {
skipToEggGlow: () => {
if (firstHatchTour.isStep('idle')) {
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
}
},
resetTour: () => {
firstHatchTour.actions.reset();
},
resetTour: resetTourWithProfile,
currentStepId: firstHatchTour.state.currentStepId,
isCompleted: firstHatchTour.state.isCompleted,
}), [firstHatchTour]);
}), [firstHatchTour, resetTourWithProfile]);
// ─── UI Walkthrough Tour ───
const { registerAnchor } = useTourAnchors();
// Build step definitions based on the current visible bar items
const uiTourSteps = useMemo(() => {
const slots = getVisibleSlots(barPrefs);
const labels = slots.map(s => BAR_ITEM_LABELS[s.id]);
const descriptions = slots.map(s =>
BAR_ITEM_TOUR_DESCRIPTIONS[s.id] ?? `Tap here to access ${BAR_ITEM_LABELS[s.id]}.`,
);
return buildUITourSteps(labels, descriptions);
}, [barPrefs]);
const uiTour = useUITour(uiTourSteps);
// Wire the ref so dev reset can reach the UI tour
uiTourResetRef.current = uiTour.actions.reset;
// Auto-start the UI tour when:
// - First hatch tour just completed this session (isCompleted && not active)
// - UI tour hasn't been done yet (profile tag)
// - UI tour is not yet active or completed this session
useEffect(() => {
if (
firstHatchTour.state.isCompleted
&& !isFirstHatchTourActive
&& !profile?.uiTourDone
&& !uiTour.state.isActive
&& !uiTour.state.isCompleted
) {
// Small delay so the reveal overlay fully closes first
const timer = setTimeout(() => uiTour.actions.start(), 600);
return () => clearTimeout(timer);
}
}, [firstHatchTour.state.isCompleted, isFirstHatchTourActive, profile?.uiTourDone, uiTour]);
const handleUITourComplete = useCallback(() => {
uiTour.actions.complete();
// Note: blobbi_ui_tour_done is NOT persisted yet.
// When ready, add profile tag publish here.
}, [uiTour]);
// State detection for tasks
// Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state
@@ -1590,19 +1704,34 @@ function BlobbiDashboard({
)}
</div>
{/* First Hatch Tour: inline card directly below egg (above stats) */}
{showFirstHatchCard && (
{/* First Hatch Tour: tap hint below egg during click steps */}
{isFirstHatchTourActive && firstHatchTour.isAnyStep(
'egg_glowing_waiting_click',
'egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3',
) && (
<div className="px-4 sm:px-6 mt-2">
<FirstHatchTourCard
blobbiName={companion.name}
requiredPhrase={firstHatchPhrase}
postCompleted={!!tourPostFound || !firstHatchTour.isStep('show_hatch_card')}
onCreatePost={() => setShowPostModal(true)}
currentStep={firstHatchTour.state.currentStepId}
/>
<div className="w-full max-w-sm mx-auto text-center space-y-2">
<h3 className="text-lg font-semibold">
Tap {companion.name} to hatch!
</h3>
<p className="text-sm text-muted-foreground">
Tap the egg to help {companion.name} break free.
</p>
</div>
</div>
)}
{/* Reveal Overlay: shown after hatching for naming */}
{showRevealOverlay && (
<BlobbiRevealOverlay
companion={companion}
open={showRevealOverlay}
onDismiss={handleRevealDismiss}
onNameConfirm={handleRevealNameConfirm}
isNaming={isNamingBlobbi}
/>
)}
{/* Stats Section - hidden during first-hatch tour */}
{!isFirstHatchTourActive && (
<div className="px-4 sm:px-6">
@@ -1712,6 +1841,17 @@ function BlobbiDashboard({
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
barPreferences={barPrefs}
onEditBar={() => setShowBarEditor(true)}
registerTourAnchor={registerAnchor}
/>
)}
{/* UI Tour Overlay */}
{uiTour.state.isActive && (
<UITourOverlay
tourState={uiTour.state}
tourActions={uiTour.actions}
companion={companion}
onComplete={handleUITourComplete}
/>
)}
@@ -2265,6 +2405,8 @@ interface BlobbiBottomBarProps {
// ── Action bar preferences ──
barPreferences: ActionBarPreferences;
onEditBar: () => void;
/** Optional: register anchor elements for the UI tour */
registerTourAnchor?: (id: string, el: HTMLElement | null) => void;
// ── Dev-only actions ──
onDevInstantTransition?: () => void;
onDevOpenEditor?: () => void;
@@ -2298,6 +2440,7 @@ function BlobbiBottomBar({
// Bar preferences
barPreferences,
onEditBar,
registerTourAnchor,
// Dev-only props
onDevInstantTransition,
onDevOpenEditor,
@@ -2370,7 +2513,7 @@ function BlobbiBottomBar({
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-0.5 sm:gap-2">
{/* Left Group - aligned to end (closer to center) */}
<div className="flex items-center justify-end gap-0 sm:gap-1 overflow-hidden">
{leftSlots.map((slot) => (
{leftSlots.map((slot, idx) => (
<BottomBarButton
key={slot.id}
onClick={handlers[slot.id]}
@@ -2379,6 +2522,9 @@ function BlobbiBottomBar({
badge={badgeMap[slot.id].badge}
badgeVariant={badgeMap[slot.id].variant}
highlighted={slot.highlighted}
anchorRef={registerTourAnchor
? (el) => registerTourAnchor(`bar-item-${idx}`, el)
: undefined}
/>
))}
</div>
@@ -2393,7 +2539,7 @@ function BlobbiBottomBar({
{/* Right Group - aligned to start (closer to center) */}
<div className="flex items-center justify-start gap-0 sm:gap-1 overflow-hidden">
{rightSlots.map((slot) => (
{rightSlots.map((slot, idx) => (
<BottomBarButton
key={slot.id}
onClick={handlers[slot.id]}
@@ -2402,6 +2548,9 @@ function BlobbiBottomBar({
badge={badgeMap[slot.id].badge}
badgeVariant={badgeMap[slot.id].variant}
highlighted={slot.highlighted}
anchorRef={registerTourAnchor
? (el) => registerTourAnchor(`bar-item-${halfIdx + idx}`, el)
: undefined}
/>
))}
@@ -2509,9 +2658,11 @@ interface BottomBarButtonProps {
badgeVariant?: 'default' | 'warning' | 'success';
/** Show subtle highlight ring around this button */
highlighted?: boolean;
/** Optional ref callback for tour anchor registration */
anchorRef?: (el: HTMLButtonElement | null) => void;
}
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default', highlighted }: BottomBarButtonProps) {
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default', highlighted, anchorRef }: BottomBarButtonProps) {
// Determine if badge should show
const showBadge = badge !== undefined && (typeof badge === 'string' || badge > 0);
@@ -2524,6 +2675,7 @@ function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default'
return (
<button
ref={anchorRef}
onClick={onClick}
className={cn(
"flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]",
+43 -1
View File
@@ -117,6 +117,40 @@ export default {
'collapsible-up': {
from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: '0' }
},
'spin-slow': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' }
},
'float-particle': {
'0%, 100%': { transform: 'translateY(0) scale(1)', opacity: '0.6' },
'50%': { transform: 'translateY(-20px) scale(1.2)', opacity: '1' }
},
'bounce-gentle': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-4px)' }
},
'walk-bob': {
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
'25%': { transform: 'translateY(-3px) rotate(-2deg)' },
'75%': { transform: 'translateY(-3px) rotate(2deg)' }
},
'look-down': {
'0%': { transform: 'translateY(0) rotate(0deg)' },
'100%': { transform: 'translateY(2px) rotate(8deg)' }
},
'guide-fall': {
'0%': { transform: 'translateY(0)', opacity: '1' },
'100%': { transform: 'translateY(120vh)', opacity: '0' }
},
'guide-rise': {
'0%': { transform: 'translateY(120vh)', opacity: '0' },
'40%': { opacity: '1' },
'100%': { transform: 'translateY(0)' }
},
'tour-highlight-pulse': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(251,191,36,0.4)' },
'50%': { boxShadow: '0 0 0 6px rgba(251,191,36,0.15)' }
}
},
animation: {
@@ -126,7 +160,15 @@ export default {
'badge-spotlight': 'badge-spotlight 8s linear infinite',
'highlight-fade': 'highlight-fade 1.5s ease-out forwards',
'collapsible-down': 'collapsible-down 0.2s ease-out',
'collapsible-up': 'collapsible-up 0.2s ease-out'
'collapsible-up': 'collapsible-up 0.2s ease-out',
'spin-slow': 'spin-slow 20s linear infinite',
'float-particle': 'float-particle 3s ease-in-out infinite',
'bounce-gentle': 'bounce-gentle 2s ease-in-out infinite',
'walk-bob': 'walk-bob 0.4s ease-in-out infinite',
'look-down': 'look-down 0.4s ease-out forwards',
'guide-fall': 'guide-fall 0.6s ease-in forwards',
'guide-rise': 'guide-rise 0.7s ease-out forwards',
'tour-highlight-pulse': 'tour-highlight-pulse 2s ease-in-out infinite'
}
}
},