Skip to content

[codex] Add cinematic intro animation#49

Draft
joyehuang wants to merge 21 commits into
mainfrom
feat/intro-cinematic
Draft

[codex] Add cinematic intro animation#49
joyehuang wants to merge 21 commits into
mainfrom
feat/intro-cinematic

Conversation

@joyehuang

Copy link
Copy Markdown
Owner

Summary

This PR records the experimental homepage entrance-animation branch. It adds a cinematic intro layer for the blog homepage, centered on an LLM-style self-introduction: the site asks who is joye?, streams the answer token-by-token, highlights named entities, then hands off into the normal landing page.

This is opened as a draft because the concept is strong, but the interaction, pacing, mobile behavior, and long-term maintenance shape still need review.

What changed

  • Added IntroOverlay.astro and src/scripts/intro.ts for the main entrance sequence.
  • Added intro.css for the cinematic overlay, streaming text, entity highlights, atmosphere, reduced-motion skip behavior, and dev replay button.
  • Integrated the intro on src/pages/index.astro before the homepage content.
  • Added src/data/joye-log.ts as structured inventory for work/projects/posts used by the intro status line and earlier animation explorations.
  • Added JoJo onboarding tour components and styles:
    • JoJoTour.astro
    • JoJoTour.tsx
    • jojo-tour.css
  • Added intro-related visual assets under public/intro/.
  • Kept an archived previous 3D intro experiment in .intro-archive/intro-3d-embedding.ts.bak for reference.
  • Made a small cleanup commit before opening the PR: 175f279 fix(intro): trim tour file eof whitespace.

Behavior notes

  • The intro plays once per session via sessionStorage.
  • prefers-reduced-motion: reduce skips the intro.
  • The page scroll position is reset before playback to avoid entering mid-page.
  • The landing sections are pre-hidden during the overlay, then revealed through a GSAP handoff.
  • Dev mode exposes a replay button for iteration.

Validation

  • git diff --check main...origin/feat/intro-cinematic passes after the whitespace cleanup.

I did not rerun the full Astro build/check in this request; this PR is primarily opened as a draft record for the existing branch and should get a proper visual/browser pass before merging.

Follow-up notes

Things worth reviewing before this becomes merge-ready:

  • Whether the intro should exist for all visitors or only first-time/session visitors.
  • Mobile layout and reduced-motion handling.
  • Whether JoJo tour should remain in this branch or be split into a separate PR.
  • Whether the archived intro experiment should stay in repo history or move to notes/docs.
  • Possible conflicts with current homepage changes that landed after this branch was created.

joyehuang added 21 commits June 22, 2026 15:59
Adds a Three.js-driven cinematic intro overlay on the landing page:
- SSR renders a dark overlay shell + an inline <head> script that decides
  play/skip synchronously (prefers-reduced-motion or sessionStorage flag)
- src/scripts/intro.ts lazy-imports three + gsap so they never enter the
  first-paint critical path
- Phase 1 (0~0.9s): 5000-particle cloud fades in (2200 on mobile)
- Phase 2 (0.9~1.4s): hold
- Phase 3 (1.4~2.3s): cloud expands + dissolves, camera dollies in
- Phase 4 (2.0~2.8s): overlay dissolves, hero zooms from scale(0.94)+blur(8px)
  to scale(1)+blur(0) via GSAP
- Locks the page (overflow hidden) and suppresses the default .animate
  fade-in-up while active so GSAP can take over hero transitions
