7 Commits

Author SHA1 Message Date
zhanxi ab39fdead3 feat: 补齐作品集 agent skill 规范
按开发到生产流程合并,tag 在生产分支合并后重新创建。
2026-06-11 10:05:17 +00:00
湛兮 06185b67b4 feat: 补齐作品集 agent skill 规范
- .agents: 新增中文提交与注释同步 skill 及项目索引

- AGENTS.md: 增加本地协作规则和 skill 入口

- README.md: 同步 Agent 文档入口
2026-06-11 17:48:09 +08:00
湛兮 832671e308 feat: add portfolio external links 2026-06-11 16:34:57 +08:00
湛兮 92cedd235b fix: play dialogue rounds on enter 2026-06-11 16:09:55 +08:00
湛兮 4ae452336c fix: snap scroll to dialogue typing 2026-06-11 15:38:25 +08:00
湛兮 4c63c00a18 fix: sync resume portfolio lockfile 2026-06-11 14:23:09 +08:00
湛兮 22baa715fd feat: import fluid portfolio snapshot 2026-06-11 12:24:52 +08:00
19 changed files with 2230 additions and 1770 deletions
+21
View File
@@ -0,0 +1,21 @@
# my-resume Agent Skills
本目录存放 `my-resume` 的项目级 Agent skill。规则参考 SeaCloud 项目的 agent skill 组织方式,并按当前 Vite、Three.js 和作品集页面场景保留最小必要规范。
## 使用入口
1. 进入仓库后先读 `AGENTS.md`
2. 创建或修改代码文件时,使用 `header-comment-sync` 保持中文文件头、导出声明和复杂逻辑注释同步。
3. 提交或推送代码时,使用 `chinese-commit-message` 生成英文 type 前缀加中文摘要的 commit message。
4. 所有 skill 的触达文件补齐规则只处理本次任务相关文件,不要求为了单次任务全仓扫描。
## 当前项目事实
- 应用类型:独立 Vite + Three.js 作品集站点。
- 技术栈:Vite、Three.js、GSAP、Lenis、原生 JavaScript。
- 关键边界:交互与动效逻辑需要关注移动端性能、 reduced-motion 降级和首屏可读性。
## Skill 索引
- `header-comment-sync`:中文文件头、导出声明、复杂逻辑和风险边界注释。
- `chinese-commit-message`:中文提交信息格式。
@@ -0,0 +1,54 @@
---
name: chinese-commit-message
description: 在本仓库执行 git commit、整理提交说明或准备推送时使用,保持提交信息使用英文类型前缀加中文描述。
---
# 中文提交信息
## 何时使用
- 用户要求提交、推送或整理 commit message。
- 你准备执行 `git commit`
- 需要总结当前 diff 的提交说明。
## 核心要求
1. 提交信息必须使用英文 type 加中文摘要,例如 `feat: 补齐作品集 agent skill 规范`
2. 常用 type 优先使用 `feat``fix``refactor``docs``chore`
3. 不强制 scope;只有模块边界非常清楚时才使用 `feat(scene): ...`
4. 标题部分使用简洁中文,直接说明改动,不写“修改一下”“更新代码”这类空泛描述。
5. 非 trivial 改动建议补中文正文,用扁平 bullet 按文件或内容块说明关键动作。
6. 正文与标题之间空一行;正文每条 bullet 对应一个独立文件或明确内容块。
7. 如果用户指定提交信息,优先尊重用户原意,只做必要格式整理。
## 触达文件补齐
- 不要求为了提交信息单独全仓扫描。
- 提交前如果 diff 中的触达文件明显违反对应 skill 的触达补齐要求,应先修正当前相关链路,再整理 commit message。
- commit 正文应概括本次触达补齐,例如“补齐触达文件注释”“同步 README 目录说明”。
## 推荐结构
```text
feat: 补齐作品集 agent skill 规范
- .agents/README.md: 新增项目级 skill 索引和使用入口
- .agents/skills: 补充中文提交与注释同步规则
- AGENTS.md: 增加本地 skill 入口
```
## 不推荐写法
- `新增规则`
- `feat: add skills`
- `fix bug`
- `update`
- 复杂改动只有标题没有正文。
- 正文写成长段流水账或嵌套列表。
## 落地检查
- type 是否合理。
- 中文摘要是否覆盖主改动。
- 是否需要正文。
- 正文是否按文件或内容块归纳。
@@ -0,0 +1,66 @@
---
name: header-comment-sync
description: 在本仓库创建或修改 ts、tsx、js、jsx、mjs、cjs、vue、astro 文件时使用,保持中文文件头、导出声明、复杂逻辑和风险边界注释准确。
---
# 注释规范与同步
## 目标
让下一次进入文件的维护者能快速理解当前职责、关键约束和风险边界。注释解释“为什么”和“边界”,不复述代码表面行为。
## 适用场景
- 新增或修改页面入口、Three.js 场景、动效编排、渲染、数据、工具或脚本文件。
- 修改滚动时间线、WebGL shader、响应式布局、性能降级、 reduced-motion 或首屏渲染逻辑。
- 发现旧注释与当前代码行为不一致。
## 核心原则
- 注释使用中文,描述当前事实。
- 简单局部变量不强行注释。
- 文件头说明文件当前职责,1 到 2 行即可。
- 导出的函数、类、类型、常量、配置对象和复杂模块方法应有用途说明。
- 复杂动画相位、shader uniform、滚动联动、性能降级和浏览器兼容边界需要短注释。
- TODO/FIXME 必须说明触发条件、剩余动作和可删除条件。
## 文件头规则
- 每个适用文件都要有准确文件头。
- 如果文件必须以 `"use client"``"use server"` 开头,文件头注释放在指令之后、导入之前。
- Vue 文件不为了补头注释重排模板结构;在 `<script>``<script setup>` 顶部补当前职责说明。
- 文件头不要写“本文件用于...”这类空话,直接说明业务角色。
```js
/**
* 装配作品集滚动叙事、WebGL 背景和移动端性能降级策略。
*/
```
## JSDoc 与局部注释
- 导出的函数、类、类型、常量和配置对象优先使用 JSDoc。
- 非导出但复杂的解析、格式化、请求构造、动画相位派生、回调工厂也要补。
- 只在局部逻辑确实有约束时使用行内注释,例如性能降级、兼容字段、shader 参数和临时迁移。
- 不要在每一行、简单 DOM 拼装、短生命周期变量上堆注释。
## 触达文件补齐
- 不要求为了注释规范单独全仓扫描。
- 只要本次任务修改了适用文件,就顺手补齐明显缺失或过时的文件头、导出声明、共享类型、复杂回调和协议注释。
- 大文件可按触达区域优先,但同文件内裸露的顶层导出和共享类型应一起补。
- 如果一次补齐整个大文件会明显超出需求范围,至少补齐当前需求链路和顶层声明,并在交付说明里说明剩余范围。
## 不推荐的写法
- 注释只写“处理数据”“点击事件”这种无信息内容。
- 注释描述旧方案,和当前代码矛盾。
- 为简单 DOM 拼装、简单赋值、短生命周期变量写注释。
- 用英文口号、emoji 或情绪化标记替代项目内中文说明。
## 落地检查
- 修改后的文件头是否准确。
- 新增/修改的导出声明是否有必要说明。
- 复杂逻辑是否解释了约束而不是复述代码。
- 旧注释是否仍然可信。
+17
View File
@@ -0,0 +1,17 @@
# my-resume Agent 入口
## 项目规则
- 使用当前 Vite + Three.js + 原生 JavaScript 结构,不引入无关框架。
- 页面内容、计划文档和交付说明优先使用中文。
- 改动可运行入口、目录或重要文件时,同步更新 `README.md`
## 必用 Skill
- 创建或修改 `ts``tsx``js``jsx``mjs``cjs``vue``astro` 文件时,使用 `./.agents/skills/header-comment-sync/SKILL.md`
- 执行 `git commit`、整理提交说明或准备推送时,使用 `./.agents/skills/chinese-commit-message/SKILL.md`
## 本地规则
- 先读 `.agents/README.md`,再按任务读取匹配的 skill。
- skill 的触达文件补齐只处理本次修改相关文件,不为单次任务全仓扫描。
+7
View File
@@ -11,3 +11,10 @@ npm run build
``` ```
The page uses a restrained scroll-aware WebGL backdrop, critical first-paint styling, grounded resume summaries, responsive layout, subtle scroll reveals, project cards, skills, experience, and contact sections. The page uses a restrained scroll-aware WebGL backdrop, critical first-paint styling, grounded resume summaries, responsive layout, subtle scroll reveals, project cards, skills, experience, and contact sections.
## Agent Notes
- `AGENTS.md`: Codex/Agent 入口规则。
- `.agents/README.md`: 本项目 Agent skill 索引和使用入口。
- `.agents/skills/chinese-commit-message/SKILL.md`: 中文提交信息规范。
- `.agents/skills/header-comment-sync/SKILL.md`: 中文文件头和注释同步规范。
@@ -0,0 +1,87 @@
# 设计文档:「与湛兮(花名)的数字思维体对话」作品集重设计
日期:2026-06-11
状态:已批准
备份:`../wang-yuanyou-fluid-portfolio-backup-20260611-110154/`
## 概念
整站是访客与「湛兮(花名)的 AI 分身」的一场对话记录。简历核心(最近一年做 SSE 流式对话、打字机渲染、思考态动画)即设计语言来源——网站本身就是其工作成果的活演示。3D 流体 =「思维体」,是画面中唯一的角色。
决策记录:
- 设计隐喻:AI 对话流(vs 职业河流 / 跨端粒子宇宙)
- 推进方式:滚动驱动的对话(vs 真·对话界面 / 滚动+问答入口)
- 流体形态:单一思维流体 + 三状态状态机(vs raymarched metaballs / GPGPU 粒子流)
- 视觉基调:深色 AI 实验室(vs 浅色编辑风 / 双主题)
- 技术路线:原生 Three.js 自写 shader + GSAP ScrollTrigger + Lenisvs 纯手写 / R3F
## 信息架构:7 轮问答
| # | 访客提问 | 回答内容 | 数据源 |
|---|---|---|---|
| 0 | —(开机自检) | 「你好,我是湛兮(花名)。」+ 身份一行 + 6 指标 token 式吐出 | metrics |
| 1 | 先介绍一下你自己? | 4 条概述逐行流式输出 | resumeSignals |
| 2 | 最近一年具体在做什么? | 4 张焦点卡依次「生成」 | focusAreas |
| 3 | 有实际项目证明吗? | 4 个项目,各为一轮子对话:项目名打字机 → 模块逐条吐出 | projects |
| 4 | 技能栈展开讲讲? | 7 组技能标签 token 流喷发归位 | skills |
| 5 | 之前的团队经历? | 3 段经历沿垂直对话流时间线生成 | experiences |
| 6 | 怎么联系你? | 联系方式 + 流体归于平静,「对话已保存」收尾 | — |
页面右侧(移动端顶部)设「会话进度轨」:7 节点对应 7 轮,可点击跳转,保证 HR 快速扫读。
## 思维流体(Three.js 自写 shader
- 几何:高细分 IcosahedronGeometry;顶点着色器 3 层 FBM simplex 噪声液态位移
- 片元:Fresnel 边缘辉光 + 双色渐变(青 #22d3ee ↔ 紫 #a78bfa+ 加性内核光晕
- 伴生粒子:约 2000 GPU 粒子,curl noise 流场
- 状态机(uniform 插值过渡):
- `idle`:低频低幅呼吸,粒子懒散环绕
- `thinking`:收缩 0.85x,噪声频率 ×3 搅动,色温升高,粒子吸入
- `answering`:回弹 1.1x 归位,粒子向内容区喷发消散,辉光脉冲与文字生成同步
- 流体位置随轮次左右缓移(lerp)与内容互让;鼠标轻微视差
## 非线性动画编排(GSAP ScrollTrigger + Lenis
每轮问答为一个 pin 区段,滚动量映射轮内时间轴:
1. 提问(0→15%):访客气泡逐字打出,steps() 离散节奏
2. 思考(15→35%):流体 thinking,内容区仅 shimmer 占位——刻意停顿即非线性核心
3. 爆发生成(35→75%):expo.out 爆发;标题打字机、卡片不等间隔 stagger(模拟 token 不均匀到达)、数字滚动跳变
4. 余韵(75→100%):流体回 idle,内容微视差上浮,解除 pin
每轮思考时长 / 爆发曲线 / stagger 间隔均不同(场景配置驱动),避免节奏雷同。向上滚动时时间轴反播,内容「被收回」。
降级:`prefers-reduced-motion` → 关 pin 与打字机、内容直出、流体仅呼吸;移动端粒子减半、DPR ≤ 2、细分降档。
## 视觉系统
- 背景 #070b14 近黑蓝 + 极淡网格点阵 + 流体环境光溢出
- 强调色:青 #22d3ee(访客/交互)、紫 #a78bfa(思维体/回答)、琥珀 #fbbf24(仅指标数字)
- 气泡语言:提问 = 右对齐细边框气泡;回答 = 无框流式文本块 + 左侧渐变「生成光标」竖线
- 字体:等宽(JetBrains Mono / 思源等宽回退)用于指标、token、技能标签;正文 Inter + 思源黑体
- 细节:回答块尾闪烁光标 ▋;项目 logo 复用 public/logos/
## 文件结构
```
src/
main.js # 入口:装配 + Lenis + 进度轨
resume-data.js # 不动(数据即简历)
dialogue.js # 7 轮问答文案与节奏配置
render.js # 由 resume-data 生成各轮 DOM
choreography.js # GSAP ScrollTrigger 时间轴编排
mind/
mind.js # 流体场景、状态机、粒子
shaders.js # GLSL
styles.css # 重写
index.html # 重写骨架
```
新依赖:gsap、lenis。重写 main.js / styles.css / index.html(旧版已整目录备份)。
## 性能与验收
- 单 canvas 固定底层;rAF 与 GSAP ticker 合并;目标桌面 60fps、移动 30fps+
- 文字全部真实 DOM(可选中/可索引),打字机仅控制 reveal;进度轨 + 锚点保证可跳转
- 验收:7 轮完整走查(含回滚反播)、375px 移动端、reduced-motion 降级、Lighthouse 不低于现版本
@@ -0,0 +1,56 @@
# 「数字思维体对话」作品集重设计 实现计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 将现有作品集重写为「与湛兮(花名)的 AI 分身对话」式单页站点:滚动驱动的 7 轮问答 + shader 思维流体状态机。
**Architecture:** Vite 原生 JS。`resume-data.js` 不动;`dialogue.js` 定义 7 轮问答节奏配置;`render.js` 生成 DOM`mind/` 为 Three.js shader 流体(idle/thinking/answering 状态机 + GPU 粒子);`choreography.js` 用 GSAP ScrollTrigger pin+scrub 编排每轮「提问→思考→爆发生成→余韵」;Lenis 平滑滚动。
**Tech Stack:** Vite 7, Three.js 0.184, GSAP ScrollTrigger, Lenis, 自写 GLSLFBM simplex + Fresnel)。
**设计文档:** `docs/plans/2026-06-11-ai-dialogue-redesign-design.md`(已批准)
**备份:** `../wang-yuanyou-fluid-portfolio-backup-20260611-110154/`
---
### Task 1: 依赖安装
- `pnpm add gsap lenis`
- 验证: package.json 出现两依赖,`pnpm dev` 可启动。
### Task 2: `src/dialogue.js` — 轮次配置
- 7 轮:boot / intro(resumeSignals) / focus(focusAreas) / projects / skills / experience / contact。
- 每轮字段:id、label(进度轨)、question、type、side(流体停靠 -1/1)、palette(双色)、pinLength、qEnd/thinkEnd(相位)、staggerEach——逐轮不同以保证节奏差异。
- 联系信息常量:邮箱 419021733@qq.com / wmagmgema521@gmail.com、电话 19980439383、GitHub zhanBoss、男·29岁·电子科技大学 信息管理与信息系统 本科。
### Task 3: `index.html` — 骨架重写
- canvas#mind-canvasfixed 底层)、.grid-overlay、header.hud(左:姓名/DIGITAL MIND;右:● ONLINE 状态灯)、nav#session-rail、main#dialogue(空,由 render.js 填充)、Google FontsJetBrains Mono + Inter, display=swap)。
### Task 4: `src/styles.css` — 深色 AI 实验室
- 背景 #070b14、点阵网格 overlay、青 #22d3ee / 紫 #a78bfa / 琥珀 #fbbf24
- 气泡语言:.q-bubble 右对齐细边框;.answer 左侧渐变生成竖线 + 尾部 ▋ 闪烁光标;token/指标/技能用等宽字体。
- 各轮内容样式:metrics token、focus 卡、project 块、skill 标签云、timeline、contact。
- 进度轨右侧固定(移动端转顶部水平);≤768px 移动端布局;prefers-reduced-motion 关闭闪烁。
### Task 5: `src/render.js` — DOM 生成
- 每轮 section.round 结构:q-bubble(空文本,编排时打字)→ .thinking(三点 shimmer)→ .answer.gen 子项供 stagger)。
- 按 type 分发渲染器,全部读 resume-data.jsboot 轮含问候打字行 + 身份行 + metrics token。
- 文案全部真实 DOM(初始由 CSS/GSAP 隐藏,reduced-motion 时直接可见)。
### Task 6: `src/mind/shaders.js` + `src/mind/mind.js` — 思维流体
- 顶点:Ashima simplex 3D + 3 octave FBM 位移;片元:Fresnel 辉光 + uColorA/uColorB 渐变 + uHeat 色温 + AdditiveBlending。
- 粒子:~2000(移动端 900),属性 seed/radius/speed/phase,轨道半径受 uAttract(吸入)与 uBurst(喷发,setState('answering') 时置 1 后衰减)控制,全 GPU。
- 状态机 STATES{idle, thinking, answering},每帧 lerp uniformssetSide(dir) 左右停靠(移动端居中缩小);setPalette 双色渐变;指针视差;DPR≤2resize。
### Task 7: `src/choreography.js` — 滚动编排
- Lenis + ScrollTrigger 集成(gsap.ticker 驱动)。
- boot 轮:页面加载后时间驱动的开场时间轴(非滚动)。
- 轮 1-6pin + scrub 时间轴,相位 0→qEnd 打字(steps 离散)→thinkEnd 思考 shimmer→0.78 爆发 staggerexpo.out,不等间隔)→1 余韵视差;onUpdate 按相位切 mind 状态;enter/enterBack 切 side+palette。
- 进度轨高亮与 lenis.scrollTo 跳转。
- reduced-motion:跳过 pin/打字机,内容直出。
### Task 8: `src/main.js` — 装配
- 渲染 DOM → 建 rail → createMind → choreography → boot 开场。
### Task 9: 验证
- `pnpm dev` + Playwright:桌面 1440px 走查 7 轮(截图 hero/中段/尾段)、向上回滚反播、375px 移动端、console 无错误。
- `pnpm build` 成功。
+22 -176
View File
@@ -1,195 +1,41 @@
<!doctype html> <!doctype html>
<html lang="zh-CN" data-theme="dark"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>湛兮(花名) · 数字思维体 / Digital Mind</title>
<meta <meta
name="description" name="description"
content="王元有个人网站,前端工程师,7 年 Web 与跨端经验,近一年参与 SeaBuzz、SeaCloud 等 AI 产品前端,覆盖对话、内容流、计费、权限和国际化。" content="湛兮(花名) — 7 年 Web 与跨端前端工程师。一场与他的数字思维体的对话:AI 对话链路、控制台业务、跨端内容流与工程实践。"
/> />
<script>
(() => {
const saved = localStorage.getItem("wy-fluid-theme");
if (saved) document.documentElement.dataset.theme = saved;
})();
</script>
<style>
:root {
background: #07080d;
color: #f5f7fb;
color-scheme: dark;
}
html[data-theme="light"] {
background: #f2f5f7;
color: #11131a;
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
background: inherit;
color: inherit;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
"Microsoft YaHei", sans-serif;
}
</style>
<link rel="stylesheet" href="/src/styles.css" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>王元有 - 前端工程师</title> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/src/styles.css" />
</head> </head>
<body> <body>
<div class="progress" id="scroll-progress"></div> <canvas id="mind-canvas" aria-hidden="true"></canvas>
<canvas id="fluid-canvas" aria-hidden="true"></canvas> <div class="grid-overlay" aria-hidden="true"></div>
<div class="scene-backdrop" id="scene-backdrop" aria-hidden="true"></div>
<div class="grain" aria-hidden="true"></div>
<header class="site-header"> <header class="hud">
<a class="brand-mark" href="#top" aria-label="王元有个人网站首页"> <div class="hud-id">
<span>WY</span> <span class="hud-name">ZHAN XI</span>
<strong>王元有</strong> <span class="hud-sub">DIGITAL MIND · v7.0</span>
</a> </div>
<nav class="site-nav" aria-label="页面导航"> <div class="hud-status">
<a href="#ai-fit">方向</a> <span class="status-dot" aria-hidden="true"></span>
<a href="#proof">证据</a> <span class="status-text">ONLINE</span>
<a href="#projects">项目</a>
<a href="#skills">能力</a>
<a href="#experience">经历</a>
<a href="#contact">联系</a>
</nav>
<div class="header-actions">
<a class="icon-link" href="https://github.com/zhanBoss" target="_blank" rel="noreferrer" aria-label="GitHub">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.58 2 12.23c0 4.52 2.87 8.35 6.84 9.7.5.1.68-.22.68-.5v-1.75c-2.78.62-3.37-1.37-3.37-1.37-.45-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.56 2.35 1.11 2.92.85.09-.66.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.35 9.35 0 0 1 12 6.9c.85 0 1.71.12 2.51.34 1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.32.68.94.68 1.9v2.82c0 .28.18.6.69.5A10.08 10.08 0 0 0 22 12.23C22 6.58 17.52 2 12 2Z"
></path>
</svg>
</a>
<button class="icon-link" id="theme-toggle" type="button" aria-label="切换明暗主题">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M12 2a10 10 0 1 0 0 20V2Zm2 2.29A8 8 0 0 1 14 19.71V4.29Z"></path>
</svg>
</button>
</div> </div>
</header> </header>
<main id="top"> <nav id="session-rail" aria-label="会话进度"></nav>
<section class="hero" aria-labelledby="hero-title">
<div class="hero-meta reveal" data-drift="-24">
<p class="eyebrow">Frontend Engineer / Product UI / Cross-platform</p>
<h1 id="hero-title">王元有</h1>
<p class="hero-subtitle">前端工程师,近一年在 AI 产品团队做前端</p>
<p class="hero-lede">
做过 B 端供应链系统、小程序、微前端平台,也参与过 SeaBuzz 和 SeaCloud 这类 AI 产品。现在更擅长把对话流、
内容流、计费、权限、国际化这些复杂链路整理成稳定的前端界面。
</p>
<div class="hero-actions">
<a class="button primary" href="#projects">看项目经历</a>
<a class="button" href="mailto:419021733@qq.com">419021733@qq.com</a>
</div>
</div>
<div class="signal-board reveal" data-drift="28" aria-label="候选人摘要"> <main id="dialogue"></main>
<div>
<span>前端经验</span>
<strong>7 年</strong>
</div>
<div>
<span>近一年方向</span>
<strong>对话与控制台</strong>
</div>
<div>
<span>主要场景</span>
<strong>对话 / 内容 / 控制台</strong>
</div>
<div>
<span>协作方式</span>
<strong>能独立推进模块</strong>
</div>
</div>
<div class="profile-panel reveal" data-drift="20" aria-label="工作方式摘要">
<div>
<span>最近职责</span>
<strong>SeaBuzz 跨端应用、SeaCloud 控制台</strong>
</div>
<div>
<span>更擅长</span>
<strong>把复杂状态、权限和数据边界梳理清楚</strong>
</div>
<div>
<span>常用技术</span>
<strong>React / Next.js / React Native / TypeScript</strong>
</div>
<div>
<span>工作习惯</span>
<strong>先对齐业务边界,再拆组件和状态模型</strong>
</div>
</div>
</section>
<section id="ai-fit" class="ai-fit-section reveal" data-drift="36" aria-labelledby="ai-fit-title">
<div class="section-heading">
<p>Current Direction</p>
<h2 id="ai-fit-title">AI 是最近一年的工作场景,不是拿来堆关键词的标签。</h2>
</div>
<div class="ai-focus-grid" id="ai-focus-grid"></div>
<div class="resume-signal-panel reveal" data-drift="-30">
<div>
<p>Resume Summary</p>
<h3>前端工程师|AI 产品方向|Web / React Native</h3>
</div>
<ul id="resume-signal-list"></ul>
</div>
</section>
<section id="proof" class="proof-strip reveal" data-drift="-40" aria-labelledby="proof-title">
<div class="section-kicker">Evidence</div>
<h2 id="proof-title">一些可以核对的经历,先放在前面。</h2>
<div class="metric-row" id="metric-row"></div>
</section>
<section id="projects" class="work-section" aria-labelledby="projects-title">
<div class="section-heading reveal" data-drift="42">
<p>Selected Work</p>
<h2 id="projects-title">最近两段经历和 AI 产品相关,但履历底子仍然是前端工程交付。</h2>
</div>
<div class="project-stack" id="project-stack"></div>
</section>
<section id="skills" class="skills-section" aria-labelledby="skills-title">
<div class="section-heading reveal" data-drift="-42">
<p>Capabilities</p>
<h2 id="skills-title">按实际做过的工作来组织技能,而不是按热门词来分类。</h2>
</div>
<div class="skill-matrix" id="skill-matrix"></div>
</section>
<section id="experience" class="experience-section" aria-labelledby="experience-title">
<div class="section-heading reveal" data-drift="34">
<p>Experience</p>
<h2 id="experience-title">职责从核心交付演进到架构设计和团队规范建设。</h2>
</div>
<div class="timeline" id="timeline"></div>
</section>
<section id="contact" class="contact-section reveal" data-drift="-26" aria-labelledby="contact-title">
<div>
<p class="section-kicker">Contact</p>
<h2 id="contact-title">正在看前端、跨端、AI 产品前端相关机会。</h2>
<span>男 · 29 岁 · 19980439383 · 电子科技大学 信息管理与信息系统 本科</span>
</div>
<div class="contact-actions">
<a class="button primary" href="mailto:419021733@qq.com">发送邮件</a>
<a class="button" href="mailto:wmagmgema521@gmail.com">wmagmgema521@gmail.com</a>
</div>
</section>
</main>
<footer>© 2026 王元有. Built as an independent fluid portfolio.</footer>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>
+40 -2
View File
@@ -8,10 +8,11 @@
"name": "wang-yuanyou-fluid-portfolio", "name": "wang-yuanyou-fluid-portfolio",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"gsap": "^3.15.0",
"lenis": "^1.3.23",
"three": "^0.184.0", "three": "^0.184.0",
"vite": "^7.2.7" "vite": "^7.2.7"
}, }
"devDependencies": {}
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7", "version": "0.27.7",
@@ -832,6 +833,43 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/lenis": {
"version": "1.3.23",
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
"integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==",
"license": "MIT",
"workspaces": [
"packages/*",
"playground",
"playground/*"
],
"funding": {
"type": "github",
"url": "https://github.com/sponsors/darkroomengineering"
},
"peerDependencies": {
"@nuxt/kit": ">=3.0.0",
"react": ">=17.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+5 -2
View File
@@ -6,11 +6,14 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"build:test": "PUBLIC_APP_ENV_LABEL=测试环境 vite build",
"build:prod": "PUBLIC_APP_ENV_LABEL=生产环境 vite build",
"preview": "vite preview --host 0.0.0.0" "preview": "vite preview --host 0.0.0.0"
}, },
"dependencies": { "dependencies": {
"gsap": "^3.15.0",
"lenis": "^1.3.23",
"three": "^0.184.0", "three": "^0.184.0",
"vite": "^7.2.7" "vite": "^7.2.7"
}, }
"devDependencies": {}
} }
+27
View File
@@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
gsap:
specifier: ^3.15.0
version: 3.15.0
lenis:
specifier: ^1.3.23
version: 1.3.23
three: three:
specifier: ^0.184.0 specifier: ^0.184.0
version: 0.184.0 version: 0.184.0
@@ -320,6 +326,23 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
lenis@1.3.23:
resolution: {integrity: sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
vue: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
react:
optional: true
vue:
optional: true
nanoid@3.3.12: nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -585,6 +608,10 @@ snapshots:
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
gsap@3.15.0: {}
lenis@1.3.23: {}
nanoid@3.3.12: {} nanoid@3.3.12: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
+301
View File
@@ -0,0 +1,301 @@
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
import { rounds } from "./dialogue";
gsap.registerPlugin(ScrollTrigger);
/* Lenis 平滑滚动 + ScrollTrigger 集成 */
export function initSmoothScroll() {
const lenis = new Lenis({ lerp: 0.1 });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
return lenis;
}
/* 打字机:时间轴上的离散字符 reveal(时间驱动,进入视口后完整播放) */
function addTypewriter(tl, textEl, caretEl, fullText, start, duration) {
const proxy = { p: 0 };
tl.to(
proxy,
{
p: 1,
duration,
ease: `steps(${fullText.length})`,
onUpdate() {
const n = Math.round(proxy.p * fullText.length);
textEl.textContent = fullText.slice(0, n);
if (caretEl) caretEl.style.opacity = proxy.p >= 1 ? "0" : "1";
},
},
start
);
}
/* 根据文案长度推导打字时长,保持各轮节奏差异 */
const typingDuration = (text) => Math.min(1.5, 0.34 + text.length * 0.07);
/* 开场(时间驱动,非滚动)*/
export function playBoot(mind) {
const boot = document.querySelector("#r-boot");
const typedLine = boot.querySelector(".typed-line");
const items = boot.querySelectorAll(".gen");
gsap.set(boot.querySelector(".answer"), { opacity: 1 });
gsap.set(items, { y: 26, autoAlpha: 0 });
const text = "这是我的数字思维体,向它提问即可。";
const proxy = { p: 0 };
const tl = gsap.timeline({ delay: 0.3 });
tl.call(() => mind.setState("thinking"))
.to(proxy, {
p: 1,
duration: 1.4,
ease: `steps(${text.length})`,
onUpdate: () => {
typedLine.textContent = text.slice(0, Math.round(proxy.p * text.length));
},
}, 0.5)
.call(() => mind.setState("answering"), null, 1.9)
.to(items, {
y: 0,
autoAlpha: 1,
duration: 0.7,
ease: "expo.out",
stagger: { each: 0.09, from: "start" },
}, 2.0)
.call(() => mind.setState("idle"), null, 3.4);
return tl;
}
/* 单轮问答时间轴:提问打字 → 思考 → 回答爆发生成(一次性播放) */
function buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind }) {
const typeDur = typingDuration(round.question);
const tl = gsap.timeline({ paused: true });
// 1. 提问打字
addTypewriter(tl, qText, qCaret, round.question, 0, typeDur);
// 2. 思考 shimmer(刻意停顿 = 非线性蓄力)
tl.call(() => mind.setState("thinking"), null, typeDur)
.to(thinking, { opacity: 1, duration: 0.22 }, typeDur)
.to({}, { duration: 0.5 })
.to(thinking, { opacity: 0, duration: 0.18 })
// 3. 爆发生成:不等间隔 stagger 模拟 token 到达
.call(() => mind.setState("answering"))
.to(answer, { opacity: 1, duration: 0.2 }, "<")
.to(
items,
{
y: 0,
autoAlpha: 1,
duration: 0.7,
ease: "expo.out",
stagger: { each: Math.max(0.05, round.staggerEach), from: "start" },
},
"<0.05"
)
.call(() => mind.setState("idle"), null, "+=0.4");
return tl;
}
/* 轮次编排:进入视口即播放(不再依赖滚动 scrub,修复"不滚动则不加载" */
export function initChoreography(mind, setActive = () => {}) {
rounds
.filter((r) => r.question)
.forEach((round) => {
const section = document.getElementById(`r-${round.id}`);
const qText = section.querySelector(".q-text");
const qCaret = section.querySelector(".q-caret");
const thinking = section.querySelector(".thinking");
const answer = section.querySelector(".answer");
const items = section.querySelectorAll(".gen");
gsap.set(items, { y: 34, autoAlpha: 0 });
if (round.flow) {
buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive });
return;
}
const tl = buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind });
ScrollTrigger.create({
trigger: section,
start: "top 60%",
end: "bottom 40%",
onToggle(self) {
if (self.isActive) setActive(round.id);
},
onEnter: () => {
mind.setSide(round.side);
mind.setPalette(round.palette);
tl.play();
},
onEnterBack: () => {
mind.setSide(round.side);
mind.setPalette(round.palette);
},
onLeave: () => mind.setState("idle"),
onLeaveBack: () => mind.setState("idle"),
});
});
}
/* 长内容轮:提问/思考时间驱动一次播放,内容块各自进入视口时「生成」 */
function buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive }) {
gsap.set(answer, { opacity: 1 });
const typeDur = typingDuration(round.question);
const qTl = gsap.timeline({ paused: true });
addTypewriter(qTl, qText, qCaret, round.question, 0, typeDur);
qTl.to(thinking, { opacity: 1, duration: 0.2 }, typeDur)
.to(thinking, { opacity: 0, duration: 0.2 }, "+=0.5");
ScrollTrigger.create({
trigger: section,
start: "top 70%",
once: true,
onEnter: () => qTl.play(),
});
items.forEach((item, i) => {
gsap.to(item, {
y: 0,
autoAlpha: 1,
duration: 0.8,
delay: (i % 3) * 0.09, // 不等间隔,模拟 token 到达
ease: "expo.out",
scrollTrigger: { trigger: item, start: "top 88%" },
});
});
ScrollTrigger.create({
trigger: section,
start: "top 60%",
end: "bottom 55%",
onToggle(self) {
if (self.isActive) {
setActive(round.id);
mind.setSide(round.side);
mind.setPalette(round.palette);
} else {
mind.setState("idle");
}
},
onUpdate(self) {
mind.setState(self.progress < 0.1 ? "thinking" : "answering");
},
});
}
/* 进度轨:高亮由编排回调驱动,这里只负责节点与跳转 */
export function initRail(lenis) {
const setActive = (id) => {
document.querySelectorAll(".rail-node").forEach((n) => {
n.classList.toggle("active", n.dataset.round === id);
});
};
ScrollTrigger.create({
trigger: "#r-boot",
start: "top top",
end: "bottom 40%",
onToggle(self) {
if (self.isActive) setActive("boot");
},
});
const jump = (id) => {
const target = document.getElementById(`r-${id}`);
if (!target) return;
if (lenis) lenis.scrollTo(target, { offset: 2, duration: 1.2 });
else target.scrollIntoView({ behavior: "smooth" });
};
return { jump, setActive };
}
/* 模块吸附:滚动停止后,若停在两轮之间的中间态,自动滚动到目标轮顶部,
再由 onEnter 正常触发打字机动画(修复"滚不到位则动画错位" */
export function initRoundSnap(lenis) {
const sections = rounds
.map((round) => document.getElementById(`r-${round.id}`))
.filter(Boolean);
if (!sections.length) return () => {};
let timer = 0;
let lockUntil = 0;
const readY = () => (typeof lenis?.scroll === "number" ? lenis.scroll : window.scrollY);
const snapTo = (section) => {
lockUntil = performance.now() + 700;
const targetY = section.getBoundingClientRect().top + readY();
if (lenis) {
lenis.scrollTo(targetY, {
duration: 0.55,
easing: (t) => 1 - Math.pow(1 - t, 3),
});
} else {
window.scrollTo({ top: targetY, behavior: "smooth" });
}
};
const settle = () => {
if (performance.now() < lockUntil) return;
const vh = window.innerHeight || document.documentElement.clientHeight;
for (const section of sections) {
const top = section.getBoundingClientRect().top;
// 已有模块对齐视口顶部 → 状态正常,无需吸附
if (Math.abs(top) < 8) return;
// 模块露出一半(未到打字机区域)→ 吸附进入该模块
const halfEntered = top > vh * 0.12 && top < vh * 0.72;
// 模块占据主屏但未对齐 → 吸附回正
const nearlyAligned = top > -vh * 0.45 && top <= vh * 0.12;
if (halfEntered || nearlyAligned) {
snapTo(section);
return;
}
}
// 长内容轮(flow)内部深处不匹配任何区间 → 自由滚动,不干预
};
const onScroll = () => {
window.clearTimeout(timer);
// lenis 惯性收尾阶段速度趋零:立即判定吸附,不等事件完全静止
const velocity =
lenis && typeof lenis.velocity === "number" ? Math.abs(lenis.velocity) : null;
if (velocity !== null && velocity < 0.3) {
settle();
return;
}
timer = window.setTimeout(settle, 90);
};
if (lenis) lenis.on("scroll", onScroll);
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.clearTimeout(timer);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}
/* 降级:reduced motion 直接展示全部内容 */
export function applyReducedMotion() {
rounds
.filter((r) => r.question)
.forEach((round) => {
const section = document.getElementById(`r-${round.id}`);
section.querySelector(".q-text").textContent = round.question;
const answer = section.querySelector(".answer");
answer.style.opacity = "1";
});
const boot = document.querySelector("#r-boot");
boot.querySelector(".typed-line").textContent = "这是我的数字思维体。";
document.querySelectorAll(".gen").forEach((n) => (n.style.opacity = "1"));
}
+73
View File
@@ -0,0 +1,73 @@
// 7 轮问答的文案与节奏配置 —— 每轮节奏参数刻意不同,避免线性重复
export const contact = {
emails: ["419021733@qq.com", "wmagmgema521@gmail.com"],
phone: "19980439383",
github: "https://github.com/zhanBoss",
identity: "男 · 29 岁 · 电子科技大学 信息管理与信息系统 本科",
};
export const rounds = [
{
id: "boot",
label: "BOOT",
type: "boot",
question: null,
side: 1,
palette: ["#22d3ee", "#a78bfa"],
},
{
id: "intro",
label: "WHO",
type: "signals",
question: "先介绍一下你自己?",
side: -1,
palette: ["#2dd4bf", "#a78bfa"],
staggerEach: 0.055,
},
{
id: "focus",
label: "NOW",
type: "focus",
question: "最近一年具体在做什么?",
side: 1,
palette: ["#22d3ee", "#c084fc"],
staggerEach: 0.085,
},
{
id: "projects",
label: "PROOF",
type: "projects",
question: "有实际项目证明吗?",
side: -1,
palette: ["#38bdf8", "#a78bfa"],
flow: true, // 内容超过一屏:逐块流式生成
staggerEach: 0.045,
},
{
id: "skills",
label: "STACK",
type: "skills",
question: "技能栈展开讲讲?",
side: 1,
palette: ["#67e8f9", "#8b5cf6"],
staggerEach: 0.02,
},
{
id: "experience",
label: "PATH",
type: "experience",
question: "之前的团队经历?",
side: -1,
palette: ["#5eead4", "#a78bfa"],
staggerEach: 0.1,
},
{
id: "contact",
label: "PING",
type: "contact",
question: "怎么联系你?",
side: 0,
palette: ["#22d3ee", "#f0abfc"],
staggerEach: 0.09,
},
];
+30 -663
View File
@@ -1,669 +1,36 @@
import * as THREE from "three"; import gsap from "gsap";
import { experiences, focusAreas, metrics, projects, resumeSignals, skills } from "./resume-data"; import { renderDialogue, renderRail } from "./render";
import { createMind } from "./mind/mind";
import {
applyReducedMotion,
initChoreography,
initRail,
initRoundSnap,
initSmoothScroll,
playBoot,
} from "./choreography";
const $ = (selector) => document.querySelector(selector); const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const $$ = (selector) => [...document.querySelectorAll(selector)];
const metricRow = $("#metric-row"); /* 1. 由简历数据生成对话 DOM */
const aiFocusGrid = $("#ai-focus-grid"); renderDialogue(document.getElementById("dialogue"));
const resumeSignalList = $("#resume-signal-list");
const projectStack = $("#project-stack");
const skillMatrix = $("#skill-matrix");
const timeline = $("#timeline");
const progress = $("#scroll-progress");
const themeToggle = $("#theme-toggle");
const root = document.documentElement;
const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value)); /* 2. 思维流体 */
const lerp = (from, to, amount) => from + (to - from) * amount; const mind = createMind(document.getElementById("mind-canvas"), { reduced });
const smoothstep = (edge0, edge1, value) => { window.__mind = mind;
const t = clamp((value - edge0) / (edge1 - edge0)); gsap.ticker.add(() => mind.tick());
return t * t * (3 - 2 * t);
};
const fluidScenes = [ /* 3. 进度轨 + 滚动编排 */
{ let lenis = null;
id: "top", if (!reduced) lenis = initSmoothScroll();
name: "hero",
colors: ["#64cbb8", "#8aa4d6", "#d77b91"],
desktop: { x: 2.05, y: 0.22, z: -0.78, scale: 1.08 },
compact: { x: 1.34, y: 1.14, z: -1.08, scale: 0.38 },
flow: 0.62,
twist: 0.48,
ring: 0.82,
particle: 0.78,
angle: 128,
},
{
id: "ai-fit",
name: "ai-fit",
colors: ["#74c8bd", "#8097d4", "#d4b56c"],
desktop: { x: 1.84, y: 0.08, z: -0.88, scale: 0.96 },
compact: { x: 1.18, y: 1.05, z: -1.14, scale: 0.34 },
flow: 0.48,
twist: 0.72,
ring: 0.62,
particle: 0.58,
angle: 154,
},
{
id: "proof",
name: "proof",
colors: ["#d4b56c", "#6dc4b1", "#c97a8d"],
desktop: { x: 2.22, y: -0.04, z: -0.84, scale: 0.9 },
compact: { x: 1.48, y: 1.0, z: -1.18, scale: 0.31 },
flow: 0.36,
twist: 0.34,
ring: 0.92,
particle: 0.52,
angle: 96,
},
{
id: "projects",
name: "projects",
colors: ["#889fd4", "#c97991", "#68c7b2"],
desktop: { x: 1.68, y: 0.26, z: -0.7, scale: 1.18 },
compact: { x: 1.22, y: 1.18, z: -1.02, scale: 0.4 },
flow: 0.82,
twist: 0.92,
ring: 0.78,
particle: 0.9,
angle: 202,
},
{
id: "skills",
name: "skills",
colors: ["#75bd9a", "#d1b46d", "#879dd0"],
desktop: { x: 2.3, y: 0.04, z: -0.96, scale: 0.86 },
compact: { x: 1.52, y: 1.08, z: -1.22, scale: 0.31 },
flow: 0.42,
twist: 0.56,
ring: 1.08,
particle: 0.46,
angle: 72,
},
{
id: "experience",
name: "experience",
colors: ["#9a8fcb", "#70beb0", "#849bd0"],
desktop: { x: 1.96, y: -0.1, z: -0.92, scale: 1.02 },
compact: { x: 1.36, y: 1.0, z: -1.14, scale: 0.36 },
flow: 0.3,
twist: 0.82,
ring: 0.72,
particle: 0.64,
angle: 232,
},
{
id: "contact",
name: "contact",
colors: ["#c97991", "#d0b56f", "#70bda8"],
desktop: { x: 1.46, y: 0.02, z: -0.66, scale: 1.24 },
compact: { x: 1.16, y: 1.14, z: -1.02, scale: 0.42 },
flow: 0.24,
twist: 0.28,
ring: 0.5,
particle: 0.42,
angle: 312,
},
].map((scene) => ({
...scene,
colorObjects: scene.colors.map((color) => new THREE.Color(color)),
}));
function getSceneFrame() { const rail = initRail(lenis);
const center = window.scrollY + window.innerHeight * 0.28; renderRail(document.getElementById("session-rail"), rail.jump);
const points = fluidScenes.map((scene) => ({
scene, if (!reduced) {
top: scene.id === "top" ? 0 : document.getElementById(scene.id)?.offsetTop ?? document.documentElement.scrollHeight, initChoreography(mind, rail.setActive);
})); initRoundSnap(lenis);
let index = 0; playBoot(mind);
for (let i = 0; i < points.length - 1; i += 1) { } else {
if (center >= points[i + 1].top) index = i + 1; applyReducedMotion();
}
const current = points[index];
const next = points[Math.min(points.length - 1, index + 1)];
const span = Math.max(1, next.top - current.top);
const blend = current === next ? 0 : smoothstep(0.18, 0.86, (center - current.top) / span);
return { current: current.scene, next: next.scene, blend };
} }
function mixScene(frame, compact) {
const layoutA = compact ? frame.current.compact : frame.current.desktop;
const layoutB = compact ? frame.next.compact : frame.next.desktop;
return {
name: frame.blend > 0.58 ? frame.next.name : frame.current.name,
x: lerp(layoutA.x, layoutB.x, frame.blend),
y: lerp(layoutA.y, layoutB.y, frame.blend),
z: lerp(layoutA.z, layoutB.z, frame.blend),
scale: lerp(layoutA.scale, layoutB.scale, frame.blend),
flow: lerp(frame.current.flow, frame.next.flow, frame.blend),
twist: lerp(frame.current.twist, frame.next.twist, frame.blend),
ring: lerp(frame.current.ring, frame.next.ring, frame.blend),
particle: lerp(frame.current.particle, frame.next.particle, frame.blend),
angle: lerp(frame.current.angle, frame.next.angle, frame.blend),
colors: frame.current.colorObjects.map((color, index) => color.clone().lerp(frame.next.colorObjects[index], frame.blend)),
};
}
function writeSceneCss(scene, scroll) {
root.dataset.scene = scene.name;
root.style.setProperty("--scene-a", scene.colors[0].getStyle());
root.style.setProperty("--scene-b", scene.colors[1].getStyle());
root.style.setProperty("--scene-c", scene.colors[2].getStyle());
root.style.setProperty("--scene-angle", `${scene.angle.toFixed(2)}deg`);
root.style.setProperty("--scene-opacity", (0.18 + scene.flow * 0.12).toFixed(3));
root.style.setProperty("--scene-drift-x", `${(-4 + scene.x * 1.4 - scroll * 3).toFixed(2)}vw`);
root.style.setProperty("--scene-drift-y", `${(-2 + scene.y * 2 + scroll * 2.5).toFixed(2)}vh`);
root.style.setProperty("--scene-rotate", `${(-1 + scene.twist * 2.5 + scroll * 3).toFixed(2)}deg`);
}
function renderMetrics() {
metricRow.innerHTML = metrics
.map((item, index) => `<article class="reveal" data-drift="${index % 2 === 0 ? 28 : -28}"><strong>${item.value}</strong><span>${item.label}</span></article>`)
.join("");
}
function renderFocusAreas() {
aiFocusGrid.innerHTML = focusAreas
.map(
(item, index) => `
<article class="ai-focus-card reveal" data-drift="${index % 2 === 0 ? -34 : 34}">
<span>${String(index + 1).padStart(2, "0")}</span>
<h3>${item.title}</h3>
<p>${item.summary}</p>
<div>${item.points.map((point) => `<em>${point}</em>`).join("")}</div>
</article>
`,
)
.join("");
}
function renderResumeSignals() {
resumeSignalList.innerHTML = resumeSignals.map((signal) => `<li>${signal}</li>`).join("");
}
function renderProjects() {
projectStack.innerHTML = projects
.map(
(project, index) => `
<article class="project-card reveal" data-drift="${index % 2 === 0 ? -52 : 52}">
<div class="project-index">
<span class="logo-frame"><img src="${project.logo}" alt="" loading="lazy" decoding="async" /></span>
<span>${String(index + 1).padStart(2, "0")}</span>
</div>
<div class="project-body">
<div class="project-head">
<div>
<h3>${project.name}</h3>
<p>${project.subtitle}</p>
</div>
<time>${project.period}</time>
</div>
<p class="project-summary">${project.summary}</p>
<ul>${project.modules.map((item) => `<li>${item}</li>`).join("")}</ul>
<div class="tech-row">${project.tech.map((item) => `<span>${item}</span>`).join("")}</div>
</div>
</article>
`,
)
.join("");
}
function renderSkills() {
skillMatrix.innerHTML = skills
.map(
(skill, index) => `
<article class="skill-group reveal" data-drift="${index % 3 === 0 ? -36 : index % 3 === 1 ? 18 : 42}">
<h3>${skill.group}</h3>
<div>${skill.items.map((item) => `<span>${item}</span>`).join("")}</div>
</article>
`,
)
.join("");
}
function renderExperience() {
timeline.innerHTML = experiences
.map(
(item, index) => `
<article class="reveal" data-drift="${index % 2 === 0 ? 38 : -38}">
<div class="timeline-side">
<span class="logo-frame"><img src="${item.logo}" alt="" loading="lazy" decoding="async" /></span>
<time>${item.period}</time>
</div>
<div>
<h3>${item.company}</h3>
<p>${item.role}</p>
<ul>${item.points.map((point) => `<li>${point}</li>`).join("")}</ul>
</div>
</article>
`,
)
.join("");
}
function setupReveal() {
$$(".reveal").forEach((node) => {
node.style.setProperty("--drift", node.dataset.drift || 0);
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) entry.target.classList.add("is-visible");
});
},
{ threshold: 0.14 },
);
$$(".reveal").forEach((node) => observer.observe(node));
}
function updateScrollProgress() {
const max = document.documentElement.scrollHeight - window.innerHeight;
const value = max > 0 ? window.scrollY / max : 0;
root.style.setProperty("--scroll-progress", value.toFixed(4));
root.style.setProperty("--bg-grid-x", `${(-220 * value).toFixed(2)}px`);
root.style.setProperty("--bg-grid-y", `${(150 * value).toFixed(2)}px`);
root.style.setProperty("--bg-grid-x-2", `${(140 * value).toFixed(2)}px`);
root.style.setProperty("--bg-grid-y-2", `${(-190 * value).toFixed(2)}px`);
progress.style.transform = `scaleX(${Math.min(1, Math.max(0, value))})`;
const frame = getSceneFrame();
const activeScene = frame.blend > 0.58 ? frame.next : frame.current;
const sections = fluidScenes
.filter((scene) => scene.id !== "top")
.map((scene) => document.getElementById(scene.id))
.filter(Boolean);
const current = activeScene.id === "top" ? null : sections.find((section) => section.id === activeScene.id);
$$(".site-nav a").forEach((link) => link.classList.toggle("active", current && link.getAttribute("href") === `#${current.id}`));
}
function restoreHashPosition() {
const id = decodeURIComponent(location.hash.slice(1));
if (!id) return;
const target = document.getElementById(id);
if (!target) return;
requestAnimationFrame(() => {
window.scrollTo({ top: target.offsetTop, behavior: "auto" });
updateScrollProgress();
});
}
function setupTheme() {
const saved = localStorage.getItem("wy-fluid-theme");
if (saved) document.documentElement.dataset.theme = saved;
themeToggle.addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
localStorage.setItem("wy-fluid-theme", next);
});
}
function setupTilts() {
$$(".project-card").forEach((card) => {
card.addEventListener("pointerenter", () => card.classList.add("is-hovered"));
card.addEventListener("pointerleave", () => card.classList.remove("is-hovered"));
});
}
function setupFluidScene() {
const canvas = $("#fluid-canvas");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!canvas || reduceMotion) return;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(39, 1, 0.1, 100);
camera.position.set(0, 0, 6.2);
const pointer = new THREE.Vector2(0.18, -0.08);
const pointerTarget = new THREE.Vector2(0.18, -0.08);
const pointerVisual = new THREE.Vector2(58, 46);
const pointerVisualTarget = new THREE.Vector2(58, 46);
const pointerState = {
down: false,
lastX: window.innerWidth * 0.58,
lastY: window.innerHeight * 0.46,
velocity: 0,
targetVelocity: 0,
impact: 0,
pressure: 0,
};
const group = new THREE.Group();
scene.add(group);
const uniforms = {
uTime: { value: 0 },
uPointer: { value: pointer },
uScroll: { value: 0 },
uImpact: { value: 0 },
uFlow: { value: fluidScenes[0].flow },
uTwist: { value: fluidScenes[0].twist },
uColorA: { value: fluidScenes[0].colorObjects[0].clone() },
uColorB: { value: fluidScenes[0].colorObjects[1].clone() },
uColorC: { value: fluidScenes[0].colorObjects[2].clone() },
uTheme: { value: document.documentElement.dataset.theme === "light" ? 1 : 0 },
};
const vertexShader = `
uniform float uTime;
uniform vec2 uPointer;
uniform float uScroll;
uniform float uImpact;
uniform float uFlow;
uniform float uTwist;
varying vec3 vNormal;
varying vec3 vWorld;
varying float vWave;
float ridge(float value) {
return 1.0 - abs(value);
}
void main() {
vec3 p = position;
float t = uTime;
float sceneBreath = sin(uScroll * 6.283 + t * (0.18 + uFlow * 0.22));
float waveA = sin(p.x * (2.15 + uTwist * 1.25) + t * (0.58 + uFlow * 0.82) + sin(p.y * 1.4 + t * 0.32));
float waveB = cos(p.y * (2.6 + uTwist * 1.8) - t * (0.42 + uFlow * 0.74) + p.z * (1.35 + uTwist));
float waveC = sin((p.x + p.y + p.z) * (1.58 + uTwist * 1.1) + t * (0.86 + uFlow * 1.05));
vec2 pointerPlane = uPointer * vec2(1.55, 1.08);
float pointerDistance = distance(p.xy, pointerPlane);
float pointerWave = ridge(sin(pointerDistance * (4.8 + uImpact * 1.2) - t * (1.8 + uImpact * 0.8)));
float pointerPull = smoothstep(1.62, 0.18, pointerDistance);
float scrollWave = sin(uScroll * (5.5 + uFlow * 5.0) + p.z * (2.4 + uTwist * 1.6) + sceneBreath) * (0.045 + uTwist * 0.055);
float displacement = waveA * (0.11 + uFlow * 0.065) + waveB * (0.085 + uTwist * 0.04) + waveC * (0.052 + uFlow * 0.035) + pointerWave * (0.055 + uImpact * 0.11) + pointerPull * (0.05 + uImpact * 0.075) + scrollWave;
p += normal * displacement;
vec2 pointerDir = normalize((p.xy - pointerPlane) + vec2(0.0001, -0.0001));
p.xy += pointerDir * pointerPull * (0.024 + uImpact * 0.055);
p.x += sin(t * (0.34 + uFlow * 0.28) + p.y * (2.1 + uTwist * 0.9)) * (0.04 + uFlow * 0.03);
p.y += cos(t * (0.28 + uFlow * 0.26) + p.x * (1.8 + uTwist * 0.75)) * (0.032 + uTwist * 0.025);
vec4 world = modelMatrix * vec4(p, 1.0);
vNormal = normalize(normalMatrix * normal);
vWorld = world.xyz;
vWave = displacement;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
const fragmentShader = `
uniform float uTime;
uniform float uTheme;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform vec3 uColorC;
varying vec3 vNormal;
varying vec3 vWorld;
varying float vWave;
void main() {
vec3 viewDir = normalize(cameraPosition - vWorld);
float fresnel = pow(1.0 - max(dot(normalize(vNormal), viewDir), 0.0), 2.4);
float scan = sin((vWorld.x - vWorld.y) * 8.0 + uTime * 1.8) * 0.5 + 0.5;
vec3 darkA = uColorA;
vec3 darkB = uColorB;
vec3 darkC = uColorC;
vec3 lightA = mix(uColorA, vec3(0.95, 1.0, 0.96), 0.42);
vec3 lightB = mix(uColorB, vec3(0.9, 0.94, 1.0), 0.34);
vec3 lightC = mix(uColorC, vec3(1.0, 0.94, 0.9), 0.3);
vec3 a = mix(darkA, lightA, uTheme);
vec3 b = mix(darkB, lightB, uTheme);
vec3 c = mix(darkC, lightC, uTheme);
vec3 color = mix(a, b, smoothstep(-0.22, 0.38, vWave));
color = mix(color, c, fresnel * 0.86 + scan * 0.12);
color += fresnel * 0.16;
float alpha = 0.56 + fresnel * 0.24 + abs(vWave) * 0.06;
gl_FragColor = vec4(color, alpha);
}
`;
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader,
fragmentShader,
transparent: true,
depthWrite: false,
});
const shellMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.13,
wireframe: true,
blending: THREE.AdditiveBlending,
});
const blob = new THREE.Mesh(new THREE.SphereGeometry(1.52, 128, 128), material);
const shell = new THREE.Mesh(new THREE.SphereGeometry(1.72, 48, 32), shellMaterial);
shell.rotation.set(0.6, 0.1, -0.4);
group.add(blob, shell);
const ringMaterial = new THREE.MeshBasicMaterial({
color: 0x74a7ff,
transparent: true,
opacity: 0.16,
wireframe: true,
blending: THREE.AdditiveBlending,
});
const rings = Array.from({ length: 4 }, (_, index) => {
const ring = new THREE.Mesh(new THREE.TorusGeometry(1.9 + index * 0.16, 0.008, 8, 180), ringMaterial.clone());
ring.rotation.set(Math.PI / 2.3 + index * 0.18, index * 0.5, index * 0.8);
ring.material.opacity = 0.08 + index * 0.018;
group.add(ring);
return ring;
});
const rayMaterial = new THREE.LineBasicMaterial({
color: 0x56f7c5,
transparent: true,
opacity: 0.025,
blending: THREE.AdditiveBlending,
});
const rayGeometry = new THREE.BufferGeometry();
const rayPositions = new Float32Array(18 * 3);
rayGeometry.setAttribute("position", new THREE.BufferAttribute(rayPositions, 3));
const interactionRay = new THREE.LineSegments(rayGeometry, rayMaterial);
group.add(interactionRay);
const particleCount = 950;
const positions = new Float32Array(particleCount * 3);
const colors = new Float32Array(particleCount * 3);
const colorA = new THREE.Color("#56f7c5");
const colorB = new THREE.Color("#ff5ea8");
const colorC = new THREE.Color("#74a7ff");
for (let index = 0; index < particleCount; index += 1) {
const radius = 2.05 + Math.random() * 2.3;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(Math.random() * 2 - 1);
positions[index * 3] = Math.sin(phi) * Math.cos(theta) * radius;
positions[index * 3 + 1] = Math.sin(phi) * Math.sin(theta) * radius;
positions[index * 3 + 2] = Math.cos(phi) * radius;
const color = index % 3 === 0 ? colorA : index % 3 === 1 ? colorB : colorC;
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
}
const particleGeometry = new THREE.BufferGeometry();
particleGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
particleGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const particles = new THREE.Points(
particleGeometry,
new THREE.PointsMaterial({
size: 0.018,
vertexColors: true,
transparent: true,
opacity: 0.78,
depthWrite: false,
blending: THREE.AdditiveBlending,
}),
);
group.add(particles);
let width = 0;
let height = 0;
const basePosition = new THREE.Vector3(fluidScenes[0].desktop.x, fluidScenes[0].desktop.y, fluidScenes[0].desktop.z);
const sceneVisual = {
name: fluidScenes[0].name,
x: basePosition.x,
y: basePosition.y,
z: basePosition.z,
scale: fluidScenes[0].desktop.scale,
flow: fluidScenes[0].flow,
twist: fluidScenes[0].twist,
ring: fluidScenes[0].ring,
particle: fluidScenes[0].particle,
angle: fluidScenes[0].angle,
colors: fluidScenes[0].colorObjects.map((color) => color.clone()),
};
function resize() {
width = window.innerWidth;
height = window.innerHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
const compact = width < 760;
const target = mixScene(getSceneFrame(), compact);
basePosition.set(target.x, target.y, target.z);
group.position.copy(basePosition);
group.scale.setScalar(target.scale);
}
function onPointerMove(event) {
const dx = event.clientX - pointerState.lastX;
const dy = event.clientY - pointerState.lastY;
pointerState.lastX = event.clientX;
pointerState.lastY = event.clientY;
const speed = Math.hypot(dx, dy);
pointerState.targetVelocity = Math.min(1, speed / 170);
pointerState.impact = Math.max(pointerState.impact, Math.min(0.26, pointerState.targetVelocity * 0.2));
pointerTarget.x = (event.clientX / window.innerWidth) * 2 - 1;
pointerTarget.y = -((event.clientY / window.innerHeight) * 2 - 1);
pointerVisualTarget.set((event.clientX / window.innerWidth) * 100, (event.clientY / window.innerHeight) * 100);
}
function onPointerDown(event) {
pointerState.down = true;
pointerState.impact = Math.max(pointerState.impact, 0.46);
root.classList.add("is-pointer-down");
onPointerMove(event);
}
function onPointerUp() {
pointerState.down = false;
root.classList.remove("is-pointer-down");
}
window.addEventListener("resize", resize);
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerDown, { passive: true });
window.addEventListener("pointerup", onPointerUp, { passive: true });
window.addEventListener("pointercancel", onPointerUp, { passive: true });
resize();
const startedAt = performance.now();
function animate() {
const elapsed = (performance.now() - startedAt) / 1000;
const scrollMax = document.documentElement.scrollHeight - window.innerHeight;
const scroll = scrollMax > 0 ? window.scrollY / scrollMax : 0;
const compact = width < 760;
const sceneTarget = mixScene(getSceneFrame(), compact);
sceneVisual.name = sceneTarget.name;
sceneVisual.x = lerp(sceneVisual.x, sceneTarget.x, 0.045);
sceneVisual.y = lerp(sceneVisual.y, sceneTarget.y, 0.045);
sceneVisual.z = lerp(sceneVisual.z, sceneTarget.z, 0.045);
sceneVisual.scale = lerp(sceneVisual.scale, sceneTarget.scale, 0.045);
sceneVisual.flow = lerp(sceneVisual.flow, sceneTarget.flow, 0.04);
sceneVisual.twist = lerp(sceneVisual.twist, sceneTarget.twist, 0.04);
sceneVisual.ring = lerp(sceneVisual.ring, sceneTarget.ring, 0.04);
sceneVisual.particle = lerp(sceneVisual.particle, sceneTarget.particle, 0.04);
sceneVisual.angle = lerp(sceneVisual.angle, sceneTarget.angle, 0.035);
sceneVisual.colors.forEach((color, index) => color.lerp(sceneTarget.colors[index], 0.045));
basePosition.set(sceneVisual.x, sceneVisual.y, sceneVisual.z);
writeSceneCss(sceneVisual, scroll);
pointer.lerp(pointerTarget, 0.024);
pointerVisual.lerp(pointerVisualTarget, 0.07);
root.style.setProperty("--cursor-x", `${pointerVisual.x.toFixed(2)}vw`);
root.style.setProperty("--cursor-y", `${pointerVisual.y.toFixed(2)}vh`);
pointerState.velocity += (pointerState.targetVelocity - pointerState.velocity) * 0.045;
pointerState.targetVelocity *= 0.82;
pointerState.pressure += ((pointerState.down ? 1 : 0) - pointerState.pressure) * 0.075;
pointerState.impact *= pointerState.down ? 0.985 : 0.955;
uniforms.uImpact.value = Math.min(0.72, pointerState.velocity * 0.24 + pointerState.impact * 0.72 + pointerState.pressure * 0.12);
uniforms.uTime.value = elapsed;
uniforms.uScroll.value = scroll;
uniforms.uFlow.value = sceneVisual.flow;
uniforms.uTwist.value = sceneVisual.twist;
uniforms.uColorA.value.copy(sceneVisual.colors[0]);
uniforms.uColorB.value.copy(sceneVisual.colors[1]);
uniforms.uColorC.value.copy(sceneVisual.colors[2]);
uniforms.uTheme.value += ((document.documentElement.dataset.theme === "light" ? 1 : 0) - uniforms.uTheme.value) * 0.08;
group.position.x = basePosition.x + pointer.x * (0.06 + uniforms.uImpact.value * 0.035);
group.position.y = basePosition.y + Math.sin(elapsed * 0.47) * 0.07 + pointer.y * (0.045 + uniforms.uImpact.value * 0.03);
group.position.z = basePosition.z + uniforms.uImpact.value * 0.025;
group.scale.setScalar(sceneVisual.scale + Math.sin(elapsed * 0.18 + scroll * 5.2) * 0.018);
group.rotation.x = Math.sin(elapsed * 0.28) * 0.14 + pointer.y * (0.11 + uniforms.uImpact.value * 0.055);
group.rotation.y = elapsed * (0.055 + sceneVisual.flow * 0.065 + uniforms.uImpact.value * 0.018) + pointer.x * (0.18 + uniforms.uImpact.value * 0.075) + scroll * (0.5 + sceneVisual.twist * 0.5);
group.rotation.z = Math.cos(elapsed * 0.18) * 0.055 + pointer.x * pointer.y * 0.055 + sceneVisual.twist * 0.06;
blob.scale.setScalar(1 + Math.sin(elapsed * 0.62) * 0.025 + uniforms.uImpact.value * 0.014);
shell.rotation.y -= 0.0014;
shell.rotation.z += 0.001;
particles.material.opacity = 0.42 + sceneVisual.particle * 0.36;
particles.rotation.y -= 0.00035 + sceneVisual.flow * 0.00045 + uniforms.uImpact.value * 0.00035;
particles.rotation.x = Math.sin(elapsed * (0.14 + sceneVisual.flow * 0.12)) * (0.055 + sceneVisual.twist * 0.06);
rings.forEach((ring, index) => {
ring.material.opacity = (0.04 + index * 0.012) * (0.75 + sceneVisual.ring * 0.7);
ring.rotation.z += 0.0007 + sceneVisual.ring * 0.00075 + index * 0.00035 + uniforms.uImpact.value * 0.00055;
ring.rotation.x += Math.sin(elapsed * 0.24 + index) * 0.00048 + pointer.y * 0.00022;
});
for (let index = 0; index < 9; index += 1) {
const offset = index * 6;
const angle = elapsed * (0.28 + sceneVisual.flow * 0.22 + index * 0.025) + index * 0.7 + scroll * sceneVisual.twist * 2.4;
const length = 0.42 + sceneVisual.ring * 0.18 + uniforms.uImpact.value * 0.72;
rayPositions[offset] = pointer.x * 0.82;
rayPositions[offset + 1] = pointer.y * 0.58;
rayPositions[offset + 2] = 0.38;
rayPositions[offset + 3] = pointer.x * 0.82 + Math.cos(angle) * length;
rayPositions[offset + 4] = pointer.y * 0.58 + Math.sin(angle) * length * 0.42;
rayPositions[offset + 5] = 0.12 + Math.sin(angle * 0.7) * 0.28;
}
rayGeometry.attributes.position.needsUpdate = true;
rayMaterial.opacity = 0.025 + uniforms.uImpact.value * 0.13;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();
}
renderMetrics();
renderFocusAreas();
renderResumeSignals();
renderProjects();
renderSkills();
renderExperience();
setupTheme();
setupReveal();
setupTilts();
setupFluidScene();
updateScrollProgress();
restoreHashPosition();
window.addEventListener("scroll", () => requestAnimationFrame(updateScrollProgress), { passive: true });
window.addEventListener("hashchange", () => {
restoreHashPosition();
updateScrollProgress();
});
+255
View File
@@ -0,0 +1,255 @@
import * as THREE from "three";
import { blobFragment, blobVertex, particleFragment, particleVertex } from "./shaders";
// 三状态目标值 —— 每帧向目标插值,无硬切
const STATES = {
idle: { freq: 1.15, amp: 0.24, speed: 0.22, heat: 0.0, scale: 1.0, attract: 0.12 },
thinking: { freq: 2.6, amp: 0.27, speed: 0.95, heat: 0.7, scale: 0.86, attract: 1.0 },
answering: { freq: 1.9, amp: 0.46, speed: 0.5, heat: 0.22, scale: 1.09, attract: 0.0 },
};
const lerp = (a, b, t) => a + (b - a) * t;
export function createMind(canvas, { reduced = false } = {}) {
const isMobile = window.innerWidth < 880;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));
renderer.setSize(window.innerWidth, window.innerHeight);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 30);
camera.position.z = 6;
const group = new THREE.Group();
scene.add(group);
/* ---- 流体球 ---- */
const blobUniforms = {
uTime: { value: 0 },
uFreq: { value: STATES.idle.freq },
uAmp: { value: STATES.idle.amp },
uSpeed: { value: STATES.idle.speed },
uHeat: { value: 0 },
uOpacity: { value: 1 },
uColorA: { value: new THREE.Color("#22d3ee") },
uColorB: { value: new THREE.Color("#a78bfa") },
};
const blob = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.35, isMobile ? 48 : 96),
new THREE.ShaderMaterial({
vertexShader: blobVertex,
fragmentShader: blobFragment,
uniforms: blobUniforms,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
);
group.add(blob);
/* ---- 伴生粒子 ---- */
const COUNT = isMobile ? 900 : 2000;
const seeds = new Float32Array(COUNT * 3);
const radii = new Float32Array(COUNT);
const speeds = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) {
seeds[i * 3] = Math.random();
seeds[i * 3 + 1] = Math.random();
seeds[i * 3 + 2] = Math.random();
radii[i] = 1.7 + Math.random() * 1.5;
speeds[i] = 0.25 + Math.random() * 0.6;
}
const pGeo = new THREE.BufferGeometry();
pGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3));
pGeo.setAttribute("aSeed", new THREE.BufferAttribute(seeds, 3));
pGeo.setAttribute("aRadius", new THREE.BufferAttribute(radii, 1));
pGeo.setAttribute("aSpeed", new THREE.BufferAttribute(speeds, 1));
const particleUniforms = {
uTime: { value: 0 },
uAttract: { value: STATES.idle.attract },
uBurst: { value: 0 },
uDir: { value: -1 },
uSize: { value: isMobile ? 9 : 12 },
uMouse: { value: new THREE.Vector3(999, 999, 999) },
uMouseForce: { value: 0 },
uGrab: { value: 0 },
uHeat: { value: 0 },
uOpacity: { value: 1 },
uColorA: blobUniforms.uColorA,
uColorB: blobUniforms.uColorB,
};
const particles = new THREE.Points(
pGeo,
new THREE.ShaderMaterial({
vertexShader: particleVertex,
fragmentShader: particleFragment,
uniforms: particleUniforms,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
);
group.add(particles);
/* ---- 状态机 ---- */
let current = { ...STATES.idle };
let target = STATES.idle;
let stateName = "idle";
let burst = 0;
const colorA = blobUniforms.uColorA.value;
const colorB = blobUniforms.uColorB.value;
const targetColorA = colorA.clone();
const targetColorB = colorB.clone();
// 停靠位置:side -1 内容在右 → 流体去左;1 → 右;0 → 居中偏后
const dock = new THREE.Vector3();
const dockTarget = new THREE.Vector3();
const pointer = { x: 0, y: 0 };
function dockFor(side) {
if (window.innerWidth < 880) {
// 移动端:居中上移、退后、缩小,让内容可读
return new THREE.Vector3(side * 0.3, 1.25, -1.6);
}
if (side === 0) return new THREE.Vector3(0, 0.95, -2.8);
return new THREE.Vector3(side * 2.05, 0.1, -0.7);
}
let side = 1;
let opacityTarget = 1;
let hasPointer = false;
let mouseSnapped = false;
// 按压牵引弹簧(欠阻尼 → 过冲回弹的阻尼感)
let grab = 0;
let grabVel = 0;
let grabTarget = 0;
const mouseRay = new THREE.Vector3();
const mouseLocal = new THREE.Vector3();
const api = {
setState(name) {
if (name === stateName || reduced) return;
stateName = name;
target = STATES[name];
if (name === "answering") burst = 1; // 喷发脉冲,随帧衰减
},
setSide(s) {
side = s;
dockTarget.copy(dockFor(s));
particleUniforms.uDir.value = s === 0 ? 0 : -s; // 粒子喷向内容区
opacityTarget = s === 0 ? 0.42 : 1; // 居中停靠时减淡,保证内容可读
},
setPalette([a, b]) {
targetColorA.set(a);
targetColorB.set(b);
},
get state() {
return stateName;
},
debug() {
return { stateName, side, dock: dock.toArray(), dockTarget: dockTarget.toArray(), group: group.position.toArray() };
},
};
api.setSide(1);
dock.copy(dockTarget);
group.position.copy(dock);
window.addEventListener("pointermove", (e) => {
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = (e.clientY / window.innerHeight) * 2 - 1;
hasPointer = true;
});
window.addEventListener("pointerdown", (e) => {
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = (e.clientY / window.innerHeight) * 2 - 1;
hasPointer = true;
grabTarget = 1;
});
const release = () => { grabTarget = 0; };
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
window.addEventListener("blur", release);
window.addEventListener("resize", () => {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
api.setSide(side);
});
/* ---- 帧循环(由外部 ticker 驱动) ---- */
const clock = new THREE.Clock();
function tick() {
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
const k = 1 - Math.pow(0.0014, dt); // 帧率无关的平滑系数
current.freq = lerp(current.freq, target.freq, k);
current.amp = lerp(current.amp, target.amp, k);
current.speed = lerp(current.speed, target.speed, k);
current.heat = lerp(current.heat, target.heat, k);
current.scale = lerp(current.scale, target.scale, k);
current.attract = lerp(current.attract, target.attract, k);
burst = lerp(burst, 0, 1 - Math.pow(0.25, dt)); // 脉冲衰减
blobUniforms.uTime.value = reduced ? t * 0.25 : t;
blobUniforms.uFreq.value = current.freq;
blobUniforms.uAmp.value = current.amp;
blobUniforms.uSpeed.value = current.speed;
blobUniforms.uHeat.value = current.heat;
particleUniforms.uTime.value = reduced ? t * 0.25 : t;
particleUniforms.uAttract.value = current.attract;
particleUniforms.uBurst.value = burst;
particleUniforms.uHeat.value = current.heat;
colorA.lerp(targetColorA, k);
colorB.lerp(targetColorB, k);
const op = lerp(blobUniforms.uOpacity.value, opacityTarget, k);
blobUniforms.uOpacity.value = op;
particleUniforms.uOpacity.value = op;
const breathe = 1 + Math.sin(t * 1.4) * 0.018;
group.scale.setScalar(current.scale * breathe);
dock.lerp(dockTarget, 1 - Math.pow(0.02, dt));
group.position.set(
dock.x + pointer.x * 0.14,
dock.y - pointer.y * 0.1,
dock.z
);
group.rotation.y += dt * 0.12;
group.rotation.x = pointer.y * 0.08;
// 鼠标牵引:把指针投影到流体所在深度平面,转到 group 局部空间后平滑跟随
if (hasPointer && !reduced) {
mouseRay.set(pointer.x, -pointer.y, 0.5).unproject(camera).sub(camera.position).normalize();
const planeDist = (group.position.z - camera.position.z) / mouseRay.z;
mouseLocal.copy(camera.position).addScaledVector(mouseRay, planeDist);
group.updateMatrixWorld();
group.worldToLocal(mouseLocal);
if (!mouseSnapped) {
particleUniforms.uMouse.value.copy(mouseLocal);
mouseSnapped = true;
} else {
particleUniforms.uMouse.value.lerp(mouseLocal, 1 - Math.pow(0.01, dt));
}
particleUniforms.uMouseForce.value = lerp(particleUniforms.uMouseForce.value, 0.85, k);
// 按压牵引:欠阻尼弹簧积分 —— 按下缓冲吸入,松开过冲散开再归位
grabVel += (grabTarget - grab) * 26 * dt;
grabVel *= Math.exp(-3.2 * dt);
grab += grabVel * dt;
particleUniforms.uGrab.value = grab;
}
renderer.render(scene, camera);
}
return { ...api, tick };
}
+202
View File
@@ -0,0 +1,202 @@
// 思维流体 GLSL —— Ashima simplex noise + 3 octave FBM + Fresnel
const SIMPLEX = /* glsl */ `
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec4 mod289(vec4 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec4 permute(vec4 x){ return mod289(((x*34.0)+1.0)*x); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v){
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
float fbm(vec3 p){
float sum = 0.0;
float amp = 0.5;
for (int i = 0; i < 3; i++) {
sum += amp * snoise(p);
p *= 2.1;
amp *= 0.5;
}
return sum;
}
`;
export const blobVertex = /* glsl */ `
uniform float uTime;
uniform float uFreq;
uniform float uAmp;
uniform float uSpeed;
varying float vDisp;
varying vec3 vNormal;
varying vec3 vViewDir;
${SIMPLEX}
void main(){
float t = uTime * uSpeed;
float d = fbm(normal * uFreq + vec3(t, t * 0.7, -t * 0.4));
vDisp = d;
vec3 displaced = position + normal * d * uAmp;
vec4 mvPosition = modelViewMatrix * vec4(displaced, 1.0);
vNormal = normalize(normalMatrix * normal);
vViewDir = normalize(-mvPosition.xyz);
gl_Position = projectionMatrix * mvPosition;
}
`;
export const blobFragment = /* glsl */ `
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform float uHeat;
uniform float uOpacity;
varying float vDisp;
varying vec3 vNormal;
varying vec3 vViewDir;
void main(){
float fresnel = pow(1.0 - max(dot(vNormal, vViewDir), 0.0), 2.4);
vec3 base = mix(uColorA, uColorB, vDisp * 0.5 + 0.5);
base = mix(base, vec3(1.0, 0.62, 0.38), uHeat * 0.4);
vec3 color = base * (0.32 + fresnel * 1.6) + base * smoothstep(0.35, 0.9, vDisp) * 0.45;
float alpha = (0.22 + fresnel * 0.85) * uOpacity;
gl_FragColor = vec4(color, alpha);
}
`;
export const particleVertex = /* glsl */ `
uniform float uTime;
uniform float uAttract; // 1 = 吸入核心 (thinking)
uniform float uBurst; // 1 = 向外喷发 (answering)
uniform float uDir; // 喷发的水平方向(指向内容区)
uniform float uSize;
uniform vec3 uMouse; // 指针在粒子局部空间的位置
uniform float uMouseForce;
uniform float uGrab; // 按压牵引强度(弹簧驱动,可为负 → 回弹散开)
attribute vec3 aSeed; // 每粒子随机种子
attribute float aRadius;
attribute float aSpeed;
varying float vFade;
void main(){
float t = uTime * aSpeed;
// 进动轨道:两组相位叠加,避免规则圆轨
float theta = aSeed.x * 6.2831 + t;
float phi = aSeed.y * 3.1415 + sin(t * 0.6 + aSeed.z * 6.2831) * 0.7;
float r = aRadius * mix(1.0, 0.3, uAttract);
r += uBurst * (0.8 + aSeed.z * 2.6);
vec3 pos = vec3(
cos(theta) * sin(phi) * r,
cos(phi) * r * 0.85,
sin(theta) * sin(phi) * r
);
// 喷发时整体偏向内容区一侧
pos.x += uBurst * uDir * (0.6 + aSeed.y * 1.8);
// 鼠标牵引:靠近指针的粒子被吸过去,绕指针形成小漩涡
float md = distance(pos, uMouse);
float pull = smoothstep(2.8, 0.2, md) * uMouseForce * (0.35 + aSeed.z * 0.65);
vec3 swirl = vec3(
sin(t * 2.2 + aSeed.x * 6.2831),
cos(t * 1.7 + aSeed.y * 6.2831),
sin(t * 1.3 + aSeed.z * 6.2831)
) * (0.12 + aSeed.y * 0.3);
pos = mix(pos, uMouse + swirl, pull);
// 按住鼠标:全体粒子被牵向按压点,逐粒错相往复脉动;
// uGrab 为负(松开回弹)时 mix 外推 → 粒子向外散开再被拉回
float osc = 0.55 + 0.45 * sin(uTime * (1.2 + aSeed.x * 1.6) + aSeed.y * 6.2831);
float gpull = clamp(uGrab, -0.35, 1.25) * osc * (0.5 + aSeed.z * 0.5);
gpull = clamp(gpull, -0.4, 0.96);
vec3 gswirl = vec3(
sin(t * 1.8 + aSeed.y * 6.2831),
cos(t * 1.4 + aSeed.z * 6.2831),
sin(t * 1.1 + aSeed.x * 6.2831)
) * (0.18 + aSeed.x * 0.5);
pos = mix(pos, uMouse + gswirl, gpull);
vFade = 1.0 - uBurst * aSeed.z * 0.9;
vFade = min(vFade + pull * 0.5 + max(gpull, 0.0) * 0.35, 1.2);
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * (0.6 + aSeed.z) * (1.0 + pull * 0.5 + max(gpull, 0.0) * 0.3) * (3.4 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}
`;
export const particleFragment = /* glsl */ `
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform float uHeat;
uniform float uOpacity;
varying float vFade;
void main(){
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
float mask = smoothstep(0.5, 0.05, d);
vec3 color = mix(uColorA, uColorB, vFade);
color = mix(color, vec3(1.0, 0.62, 0.38), uHeat * 0.35);
gl_FragColor = vec4(color, mask * vFade * 0.75 * uOpacity);
}
`;
+241
View File
@@ -0,0 +1,241 @@
import {
experiences,
focusAreas,
metrics,
projects,
resumeSignals,
skills,
} from "./resume-data";
import { contact, rounds } from "./dialogue";
const el = (tag, className, html) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (html != null) node.innerHTML = html;
return node;
};
const externalLink = (className, href, text, ariaLabel = text) => {
const node = el("a", className);
node.href = href;
node.target = "_blank";
node.rel = "noreferrer";
node.textContent = text;
node.setAttribute("aria-label", ariaLabel);
return node;
};
/* ---------- 各轮回答内容 ---------- */
function bootContent(answer) {
answer.append(
el(
"h1",
"boot-greeting",
`你好,我是<span class="grad">湛兮(花名)</span>。<br /><span class="typed-line"></span>`,
),
el(
"p",
"boot-identity gen",
`7 年 Web 与跨端前端 · 最近一年在 <em>AI 产品团队</em> 做对话链路与控制台`,
),
);
const stream = el("div", "metric-stream");
metrics.forEach((m) => {
const token = el("div", "metric-token gen");
token.append(el("span", "v", m.value), el("span", "l", m.label));
stream.append(token);
});
answer.append(stream, el("div", "boot-hint gen", "SCROLL TO ASK"));
}
function signalsContent(answer) {
answer.append(el("div", "a-title gen", "Self Introduction"));
resumeSignals.forEach((line, i) => {
const row = el("div", "signal-line gen");
row.append(
el("span", "idx", String(i + 1).padStart(2, "0")),
el("p", null, line),
);
answer.append(row);
});
}
function focusContent(answer) {
answer.append(el("div", "a-title gen", "Last 12 Months"));
const grid = el("div", "focus-grid");
answer.append(grid);
focusAreas.forEach((f) => {
const card = el("div", "focus-card gen");
card.append(el("h3", null, f.title), el("p", null, f.summary));
const tags = el("div", "tag-row");
f.points.forEach((p) => tags.append(el("span", "tag", p)));
card.append(tags);
grid.append(card);
});
}
function projectsContent(answer) {
answer.append(el("div", "a-title gen", "Selected Proof"));
projects.forEach((p) => {
const block = el("article", "project-block gen");
const head = el("div", "project-head");
const meta = el("div", "project-meta");
meta.append(
p.url
? externalLink("name entity-link", p.url, p.name, `打开 ${p.name}`)
: el("span", "name", p.name),
);
if (p.links?.length > 1) {
const links = el("div", "project-links");
p.links.forEach((link) => {
links.append(
externalLink("mini-link", link.url, link.label, `打开 ${link.label}`),
);
});
meta.append(links);
}
head.append(
Object.assign(el("img"), { src: p.logo, alt: p.name, loading: "lazy" }),
meta,
el("span", "period", p.period),
);
block.append(
head,
el("div", "project-sub", p.subtitle),
el("p", "project-summary", p.summary),
);
const ul = el("ul", "project-modules");
p.modules.forEach((m) => ul.append(el("li", "gen-sub", m)));
block.append(ul);
const tags = el("div", "tag-row");
p.tech.forEach((t) => tags.append(el("span", "tag", t)));
block.append(tags);
answer.append(block);
});
}
function skillsContent(answer) {
answer.append(el("div", "a-title gen", "Token Stream / Skills"));
const HOT = new Set([
"SSE 流式通信",
"Next.js",
"React",
"React Native",
"TypeScript",
"Qiankun",
]);
skills.forEach((group) => {
const wrap = el("div", "skill-group");
wrap.append(el("div", "g-name", group.group));
const row = el("div", "tag-row");
group.items.forEach((item) => {
row.append(
el("span", `skill-token gen${HOT.has(item) ? " hot" : ""}`, item),
);
});
wrap.append(row);
answer.append(wrap);
});
}
function experienceContent(answer) {
answer.append(el("div", "a-title gen", "Career Path"));
experiences.forEach((exp) => {
const item = el("div", "exp-item gen");
const head = el("div", "exp-head");
head.append(
Object.assign(el("img"), {
src: exp.logo,
alt: exp.company,
loading: "lazy",
}),
exp.url
? externalLink(
"co entity-link",
exp.url,
exp.company,
`打开 ${exp.company}`,
)
: el("span", "co", exp.company),
el("span", "period", exp.period),
);
item.append(head, el("div", "exp-role", exp.role));
const ul = el("ul", "exp-points");
exp.points.forEach((pt) => ul.append(el("li", null, pt)));
item.append(ul);
answer.append(item);
});
}
function contactContent(answer) {
answer.append(
el("h2", "contact-lead gen", "随时可以开启下一轮对话。"),
el("p", "contact-id gen", contact.identity),
);
const actions = el("div", "contact-actions gen");
const mail = el("a", "contact-btn primary", "发送邮件");
mail.href = `mailto:${contact.emails[0]}`;
const mail2 = el("a", "contact-btn", contact.emails[1]);
mail2.href = `mailto:${contact.emails[1]}`;
const gh = el("a", "contact-btn", "GitHub / zhanBoss");
gh.href = contact.github;
gh.target = "_blank";
gh.rel = "noreferrer";
const tel = el("a", "contact-btn", contact.phone);
tel.href = `tel:${contact.phone}`;
actions.append(mail, mail2, gh, tel);
answer.append(actions, el("div", "session-end gen", "SESSION SAVED · 2026"));
}
const CONTENT = {
boot: bootContent,
signals: signalsContent,
focus: focusContent,
projects: projectsContent,
skills: skillsContent,
experience: experienceContent,
contact: contactContent,
};
/* ---------- 装配 ---------- */
export function renderDialogue(mount) {
rounds.forEach((round) => {
const section = el("section", "round");
section.id = `r-${round.id}`;
section.dataset.side = String(round.side);
const inner = el("div", "round-inner");
if (round.question) {
const qRow = el("div", "q-row");
const bubble = el("div", "q-bubble");
bubble.append(el("span", "q-text"), el("span", "q-caret"));
qRow.append(bubble);
const thinking = el("div", "thinking");
const dots = el("span", "dots");
dots.append(el("span", "dot"), el("span", "dot"), el("span", "dot"));
thinking.append(dots, el("span", null, "THINKING"));
inner.append(qRow, thinking);
}
const answer = el("div", "answer");
CONTENT[round.type](answer);
if (round.type !== "boot") answer.append(el("span", "a-caret", "▋"));
inner.append(answer);
section.append(inner);
mount.append(section);
});
}
export function renderRail(mount, onJump) {
rounds.forEach((round) => {
const node = el("button", "rail-node");
node.type = "button";
node.dataset.round = round.id;
node.setAttribute("aria-label", `跳转到 ${round.label}`);
node.append(el("span", "rail-label", round.label));
node.addEventListener("click", () => onJump(round.id));
mount.append(node);
});
}
+11
View File
@@ -73,6 +73,11 @@ export const projects = [
id: "vtrix", id: "vtrix",
name: "SeaCloud / Vtrix", name: "SeaCloud / Vtrix",
logo: "/logos/vtrix.png", logo: "/logos/vtrix.png",
url: "https://cloud.seaart.ai/",
links: [
{ label: "SeaCloud", url: "https://cloud.seaart.ai/" },
{ label: "Vtrix", url: "https://www.vtrix.ai/" },
],
period: "2025.09 - 至今", period: "2025.09 - 至今",
subtitle: "模型服务平台控制台", subtitle: "模型服务平台控制台",
summary: summary:
@@ -89,6 +94,7 @@ export const projects = [
id: "seabuzz", id: "seabuzz",
name: "SeaBuzz", name: "SeaBuzz",
logo: "/logos/seabuzz.webp", logo: "/logos/seabuzz.webp",
url: "https://play.google.com/store/apps/details?id=app.seahot.ai",
period: "2025.05 - 至今", period: "2025.05 - 至今",
subtitle: "资讯、搜索与对话应用", subtitle: "资讯、搜索与对话应用",
summary: summary:
@@ -106,6 +112,7 @@ export const projects = [
id: "bawang", id: "bawang",
name: "霸王功夫", name: "霸王功夫",
logo: "/logos/chagee.png", logo: "/logos/chagee.png",
url: "https://bwcj.com/",
period: "2024.03 - 2025.03", period: "2024.03 - 2025.03",
subtitle: "门店、食安与供应链系统", subtitle: "门店、食安与供应链系统",
summary: summary:
@@ -122,6 +129,7 @@ export const projects = [
id: "jingyingbang", id: "jingyingbang",
name: "经营帮平台 + 经营帮拉新", name: "经营帮平台 + 经营帮拉新",
logo: "/logos/jingyingbang.png", logo: "/logos/jingyingbang.png",
url: "https://jingyingbang.com/",
period: "2022.04 - 2024.03", period: "2022.04 - 2024.03",
subtitle: "微前端平台与小程序", subtitle: "微前端平台与小程序",
summary: summary:
@@ -140,6 +148,7 @@ export const experiences = [
{ {
company: "成都海艺互娱科技有限公司", company: "成都海艺互娱科技有限公司",
logo: "/logos/seaart.webp", logo: "/logos/seaart.webp",
url: "https://www.seaart.ai/",
role: "React Native 开发工程师", role: "React Native 开发工程师",
period: "2025.05 - 至今", period: "2025.05 - 至今",
points: [ points: [
@@ -151,6 +160,7 @@ export const experiences = [
{ {
company: "四川茶姬企业管理有限公司", company: "四川茶姬企业管理有限公司",
logo: "/logos/chagee.png", logo: "/logos/chagee.png",
url: "https://bwcj.com/",
role: "高级 Web 前端开发", role: "高级 Web 前端开发",
period: "2024.03 - 2025.03", period: "2024.03 - 2025.03",
points: [ points: [
@@ -162,6 +172,7 @@ export const experiences = [
{ {
company: "中钧科技有限公司四川分公司", company: "中钧科技有限公司四川分公司",
logo: "/logos/zhongjun.png", logo: "/logos/zhongjun.png",
url: "https://zhongjunkeji.com/",
role: "前端开发组长", role: "前端开发组长",
period: "2022.04 - 2024.03", period: "2022.04 - 2024.03",
points: [ points: [
+715 -927
View File
File diff suppressed because it is too large Load Diff