Skip to content

Commit 82f863a

Browse files
rezailmiReza Ilmi
andauthored
Feel-better polish + hatch tuner + bird crown + Path Finder cards (#60)
* refactor(polish): feel-better pass across History / Profile / Path Finder, plus bird palette + legend icons + rail borders Apply the 16 principles of the make-interfaces-feel-better skill across the three sheets and their shared chrome. Token-first cascade so follow-on surfaces inherit. - styles.css `@theme`: new shadow tokens (--shadow-sheet-popover / -dialog / -tile / -active-rail), --press-scale token, .image-outline utility, global :where(h1-h4) text-balance + :where(p,li) text-pretty, rename @Keyframes trajectoryMenuIn → sheet-popover-in - ui/sheet.tsx, ui/drawer.tsx: PageCloseButton + Drawer close size-9/8 → size-10 hit area + press-scale; transition-[transform,opacity] collapse (principle #14); SheetNavButton gets press-scale 0.97 - ui/button.tsx: JSDoc note on `sm` variant hit area - HistorySheet + CalendarPane + DayDetailCard: ViewModeDropdown press + popover shadow token + enter animation; chevron/today/term/retry buttons bumped to min-h-10 / size-10 with press-scale - ProfileSheet: avatar + claim thumbnails get .image-outline; AccountMenu + ShareDialog + Tldr/Personality hero get shadow tokens + popover enter animation; ShareDialog buttons bumped to min-h-10 + press-scale; toggle pseudo hit-area extender; press-scale on TimelineQuote + BigFiveCards + Tldr chips; drop unused Button import - TrajectorySheet: rename keyframe callsites to sheet-popover-in; nudge pathway-tabs outer radius to rounded-[18px] (exact concentric) - ChoicesPageView: tabular-nums on counts; press-scale + before:-inset pseudo-extender on pattern + force chips Bird: - Kira.js: bring Masked Bower into the palette system. Adds palette.body + palette.tie to the masked species spec; refactors the GLB recolor block to read from the palette. setSpecies() now routes ALL species ids through the MaskedBower GLB, tinted from the chosen species's palette (back/accent → body/tie), so picking any egg color hatches the same GLB shape in the matching colorway. Re-applies tints in _buildMaskedAsync's resolve handler since the GLB scene is module-cached. Calendar legend: replace dot/square markers with lucide icons (Smile / NotebookPen / Camera / CalendarDays). SideRail buttons: subtle dark borders for hover (black/8) + active (black/10) — the prior white-on-cream borders were invisible. Active state now uses --shadow-active-rail token; tooltip pill uses --shadow-sheet-popover + faint border + specific transition list. `pnpm check` clean. `pnpm test` shows only pre-existing failures identical to main. * feat(hatch): visual tuner + bird crown restore + calendar cell icons + Path Finder cards Bird (Kira.js): keep V_Crest* crown visible across all colorways (removed PR #55's MB_Crest hide block + dropped PR #57's mohawk tuft + cap-sleeve calls); reparent MB_Crest meshes under MB_Head via Object3D.attach() so the crown follows head tilt + wave; add MB_HeadOrange to the palette tint map so the head matches the body color. Hatch tuner: new src/lib/student-space/hatch-tuner.ts store mirroring camera-tuner pattern (DEFAULT_HATCH_PRESET + patch/reset/useHatchPreset). EggHatcher reads bird Y positions, scale, camera FOV + distance from the store at scene init; new HATCH_TUNER_REPLAY_EVENT bumps a replayKey so the canvas remounts with fresh values. New HatchTuneHud component with sliders + embedded EggCanvas preview + Replay/Copy/Reset, gated to dev via the Cmd+K palette and mounted globally in __root.tsx. Tuned defaults: birdStartY -0.7, birdRevealY -0.4, birdScale 0.39, cameraFov 28, cameraDistance 6.4. EggCanvas polish: container h-48 w-40 → h-80 w-56, shell halves transition transparent and fade out during the WAVE phase so the bird stands alone before the next screen, name-step input restyled to match the swatch-card hover/focus treatment (max-w-[18rem], --shadow-sheet-tile default + lift to --shadow-sheet-popover on hover/focus). buildBird routes ALL egg colors through the GLB clone tinted from the species palette (procedural builder archived in place as defensive fallback). CalendarPane: cell markers swapped from colored dots / squares / glyph-dot to Smile / Camera|NotebookPen / CalendarDays icons matching the legend; per-mood color preserved via inline style; photo precedence over reflection preserved; selected-cell inversion preserved. TrajectorySheet (Path Finder): SearchingBody pathway tabs converted from a single tablist + tabpanel into a stacked <ol> of self-contained cards (number badge + title + prompt + EvidenceDisclosure + MSF link inside each card); hover lift via --shadow-sheet-tile → --shadow-sheet-popover; selection state (activeIndex / selectedIndex) removed. Misc: firstChatActions.feel copy "Tell me how I feel now" → "Capture my how i feel now"; KiraSpeciesPalette type extended with optional body+tie GLB tints. `pnpm check` clean. `pnpm test` shows only pre-existing failures identical to main (same 10 failures, +17 new tests from rebased PRs all passing). --------- Co-authored-by: Reza Ilmi <reza.ilmi@gt.tech.gov.sg>
1 parent 70cc00b commit 82f863a

20 files changed

Lines changed: 1157 additions & 207 deletions

docs/plans/2026-05-23-001-refactor-feel-better-history-profile-pathfinder-plan.md

Lines changed: 397 additions & 0 deletions
Large diffs are not rendered by default.

src/components/ChoicesPageView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ function PatternTagPicker({
298298
aria-pressed={active}
299299
data-testid={`choices-decision-tag-${entry.id}-${tag}`}
300300
className={cn(
301-
'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
301+
'relative cursor-pointer rounded-full border px-2.5 py-0.5 text-xs font-medium transition-[background-color,color,border-color,transform] before:absolute before:-inset-2 before:content-[""] active:scale-[0.96] disabled:pointer-events-none',
302302
active
303303
? 'border-[#2b2620] bg-[#2b2620] text-white'
304304
: 'border-[#e3d8c4] bg-white/70 text-[#2b2620]/65 hover:text-[#2b2620]',
@@ -415,7 +415,7 @@ function DecisionForm({
415415
aria-pressed={active}
416416
data-testid={`choices-decision-form-force-${f}`}
417417
className={cn(
418-
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
418+
'relative cursor-pointer rounded-full border px-3 py-1 text-xs font-medium transition-[background-color,color,border-color,transform] before:absolute before:-inset-2 before:content-[""] active:scale-[0.96]',
419419
active
420420
? cn('font-medium', theme.callout, theme.border)
421421
: 'border-[#e3d8c4] bg-white text-[#2b2620]/65 hover:text-[#2b2620]',
@@ -514,7 +514,7 @@ function SectionPatterns({
514514
{PATTERN_TAG_LABEL[tag]}
515515
</p>
516516
<p className="text-xs text-[#2b2620]/65">{PATTERN_TAG_DESCRIPTION[tag]}</p>
517-
<p className="mt-2 text-2xl font-semibold">
517+
<p className="mt-2 text-2xl font-semibold tabular-nums">
518518
{count}
519519
<span className="ml-1 text-xs font-normal text-[#2b2620]/55">
520520
{count === 1 ? 'decision' : 'decisions'}

src/components/DevPalette.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Dialog as BaseDialog } from '@base-ui-components/react/dialog'
22
import { useNavigate, useRouterState } from '@tanstack/react-router'
33
import { useCallback, useEffect, useMemo, useState } from 'react'
44
import { CAMERA_TUNER_OPEN_EVENT } from '~/components/student-space/onboarding/CameraTuneHud'
5+
import { HATCH_TUNER_OPEN_EVENT } from '~/components/student-space/onboarding/HatchTuneHud'
56
import { Dialog, DialogOverlay, DialogPortal } from '~/components/ui/dialog'
67
import { clearStudentSpaceLocalState } from '~/lib/clear-student-space-local-state'
78
import { signOutEngine } from '~/lib/sign-out-engine'
@@ -68,6 +69,17 @@ export function DevPalette() {
6869
},
6970
}
7071
: null
72+
const hatchTuner: Command | null = import.meta.env.DEV
73+
? {
74+
id: 'hatch-tuner',
75+
label: 'Show hatch tuner',
76+
hint: 'live preview',
77+
run: () => {
78+
setOpen(false)
79+
window.dispatchEvent(new Event(HATCH_TUNER_OPEN_EVENT))
80+
},
81+
}
82+
: null
7183
return [
7284
{ id: 'ui', label: 'Switch to UI mode', hint: '/', run: go('/') },
7385
{
@@ -86,6 +98,7 @@ export function DevPalette() {
8698
},
8799
},
88100
...(cameraTuner ? [cameraTuner] : []),
101+
...(hatchTuner ? [hatchTuner] : []),
89102
{
90103
id: 'restart-onboarding',
91104
label: 'Restart onboarding',

src/components/student-space/navigation/SideRail.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,15 +167,15 @@ function RailButton({
167167
className={cn(
168168
'group relative grid size-11 cursor-pointer place-items-center rounded-xl border border-transparent transition-[transform,background-color,border-color,color,box-shadow] duration-(--duration-fast) ease-(--ease-out) active:scale-[0.96] motion-reduce:active:scale-100',
169169
'bg-transparent text-(--color-sheet-ink-soft) shadow-none',
170-
'hover:border-white/70 hover:bg-white/70 hover:text-(--color-sheet-ink)',
171-
'data-[active]:border-white data-[active]:bg-white data-[active]:text-(--color-sheet-ink) data-[active]:shadow-lg data-[active]:shadow-black/12',
170+
'hover:border-black/[0.08] hover:bg-white/70 hover:text-(--color-sheet-ink)',
171+
'data-[active]:border-black/[0.10] data-[active]:bg-white data-[active]:text-(--color-sheet-ink) data-[active]:shadow-[var(--shadow-active-rail)]',
172172
compact && 'text-(--color-sheet-ink-faint)',
173173
)}
174174
>
175175
<Icon aria-hidden className="size-5" />
176176
<span
177177
className={cn(
178-
'pointer-events-none absolute left-[calc(100%+8px)] top-1/2 z-10 -translate-y-1/2 translate-x-1 rounded-full px-2.5 py-1 text-[11px] font-semibold whitespace-nowrap opacity-0 shadow-lg shadow-black/12 transition-[opacity,transform] duration-(--duration-fast) ease-(--ease-out) motion-reduce:transition-none group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:translate-x-0 group-focus-visible:opacity-100 max-[640px]:hidden',
178+
'pointer-events-none absolute left-[calc(100%+8px)] top-1/2 z-10 -translate-y-1/2 translate-x-1 rounded-full border border-black/[0.06] px-2.5 py-1 text-[11px] font-semibold whitespace-nowrap opacity-0 shadow-(--shadow-sheet-popover) transition-[opacity,transform] duration-(--duration-fast) ease-(--ease-out) motion-reduce:transition-none group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:translate-x-0 group-focus-visible:opacity-100 max-[640px]:hidden',
179179
'bg-white text-(--color-sheet-ink)',
180180
)}
181181
>

src/components/student-space/onboarding/EggHatcher.tsx

Lines changed: 118 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
EGG_COLORS,
2121
ONBOARDING_COPY,
2222
} from '~/engine/student-space/Game/View/Onboarding/copy.js'
23+
import { getHatchPreset } from '~/lib/student-space/hatch-tuner'
2324
import { cn } from '~/lib/utils'
2425

2526
/**
@@ -55,6 +56,11 @@ type KiraLike = {
5556
setSpecies?: (id: string) => unknown
5657
}
5758

59+
/** Event fired by the hatch-tuner HUD to remount the EggCanvas so
60+
* fresh tuner values take effect. Bumping a state key on receipt
61+
* forces React to unmount + recreate the Three.js scene. */
62+
export const HATCH_TUNER_REPLAY_EVENT = 'student-space:replay-hatch-canvas'
63+
5864
export function EggHatcher({
5965
stage,
6066
reducedMotion,
@@ -74,6 +80,13 @@ export function EggHatcher({
7480
const [selectedColor, setSelectedColor] = useState<string>(initialColor)
7581
const [name, setName] = useState(onboarding?.companionName ?? '')
7682
const [visible, setVisible] = useState(reducedMotion)
83+
const [replayKey, setReplayKey] = useState(0)
84+
85+
useEffect(() => {
86+
const onReplay = () => setReplayKey((k) => k + 1)
87+
window.addEventListener(HATCH_TUNER_REPLAY_EVENT, onReplay)
88+
return () => window.removeEventListener(HATCH_TUNER_REPLAY_EVENT, onReplay)
89+
}, [])
7790

7891
const selected = EGG_COLOR_BY_ID[selectedColor] ?? EGG_COLORS[0]
7992
const trimmedName = name.trim()
@@ -121,6 +134,7 @@ export function EggHatcher({
121134
{stage === 'egg-color' ? (
122135
<section className="flex w-full max-w-[420px] flex-col items-center gap-5 text-center">
123136
<EggCanvas
137+
key={`egg-color-${replayKey}`}
124138
color={hatchColor}
125139
reducedMotion={reducedMotion}
126140
speciesId={selectedColor}
@@ -147,16 +161,17 @@ export function EggHatcher({
147161
aria-label={ONBOARDING_COPY.eggColor.swatchAria.replace('{colorName}', color.name)}
148162
data-testid={`egg-color-${color.id}`}
149163
className={cn(
150-
'flex min-h-16 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-transparent bg-white/70',
151-
'text-xs font-semibold text-(--color-onb-ink) shadow-[0_8px_20px_rgba(15,18,36,0.10)]',
152-
'transition-[transform,border-color,background] duration-150 hover:-translate-y-px hover:bg-white',
164+
'flex min-h-16 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-transparent bg-white/70 py-5',
165+
'text-xs font-semibold text-(--color-onb-ink) shadow-(--shadow-sheet-tile)',
166+
'transition-[transform,border-color,background-color,box-shadow] duration-200 ease-(--ease-sheet)',
167+
'hover:-translate-y-0.5 hover:bg-white hover:shadow-(--shadow-sheet-popover) active:scale-[0.96]',
153168
'focus-visible:outline-[3px] focus-visible:outline-[rgba(255,138,92,0.7)] focus-visible:outline-offset-[3px]',
154-
'data-[checked]:border-(--color-onb-accent) data-[checked]:bg-white',
169+
'data-[checked]:-translate-y-0.5 data-[checked]:border-(--color-onb-accent) data-[checked]:bg-white data-[checked]:shadow-(--shadow-sheet-popover)',
155170
)}
156171
>
157172
<span
158173
aria-hidden="true"
159-
className="size-7 rounded-full shadow-inner"
174+
className="image-outline size-8 rounded-full shadow-inner"
160175
style={{ background: color.hex }}
161176
/>
162177
{color.name}
@@ -178,6 +193,7 @@ export function EggHatcher({
178193
{stage === 'egg-name' ? (
179194
<section className="flex w-full max-w-[420px] flex-col items-center gap-5 text-center">
180195
<EggCanvas
196+
key={`egg-name-${replayKey}`}
181197
color={hatchColor}
182198
reducedMotion={reducedMotion}
183199
speciesId={selectedColor}
@@ -204,7 +220,12 @@ export function EggHatcher({
204220
commitName()
205221
}
206222
}}
207-
className="min-h-12 w-full rounded-2xl border border-[rgba(43,38,32,0.12)] bg-white/80 px-4 text-center text-base font-semibold text-(--color-onb-ink) shadow-[0_8px_20px_rgba(15,18,36,0.10)] outline-none focus:border-(--color-onb-accent)"
223+
className={cn(
224+
'min-h-12 w-full max-w-[18rem] rounded-2xl border-2 border-transparent bg-white/70 px-4 text-center text-base font-semibold text-(--color-onb-ink) shadow-(--shadow-sheet-tile)',
225+
'transition-[transform,border-color,background-color,box-shadow] duration-200 ease-(--ease-sheet) outline-none',
226+
'hover:-translate-y-0.5 hover:bg-white hover:shadow-(--shadow-sheet-popover)',
227+
'focus:-translate-y-0.5 focus:border-(--color-onb-accent) focus:bg-white focus:shadow-(--shadow-sheet-popover)',
228+
)}
208229
/>
209230
<div className="flex flex-wrap justify-center gap-3">
210231
<Button
@@ -234,6 +255,7 @@ export function EggHatcher({
234255
aria-live="polite"
235256
>
236257
<EggCanvas
258+
key={`egg-hatch-${replayKey}`}
237259
color={hatchColor}
238260
reducedMotion={reducedMotion}
239261
speciesId={selectedColor}
@@ -267,8 +289,10 @@ export function EggHatcher({
267289
// boots (pre-reveal, scale 0). `BIRD_REVEAL_Y` is where it lands after the
268290
// reveal ramp; the wave bob adds ±0.025 on top.
269291
const BIRD_REVEAL_SCALE = 1.0
270-
const BIRD_START_Y = -0.8
271-
const BIRD_REVEAL_Y = -0.65
292+
// Bird Y positions + scale + camera framing all sourced from
293+
// `hatch-tuner.ts` (live-tunable via the Cmd+K dev HUD). Values are
294+
// read at scene init time — the dev HUD fires a replay event that
295+
// remounts the canvas so changes take effect.
272296

273297
const HATCH_TIMELINE = {
274298
WIGGLE_END: 450,
@@ -285,7 +309,7 @@ const HATCH_TIMELINE = {
285309
WAVE_END: 2400,
286310
} as const
287311

288-
function EggCanvas({
312+
export function EggCanvas({
289313
color,
290314
reducedMotion,
291315
speciesId,
@@ -337,8 +361,14 @@ function EggCanvas({
337361

338362
const scene = new THREE.Scene()
339363

340-
const camera = new THREE.PerspectiveCamera(28, 160 / 200, 0.1, 100)
341-
camera.position.set(0, 0, 5.0)
364+
// Snapshot tuner values at scene init. The HatchTuneHud's "Replay"
365+
// remounts this component so changes take effect on the next frame.
366+
const tuner = getHatchPreset()
367+
const BIRD_START_Y = tuner.birdStartY
368+
const BIRD_REVEAL_Y = tuner.birdRevealY
369+
370+
const camera = new THREE.PerspectiveCamera(tuner.cameraFov, 160 / 200, 0.1, 100)
371+
camera.position.set(0, 0, tuner.cameraDistance)
342372
camera.lookAt(0, 0, 0)
343373

344374
scene.add(new THREE.AmbientLight(0xffffff, 0.34))
@@ -361,6 +391,10 @@ function EggCanvas({
361391
emissive: 0x000000,
362392
emissiveIntensity: 1,
363393
flatShading: true,
394+
// Transparent so the shell halves can fade out during the wave
395+
// phase (see HATCH_TIMELINE; the intact `mesh` is invisible by
396+
// then so its opacity is irrelevant).
397+
transparent: true,
364398
})
365399
const mesh = new THREE.Mesh(geo, mat)
366400
mesh.rotation.set(0.18, 0.45, 0)
@@ -451,49 +485,72 @@ function EggCanvas({
451485
}
452486
const buildBird = (id: string) => {
453487
const token = ++buildToken
454-
if (id === 'masked') {
455-
// Async path: clone the cached GLB scene so the hatchling is
456-
// visually the same bird that flies in one screen later
457-
// (orange body, red tie, tuft hair). SkeletonUtils.clone
458-
// gives the clone its own skeleton, so animating the world
459-
// Kira's bones doesn't twitch the egg bird.
460-
void (async () => {
461-
try {
462-
const [{ scene: source }, SkeletonUtils] = await Promise.all([
463-
loadMaskedScene(),
464-
import('three/examples/jsm/utils/SkeletonUtils.js'),
465-
])
466-
if (token !== buildToken || cancelled) return
467-
const clone = SkeletonUtils.clone(source) as THREEType.Object3D
468-
// The cached source scene is already scaled to MASKED_SCALE
469-
// (0.30); the clone inherits that. For the egg preview we
470-
// size up so the bird reads at roughly the procedural bird's
471-
// footprint inside the shell.
472-
clone.scale.setScalar(0.55)
473-
// Re-find the head in the cloned tree (SkeletonUtils.clone
474-
// creates new node instances). MB_Head is the visible head
475-
// mesh, sibling to the armature inside MB_Rig.
476-
let cloneHead: THREEType.Object3D | null = null
477-
clone.traverse((o) => {
478-
if (!cloneHead && /MB_Head/i.test(o.name)) cloneHead = o
479-
})
480-
birdParts = { root: clone, head: cloneHead ?? clone, kind: 'glb-clone' }
481-
birdGroup.add(clone)
482-
} catch (err) {
483-
if (token !== buildToken || cancelled) return
484-
console.warn('[EggCanvas] masked GLB clone failed, falling back to procedural', err)
485-
const spec = SPECIES_BY_ID.masked ?? SPECIES_BY_ID.flame
486-
const parts = buildStandingBird(spec)
487-
birdParts = { root: parts.root, head: parts.head, kind: 'procedural' }
488-
birdGroup.add(parts.root)
488+
// All species hatch as the MaskedBower GLB, tinted from the
489+
// chosen species's palette — same routing as Kira.setSpecies in
490+
// the world canvas, so the egg preview matches the post-hatch
491+
// bird across every egg-color pick. The procedural builder
492+
// (buildStandingBird) is archived in place: still imported, but
493+
// only called as a defensive fallback if the GLB load fails.
494+
// SkeletonUtils.clone gives the clone its own skeleton, so
495+
// animating the world Kira's bones doesn't twitch the egg bird.
496+
void (async () => {
497+
try {
498+
const [{ scene: source }, SkeletonUtils] = await Promise.all([
499+
loadMaskedScene(),
500+
import('three/examples/jsm/utils/SkeletonUtils.js'),
501+
])
502+
if (token !== buildToken || cancelled) return
503+
const clone = SkeletonUtils.clone(source) as THREEType.Object3D
504+
// Scale comes from the hatch tuner. Default 0.38 — the
505+
// source GLB ships at MASKED_SCALE 0.30; the dev HUD can
506+
// dial this between ~0.2 and ~0.8 to fit the canvas.
507+
clone.scale.setScalar(tuner.birdScale)
508+
// Tint cloned materials per the picked species's palette.
509+
// Body + head share the same tint; tie uses the accent.
510+
// NOTE: SkeletonUtils.clone shares materials by reference,
511+
// so this mutates the cached scene's materials too. That's
512+
// fine — the world Kira re-applies its own tints on the
513+
// same materials when setSpecies runs after onboarding.
514+
const spec = SPECIES_BY_ID[id] ?? SPECIES_BY_ID.masked
515+
const bodyTint = spec?.palette?.body || spec?.palette?.back
516+
const tieTint = spec?.palette?.tie || spec?.palette?.accent
517+
const tints: Record<string, string | undefined> = {
518+
MB_BodyYellow: bodyTint,
519+
MB_HeadOrange: bodyTint,
520+
Uniform_TieStriped: tieTint,
489521
}
490-
})()
491-
return
492-
}
493-
const spec = SPECIES_BY_ID[id] ?? SPECIES_BY_ID.flame
494-
const parts = buildStandingBird(spec)
495-
birdParts = { root: parts.root, head: parts.head, kind: 'procedural' }
496-
birdGroup.add(parts.root)
522+
clone.traverse((o) => {
523+
const mesh = o as THREEType.Mesh
524+
if (!mesh.isMesh) return
525+
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
526+
for (const mat of mats) {
527+
if (!mat) continue
528+
const m = mat as THREEType.MeshStandardMaterial
529+
const hex = tints[m.name]
530+
if (hex) {
531+
m.color?.set(hex)
532+
m.needsUpdate = true
533+
}
534+
}
535+
})
536+
// Re-find the head in the cloned tree (SkeletonUtils.clone
537+
// creates new node instances). MB_Head is the visible head
538+
// mesh, sibling to the armature inside MB_Rig.
539+
let cloneHead: THREEType.Object3D | null = null
540+
clone.traverse((o) => {
541+
if (!cloneHead && /MB_Head/i.test(o.name)) cloneHead = o
542+
})
543+
birdParts = { root: clone, head: cloneHead ?? clone, kind: 'glb-clone' }
544+
birdGroup.add(clone)
545+
} catch (err) {
546+
if (token !== buildToken || cancelled) return
547+
console.warn('[EggCanvas] masked GLB clone failed, falling back to procedural', err)
548+
const spec = SPECIES_BY_ID[id] ?? SPECIES_BY_ID.masked ?? SPECIES_BY_ID.flame
549+
const parts = buildStandingBird(spec)
550+
birdParts = { root: parts.root, head: parts.head, kind: 'procedural' }
551+
birdGroup.add(parts.root)
552+
}
553+
})()
497554
}
498555
buildBird(speciesRef.current)
499556

@@ -679,6 +736,13 @@ function EggCanvas({
679736
)
680737
birdParts.head.rotation.z = Math.sin(waveU * Math.PI) * 0.2
681738
birdGroup.position.y = BIRD_REVEAL_Y + Math.sin(waveU * Math.PI) * 0.025
739+
// Fade both shell halves out during the wave so the bird is
740+
// standing alone when the screen transitions to first-chat.
741+
mat.opacity = 1 - easeOutCubic(waveU)
742+
if (waveU >= 1) {
743+
topShell.visible = false
744+
bottomShell.visible = false
745+
}
682746
}
683747
}
684748
}
@@ -748,7 +812,7 @@ function EggCanvas({
748812
}, [])
749813

750814
return (
751-
<div className="relative grid h-48 w-40 place-items-center" aria-hidden="true">
815+
<div className="relative grid h-80 w-56 place-items-center" aria-hidden="true">
752816
<div className="absolute bottom-3 h-4 w-24 rounded-full bg-[rgba(43,38,32,0.10)] blur-sm" />
753817
<canvas ref={canvasRef} className="relative h-full w-full" />
754818
</div>

0 commit comments

Comments
 (0)