- 4.5s hard timeout fallback in case WebGL boot fails
- Adds @/scripts/* path alias

Phase 2 (particles forming the avatar silhouette) and the shader-based
noise dissolution are intentionally deferred until the base vibe feels right.

New deps: three, @types/three
Replaces the previous Three.js particle cloud (which didn't fit the
terminal/CLI/minimalist theme DNA) with a Canvas 2D ASCII-particle version
that matches the blog's visual language:

- Drops three.js entirely (-150KB), keeps only gsap (already a dep)
- Particles are now monospace glyphs (█▓▒░*··>/<_0123…) rendered via Canvas 2D
- Strictly monochrome — single primary blue, no rainbow palette
- Phase 1 ENTER  (0~1.5s): chars fly in from off-screen
- Phase 2 CHAOS  (1.5~3.5s): chars drift + flicker (data-stream feel)
- Phase 3 ASSEMBLE (3.5~6s): chars lerp to avatar silhouette targets,
  shifting from decode glyphs to dense block glyphs as they approach
- Phase 4 HOLD   (6~7s): avatar complete, micro-flicker on outline
- Phase 5 REVEAL (7~8.5s): chars burst outward, overlay dissolves,
  hero zooms from scale(.94)+blur(8px)
- Avatar PNG URL is passed via data-attribute and sampled client-side
  (alpha channel → ~1200 target points on a 220x220 grid)
- Subtle scanline pattern reinforces terminal DNA
- New dev-only Replay button (top-right) clears sessionStorage + reloads
- Dev-mode __intro.state hook for live tuning from the console
…hero img

- Replay button: CSS was hiding it in both states (default display:none
  plus html.intro-active override). Fixed by making it display:flex by
  default and only hiding while intro-active plays.
- Avatar assemble position: was hard-coded to viewport center, but the
  real hero <img> lives at the top of the page (cy ≈ 148). Now queries
  the actual #content-header img via getBoundingClientRect and assembles
  the silhouette to overlap it, so the char-avatar lands exactly on top
  of the real avatar when the overlay dissolves.
- Silhouette size now scales with the real avatar's rendered size (~1.6x
  to extend slightly past the edges for a more dramatic reveal).
Adds a 10-step narrative tour that plays after the cinematic intro
finishes, walking new visitors through the landing page's core sections.

- New <JoJoTour> React component, mounted via client:idle island
- Component decides to play based on prefers-reduced-motion +
  sessionStorage('tour-played') — one play per session
- Waits for window event 'intro:complete' (or window.__introDone flag)
  from src/scripts/intro.ts so it launches exactly when the intro
  overlay dissolves
- 10 steps: welcome → terminal → about → blog → notes → talks →
  experience → open-source → skills → bye
- Strong spotlight via oversized box-shadow (rgba(0,0,0,0.68)) with a
  rounded primary-tinted border around the active section
- JoJo mascot flies between sections (GSAP-like CSS transitions) with
  a multi-line speech bubble (jojo-bubble now white-space: pre-line)
- Bottom control bar: Skip / N/10 progress / Next
- Auto-advances 2.4s per step (3.2s for welcome + bye); user can Next
  to accelerate or Skip to bail
- Locks background scroll while active
- Recomputes spotlight + JoJo position on resize
- Replay button (top-right, dev only) now clears both intro-played
  and tour-played so the full experience can be re-watched
Three small UX bugs that compounded into a janky first impression:

1. Replay from mid-page kept scroll position
   - history.scrollRestoration = 'manual' + window.scrollTo(0,0) before
     reload so the intro always starts from the top of the page.

2. First spotlight frame was misplaced (then 'caught up' late)
   - scrollIntoView is async, so reading getBoundingClientRect right
     after gave the pre-scroll position. Now we compute the target's
     'future' rect — the viewport-centered position it will land in
     once scroll completes — and set the spotlight to that immediately.
     The smooth scroll and the spotlight's CSS transition now move in
     sync, instead of spotlight lagging 500ms behind.

3. Bouncy / floaty easing made each step feel like it overshot
   - Was cubic-bezier(0.65, 0, 0.35, 1) at 0.55–0.65s. Swapped to
     Material standard cubic-bezier(0.4, 0, 0.2, 1) at 0.4s for both
     spotlight + mascot transitions. Settles fast, no rubber-band.

Also cleaned up the resize handler to use the same 'future-centered'
rect math so it stays consistent with the per-step computation.
Replaces the Canvas 2D ASCII-particle intro with a 3D Three.js experience
that ties directly to the author's AI / Agent / LLM / Code / RAG work.

The concept: the visitor's viewpoint dives into a 3D concept-space populated
by 4 clusters of glowing tokens (Agent / LLM / Code / RAG), connected by
attention lines that pulse like signal flow. The tokens then converge to
the global center and finally materialize into the author's avatar
silhouette, which lines up with the real hero <img> for the reveal.

Architecture:
- Three.js Points + custom ShaderMaterial (reliable, vs InstancedMesh
  + InstancedBufferAttribute which wasn't picking up per-instance attrs)
- Vertex shader mixes between 3 positional states per token:
  home (cluster) -> converge center -> avatar silhouette target
- Fragment shader paints soft glowing dots (core + halo) with additive
  blending
- Avatar targets sampled from the hero <img> alpha channel, mapped into
  world coords so the particle silhouette lands at the same screen
  position as the real avatar at reveal time
- Phase 1 boot line rendered as a styled HTML overlay (terminal DNA)
- Attention lines: intra-cluster (2 nearest neighbors) + cross-cluster,
  with flicker driven by sin(time)
- GSAP timeline drives fadeIn / converge / materialize globals + camera
  position; render loop pushes those into shader attributes each frame
- Mouse parallax (desktop only), reduced-motion fallback, 16s hard timeout

Re-installs three + @types/three (~150KB gz).

Tuning still needed: avatar silhouette vertical position, attention pulse
direction, possibly per-token z jitter for depth on the silhouette.
Three compounding bugs made the assembled avatar land in the wrong
place across the screen, plus a flat (overstretched) aspect ratio.

1. Page wasn't scrolled to top before measuring the hero <img>
   - The inline script ran scrollRestoration='manual' but didn't actually
     scrollTo(0,0). When the visitor previously left the page mid-scroll
     (e.g. after the JoJo tour), reload restored that scroll, putting the
     hero <img> at rect.top = -1669 — every world-space computation below
     was then garbage.
   - Now both the inline script AND runIntro() itself call window.scrollTo
     (0, 0) defensively, in case anything scrolls the page in between.

2. Camera lookAt was running on GSAP's onUpdate only
   - GSAP's onUpdate fires on its own tick which can lag the render loop
     by a frame, leaving the camera matrix out of sync with the position
     GSAP just wrote. The silhouette then rendered off-center.
   - Now call camera.lookAt(0, 0, 0) every frame inside the render tick.

3. Silhouette was stretched flat (5:1 wide)
   - Used sampleMax (the larger of sampleW/sampleH) as the divisor, but
     the avatar PNG is mostly empty vertical space so sampleH is much
     smaller than sampleW. The rendered silhouette came out 522x147.
   - Now scale uniformly by sampleH and let width follow naturally,
     preserving the PNG's intrinsic head+shoulders aspect ratio.
   - Y axis also flipped (PNG pixel Y is downward, world Y is upward);
     previously the silhouette was implicitly upside down, which is why
     earlier 'fix the Y position' attempts overshot.

4. Silhouette sized to ~27% of viewport height (revealHalfH * 0.55),
   capped so the top can't spill past the screen edge given the hero
   img sits at NDC y ≈ 0.69.

5. Overlay background softened to ~94% opacity (was fully opaque) so
   the hero silhouette bleeds through subtly as tokens approach their
   target positions — makes the handoff feel continuous.

Verified end-to-end: rendered center (754, 146) vs expected (756, 153)
= dx=-2px, dy=-7px. Avatar bbox 243x84 fully on-screen.
Replaces the abstract 3D 'Embedding Space Dive' with a content-driven
entry animation that tells Joye's actual story instead of decorating
with shapes.

The new intro types '> cat joye.log' one character at a time, then
cascades Joye's real shipped work — 12 blog posts (oldest → newest),
2 open-source repos, 4 internship roles — down the screen as a log.
The whole log then coalesces into a soft glow and the overlay dissolves
to reveal the landing page.

Why this and not the previous versions:
- Previous versions (Three.js point cloud, ASCII chars, embedding
  clusters) were all variations on 'abstract particles converge on a
  shape' — technically different, narratively identical, and not
  meaningful to a first-time visitor.
- This version uses REAL content (blog titles, repo names, role lines)
  that the visitor can actually read and remember. The animation IS
  the introduction, not a curtain in front of it.

Architecture:
- Drops three.js entirely (-150KB gz). Intro is now pure DOM + CSS
  transitions — ~5KB of logic, 60fps on any device, no WebGL required.
- New src/data/joye-log.ts holds the canonical list of work; the SSR
  template renders all lines into the DOM up front (visible to
  crawlers, screen-reader friendly), the client script just orchestrates
  reveal timing.
- Phase 1 TYPEWRITER: chars are appended at 65ms ± 30ms jitter so it
  reads as typing, not metronome.
- Phase 2 CASCADE: lines reveal top-to-bottom, 90ms stagger for the
  first 6 (early work, less weight) and 130ms for the rest.
- Phase 3 FOOTER: '18 entries · 12 posts · 2 repos · 4 roles' summary
  fades in.
- Phase 4 COALESCE: log scales to 0.32 + blur(18px) + lift 12vh over
  1.4s — metaphor of the body of work compacting into the author.
- Phase 5 REVEAL: overlay opacity → 0 in parallel with hero
  scale(.96)+blur(8px) → scale(1)+blur(0) over 1s. 600ms overlap so
  there's no hard cut.
- Visual: monochrome cool palette only (blue-gray + soft white), strict
  terminal DNA (JetBrains Mono, scanline overlay, soft dark navy
  background instead of pure black), breathing room between lines.

Disables <JoJoTour> on the landing page for now (per the user's
'don't touch the tour yet' note — focus is on getting the intro right
first). The component still exists and will be re-enabled later.

Old Three.js intro archived under .intro-archive/ for reference.
Major UI polish pass on joye.log — the content/narrative was right, but
the surface was too flat. This adds the cinematic feel without changing
the story.

Atmosphere (covers the whole overlay, doesn't touch the content):
- New .intro-atmosphere layer with two pseudo-elements
- ::before is an inline SVG feTurbulence noise pattern (180x180 tile,
  fractalNoise baseFrequency 0.85, two octaves) animated by 4-step
  grain-shift at 0.6s — gives a real film-grain texture that's never
  static. screen-blended at 10% opacity so it's a texture, not a haze.
- The layer itself paints a radial vignette (transparent center → 55%
  black at corners) so the eye is funneled into the log column.

Per-char stagger (the most visually distinctive change):
- SSR now wraps every title char and meta char in <span class='char'
  style='--i:N'>. Char index drives a calc()-based transition-delay.
- When a line receives .intro-visible, the line container fades in
  normally but each character inside also slides up + un-blurs with
  a 14ms stagger starting at 80ms. Result: each title visibly types
  itself out, line after line.

Scan light sweep:
- Each .intro-log-line has a ::before that's an oversized horizontal
  gradient band (cool blue → bright white → cool blue).
- On .intro-visible, the gradient animates background-position from
  100% → -50% over 0.85s, so a vertical 'beam' sweeps left → right
  across the line as it appears. screen-blended so it adds light
  without flattening text.

Keyword + number accent tokens:
- renderTokenized() in IntroOverlay.astro wraps keywords (Agent / LLM /
  Transformer / RoPE / Attention / RMSNorm / FeedForward / OpenHarness /
  Interview / Mock / Playyy.ai / atypica.ai / AIXCut / fAIshion.ai) and
  numbers (\b\d[\d,]*(?:\.\d+)?(?:h\d+m)?\b) in token-kw / token-num
  spans, while still char-staggering inside.
- .token-kw: brighter blue + 8px+18px text-shadow glow
- .token-num: mint green, semibold, smaller glow
- These pull the visitor's eye to the concepts that matter.

Footer chars also stagger in (8ms each) when the footer becomes visible.

Footer placeholder class 'intro-hidden-init' removed (it was a leftover
that was overriding the visible state). Footer now toggles via the same
.intro-visible pattern.

Verified: bun run check passes, dev server runs the full 9s timeline
cleanly, all 18 log lines render at SSR with per-char spans (visible to
view-source / crawlers).
Major restructuring to make the intro and the landing page read as one
continuous experience instead of 'cinematic, then a hard cut to the page'.

Content now grouped, not flat:
- src/data/joye-log.ts restructured into JOYE_LOG_GROUPS — Blog / Open
  Source / Experience — matching the same Section titles the landing
  page already renders.
- Each entry has a weight (0/1/2) driving brightness + size:
  weight=2 (Agent era, recent) is brightest and largest; weight=0
  (early work, history) is dim and small. The cascade now has clear
  visual hierarchy instead of reading as a flat list.
- SSR renders a group header ([ Blog ] 12) above each cluster with a
  gradient text label + accent bracket; a thin vertical rule on the
  left edge of each group ties entries to their parent.

Cascade respects grouping:
- Groups reveal top → bottom (Blog → Open Source → Experience).
- Inside each group, lines reveal in weight-descending order so the
  eye lands on the marquee work first, then the history.
- Group header slides in slightly before its entries.

Phase 4 MORPH — the headline change:
- For each intro-log-group, findLandingSection() locates the
  corresponding <main> <section> by matching its <h2> text against the
  group's label (Blog / Open Source / Experience).
- morphGroupIntoSection() does a FLIP-style transform: translates the
  group onto the section's bounding rect + scales it to the section's
  size, then fades the group out (with a 6px blur) while the section
  itself fades in 350ms into the morph — the eye reads it as a
  handoff, not two parallel motions.
- Hero (#content-header, #content) reveals in parallel as the visual
  anchor.

Phase 5 REVEAL remaining sections:
- Sections that don't have a corresponding log group (About, Notes,
  Talks, Education, Skills, SiteStats) were pre-hidden at the start of
  Phase 4 so dropping the .intro-active lock doesn't flash them.
- revealRemainingSections() now staggers them top → bottom at 90ms
  each, so the page feels like it's being assembled, not 'switched on'.

Bug fix wrapped in: pre-hide all landing sections before removing
.intro-active so #content becoming visible doesn't momentarily show
every section at once, then morph/reveal choreography drives each one
individually.
Replaces the flat 'translate + scale + fade' group morph with a
proper particle-dispersion sequence so the transition from log → landing
page reads as the page literally growing out of the intro, not as a
crossfade.

Per group, the new morph has three beats over ~1.9s:

A. JITTER (0 → 0.3s)
   Every char in the group runs a 5-keyframe Web Animations sequence
   with small random translate offsets (±3-4px). Visual: the line
   destabilizes, like a signal about to break up.

B. SCATTER + FLY (0.3 → 1.5s)
   Each char picks:
     - a scatter direction biased 180° away from the section's center
       (so chars initially explode outward, away from where they're
       going), with ±0.4π random spread
     - a scatter distance 40-120px
     - a final target = a random point inside the section's bounding
       rect (so the chars actually 'land on' the section, not in front
       of it)
     - a random rotation (-45°..+45°) for visual richness
   Then a 4-keyframe animation runs:
     0%   rest
     25%  scattered position (outward explosion, blur 2px, scale 0.9)
     60%  mid-flight between scatter and target (blur 4px, scale 0.7)
     100% on-section target (blur 10px, scale 0.3, opacity 0)
   Duration 1.1-1.45s, delay 0-180ms, all randomized per-char so the
   cloud feels organic, not synchronized. easing: cubic-bezier(0.45, 0,
   0.55, 1) — symmetric ease-in-out so the scatter and the arrival both
   read.

   While chars fly, the group's non-char chrome (header, stamp, meta
   etc.) fades from 1 → 0 over 1.3s starting at +200ms.

C. MATERIALIZE (0.7s into the sequence)
   Section content (opacity 0 → 1, translateY 16px → 0) fades in while
   chars are still mid-flight. The eye reads this as a handoff: chars
   arriving on the section dissolve into glow as the section's real
   content fades up through them.

Implementation notes:
- Uses the native Web Animations API (element.animate) rather than GSAP
  or CSS — gives us per-element keyframes + per-element delay without
  spawning thousands of style tags.
- All random values (scatter angle, distance, target offset, rotation,
  duration, delay) are computed per-char up front, so the same char
  always animates the same way on a given run.
- fill: 'forwards' on every animation so chars stay in their end state
  until the group is removed by overlay teardown.

Pixel-count diagnostic during testing showed the expected pattern:
during scatter, bright-pixel count climbs gradually as chars spread;
at fly-mid it jumps 40x as chars overlay the section and the section
fades in together — exactly the 'growing into the page' beat we want.
Adds a full-viewport 'company tour' beat between the Experience cascade
and the rest of the intro, so the visitor sees Joye's actual workplaces
instead of just text labels for them.

Reorder: Experience group is now first (was last). The visitor meets
Joye through where he works right now, then sees the body of work that
got him there.

Experience showcase:
- New #intro-showcase layer in IntroOverlay.astro, sibling of #intro-log.
  SSR-renders one .showcase-panel per Experience entry that has a
  screenshot field.
- Each panel = a CRT terminal frame (traffic-light dots + titlebar
  reading 'joye@experience — zsh' + '01/04' counter) wrapping a full
  homepage screenshot of the company.
- Screenshots are terminal-ized via CSS filter chain: grayscale .55 +
  brightness .78 + contrast 1.12 + sepia .18 + hue-rotate 170deg +
  saturate .85. Result reads as 'this site rendered through a CRT'.
- A repeating-linear-gradient scan-line pattern + radial vignette
  overlay sells the CRT effect, multiplied into the screenshot.
- Caption (bottom-left): '> Adastra Labs → Playyy.ai  · description',
  JetBrains Mono with primary-blue glow on the title.
- The active panel gets a slow dolly-in (scale 1.08 → 1.0 over 1.6s)
  so each company reads like a camera move, not a slideshow.

Showcase flow in intro.ts:
- After the Experience log group cascades in, it slides up + blurs out
  (opacity 0, translateY -20px, scale .94, blur 6px).
- #intro-showcase scales in from .92 → 1.
- Panels cycle: 1.4s per company, last one holds 1.9s (so the current
  role lands harder). Counter ('01' .. '04') updates in the titlebar.
- After the last panel, showcase fades out and the Blog + Open-Source
  groups cascade in normally.

Screenshots:
- public/intro/{playyy,atypica,aixcut,faishion}.png — captured the four
  company homepages at 1512x982 via browser-use, stored as static assets.

Extended morph on user request:
- Jitter phase 0.30s → 0.45s
- Scatter+fly 1.10-1.45s → 1.60-2.05s, with delay spread 0-180ms → 0-220ms
- Group fade 1.30s → 1.70s
- Materialize trigger 700ms → 950ms
- Total morph 1.90s → 2.60s
The dispersion now has time to breathe — you can see chars explode,
curve, and arrive.

Experience section doesn't re-morph — it was just showcased, so a
second particle dispersion would be redundant. It fades in directly
alongside the morph of Blog + Open Source.
The morph was reading as 'stiff' because:
- 4 keyframes gave the motion a mechanical, evenly-spaced feel
- chars hit the section and hard-cut to opacity 0 (no drift)
- easing was symmetric ease-in-out — chars moved at constant speed
  visually, no inertia

This rewrites the per-char animation to feel like real particles:

7 keyframes per char instead of 4:
  0%   rest
  14%  starting to scatter (gentle lead-in)
  28%  scattered peak (max outward displacement)
  52%  mid-flight, curving back toward section
  76%  approaching section
  88%  arrived, still glowing faintly
  95%  drifting past the section, dissolving
  100% gone

Two important changes:
1. DRIFT — after a char arrives at the section (88%), it keeps moving
   past it (95%, 100%) while fading from 0.22 → 0.08 → 0 opacity and
   blur from 11px → 16px → 22px. Removes the hard cut at the end.
2. SWIFT-OUT EASING — cubic-bezier(0.16, 1, 0.3, 1) on every char.
   Chars now decelerate naturally, like they have inertia. Was using
   symmetric ease-in-out which made speed feel constant.

Plus:
- Duration 1.6-2.05s → 2.2-2.8s (more time to read the motion)
- Delay spread 0-220ms → 0-320ms (more staggered arrivals)
- Rotation ±100° → ±120° with progressive multiplier 0.18 / 0.38 /
  0.65 / 0.88 / 1.0 / 1.15 / 1.3 across keyframes — chars spin as
  they fly, accelerating slightly into the drift
- Scale curve 1 → 0.96 → 0.86 → 0.68 → 0.48 → 0.32 → 0.2 → 0.12 —
  smoother than the previous 1 → 0.9 → 0.7 → 0.3 stair steps
- Group chrome fade revised from 3-point to 4-point (1 → 0.7 → 0.3 → 0)
  so it doesn't outpace the chars
- Total morph 2.6s → 3.2s, materialize trigger 950ms → 1100ms so the
  section fade aligns with chars arriving, not before

Jitter phase also softened: 'linear' → 'ease-in-out' so the
destabilization feels more like a wiggle than a vibration.
…k-to-visit

Showcase is no longer a passive auto-playing slideshow. The visitor can
now drive it.

Layout rewrite:
- Left rail (230px wide, terminal-menu styled) lists the four companies
  with idx + name. The active item gets a mint-green cursor (▸), brighter
  text, a left accent border, and a horizontal gradient background — same
  visual language as a focused row in tmux / vim.
- Main stage is unchanged (CRT-framed screenshot) but now sits next to
  the rail instead of full-width.

Interaction model (the actual point of this change):
- Hover a rail item → showcase pauses auto-advance and jumps to that
  company. Move the mouse away → auto-advance resumes from the new
  position after the normal 1.5s tick.
- Hover the main stage → also pauses (so the info panel below can be
  read without the screenshot changing underneath).
- Click any rail item, or click the main stage, → opens the company's
  product site in a new tab (data-url driven). The dedicated 'visit
  site ↗' button inside the info panel navigates normally and stops
  propagation so clicks on it don't double-trigger.
- Auto-cycle still happens by default (1.5s per company, 2s on the
  last) so a hands-off visitor still gets the full tour in ~6.5s.

Hover info panel:
- Floats on the right side of the stage, 280px wide, dark glass
  (backdrop-filter blur) with a primary-tinted border + soft glow.
- Shows: company (small uppercase) → product name (large, blue-glow) →
  meta rows [role, period, stack chips] → highlight bullets (mint ▸) →
  'visit site ↗' button.
- New per-entry fields in joye-log.ts drive it: role / period / stack /
  highlights / url. Each Experience entry is now a fuller picture of
  what Joye actually did there, not just a label.
- Caption at the bottom fades to 0.5 opacity while info is hovered so
  the two don't compete for the eye.

Stronger CRT / terminal feel throughout:
- Heavier screenshot filter: grayscale 0.7 (was 0.55), brightness 0.7
  (was 0.78), contrast 1.18 (was 1.12) — screenshot reads more as
  'rendered through a CRT' than 'photo with a tint'.
- New .showcase-scanlines layer with a slow vertical sweep animation
  (5s linear infinite, 100px) — simulates CRT refresh.
- New .showcase-stage::after radial bloom — cool blue glow at the
  center, screen-blended, sells the 'light being emitted' feel.
- Traffic-light dots now have a glow halo (box-shadow on each i).
- Active counter glows mint, title border is brighter, rail active
  state has a left accent border in mint.

Verified end-to-end: showcase activates, auto-advances through 4
panels, hovering a rail item pauses + switches, leaving resumes, click
opens URL. bun run check passes.
Free-form polish pass — four detail upgrades that each make the intro
feel more like a real terminal session and less like a slideshow.

1. Multi-line boot sequence (typewriter)
   The single '> cat joye.log' was thin. Boot now types three lines in
   sequence:
     > ssh joye@mind
       ↳ loading 18 entries... ok
     > cat joye.log▌
   Reads like sshing into a system. The output line is dimmer, indented,
   prefixed with ↳, and types faster (32ms vs 60ms per char) so it
   doesn't drag the boot. Cursor only blinks on the final command line.
   Boot phase is ~3.5s now (was 1.5s) but it's earnings — the visitor
   sees the system 'come online'.

2. Keyboard navigation in the showcase
   Real terminals are driven by the keyboard. Showcase now responds to:
     ArrowDown / ArrowRight  → next company
     ArrowUp / ArrowLeft     → previous company
     Enter / Space           → open the current company's URL
   All keypresses pause auto-advance, same as hover. The keydown handler
   is scoped to window but bails early if showcase isn't active, and
   cleans up on teardown.

3. Glitch transition between panels
   When the visitor switches companies (keyboard or rail hover), the new
   panel briefly RGB-shifts: screenshot hue rotates 140° → 200° → 180°
   while translating ±3-4px, and a horizontal scan band sweeps top to
   bottom in 350ms steps(2, end). Sells a CRT channel-flip. The glitch
   class self-clears after 350ms so it can re-trigger next time.

4. Character trails during the particle morph
   Each flying char now carries two drop-shadows (cool blue trailing
   left, brighter blue-white leading right) that grow then fade across
   the trajectory — peak at mid-flight (52% keyframe), gone by arrival.
   Reads as a comet trail / firefly light streak instead of a blur blob.
   Two-sided shadow gives a subtle chromatic-aberration flavor at the
   tails.

Verified: bun run check passes, boot renders 3 lines, ArrowDown moves
counter 02 → 03, glitch class is added + removed on switch, char
shadows are present in morph keyframes.
Two distinct problems from the user feedback:

1. '破碎感就是卡卡的' (the shatter feels janky)
   Cause: I had added two drop-shadow filters per char (cool blue trail
   + bright leading edge) across 8 keyframes. Several hundred chars ×
   8 keyframes of drop-shadow recomputation = GPU couldn't keep up,
   so the morph stuttered.
   Fix: drop-shadow removed entirely. Chars now animate transform +
   opacity + blur only, which are all GPU-composited. The comet-trail
   aesthetic is sacrificed for fluidity — fluidity wins.

2. '碎掉以后进入到主页的过渡太生硬' (morph-to-page handoff is stiff)
   Cause: Phase 5 was串行 — await morphs → await revealRemaining →
   overlay.classList.add('intro-hidden') → await sleep(500). The page
   appeared in discrete steps: 'morph, then snap-to-page'.
   Fix: complete timeline rewrite. Morph, remaining-sections reveal,
   and overlay dissolve now all run IN PARALLEL:
     - t+0      morph kicks off (chars fly)
     - t+350ms  Experience section fades in (showcased, no morph)
     - t+500ms  remaining 5 sections (About/Notes/Talks/Ed/Skills)
                stagger in at 60ms each
     - t+1100ms morphed sections (Blog/Open Source) start fading in
     - t+1400ms overlay starts dissolving (scale 1.06 + blur 14px +
                opacity 0, 0.9s)
     - t+3200ms morph resolves
     - t+4100ms overlay fully dissolved, removed from DOM
   The page now 'emerges through' the dissipating overlay instead of
   the overlay snapping away.

Overlay dissolve upgraded from a flat opacity fade to a real
'dissolve': opacity + scale(1.06) + blur(14px) over 0.9s. Reads as
the overlay diffusing outward, not being switched off.

Remaining-sections stagger tightened from 90ms to 60ms so all five
land within the morph window instead of trailing it.
Experience was the only landing section whose visibility relied entirely
on findLandingSection('Experience') succeeding — it was excluded from
the remainingSections safety-net, so a null lookup would leave it at
opacity 0 forever while every other section restored. Now Experience
rides the same stagger as the other non-morphed sections, and
revealImmediately() also clears any stale section inline styles if the
intro aborts mid-morph.
Replaces the log-cascade + CRT terminal showcase with a single
portfolio card grid that pops up one-by-one in a diagonal wave.

Flow (~11s, down from ~14s):
  typewriter boot -> grid pop-in wave -> hold -> dissolve + page reveal

- New PORTFOLIO_CARDS model in joye-log.ts flattens every work role,
  open-source repo, and marquee (weight >= 2) blog post into one
  story-ordered deck: work -> repos -> writing (11 cards).
- Cards are kind-accented (work=blue / repo=green / blog=cyan) with a
  screenshot media area for experiences and a glyph + gradient for
  repos/posts, a per-card scan-light sheen, and weight-driven glow.
- Pop-in uses an overshoot easing + 75ms diagonal stagger; dissolve
  lifts cards away in reverse while the overlay opens up and landing
  sections stagger in beneath it.
- Work/repo cards stay click-through-able to their product/repo URL.
- Drops the now-unused cascade/showcase/particle-morph machinery.
Rebuilds the entry as a cinematic, GSAP-orchestrated sequence:

  CRT power-on -> typewriter -> 3D materialize from depth
    -> hold (parallax + holographic flicker) -> particle shatter to page

- Cards fly in from deep Z (translateZ -780) with rotation + blur + bloom,
  assembling into a tilted deck with real perspective; the deck tilts down
  to a resting angle and tracks the cursor via 3D parallax during the hold.
- Holographic card look: per-kind accent color, HUD corner brackets, card
  + global CRT scanlines, chromatic-aberration titles, bloom on weight-2
  cards, animated diagonal sheen, subtle scanline flicker once settled.
- On handoff each card bursts into 28 particles that streak outward then
  curve toward its matching landing section (work->Experience, repo->Open
  Source, blog->Blog) while the sections materialize beneath and the
  overlay dissolves. ~308 particles total, cleaned up after.
- Timeline tightened to ~9s. revealImmediately() now also clears GSAP
  inline props so a failed intro can never leave the page blank.
Drops the busy card-grid / holographic-deck directions entirely. The new
intro is one coherent idea executed with restraint: the site introduces
its author the way their products work — by generating.

Flow (~7s):
  wrap fades in -> bio streams token-by-token (entities light up)
    -> status: done · Xs -> text lifts away, page rises beneath

- Centered layout with real negative space (padding 6vh + 30px gap), large
  calm typography (clamp 18-25px). Fixes the cramped / edge-to-edge feel
  of the previous directions.
- The bio is SSR'd with entity spans already in place; the client splits
  it into per-char spans and emits them in small 1-3 char 'token' batches
  with punctuation-aware pauses + occasional 'thinking' hesitations, so it
  reads like real LLM sampling. A streaming cursor follows the caret.
- Entities (Playyy.ai / atypica / Transformers / Attention / LLM) light up
  the moment their final char is emitted: accent color + glow + an
  underline that draws in.
- Status line pulses 'generating', then resolves to 'done · 5.3s' with the
  real elapsed stream time.
- Handoff is a single clean motion: the text lifts + blurs away while the
  landing sections stagger up beneath and the overlay dissolves. No
  particle explosions.
- Reverts joye-log.ts to its original state (the dead portfolio-card model
  is gone).
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blog Ready Ready Preview, Comment Jun 24, 2026 11:19am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant