diff --git a/.gitattributes b/.gitattributes
index 39592b16b1dd02e66483fb2030ad42828d7feb0f..9308e4bf4a07fa28bbf274ca7d54d618210d3402 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -40,3 +40,12 @@ earth-online-sun/web/public/swiss/live/04-scout.jpg filter=lfs diff=lfs merge=lf
earth-online-sun/web/public/swiss/live/05-small-ego.jpg filter=lfs diff=lfs merge=lfs -text
earth-online-sun/web/public/swiss/live/06-off-grid.jpg filter=lfs diff=lfs merge=lfs -text
earth-online-sun/web/public/swiss/livephotos.zip filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.mp3 filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.jpg filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.png filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.mov filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.mp4 filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.zip filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.woff2 filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.ttf filter=lfs diff=lfs merge=lfs -text
+eth-blueprint/**/*.wav filter=lfs diff=lfs merge=lfs -text
diff --git a/README.md b/README.md
index 719f0820dee3840230eee82a511220a5a0a7021a..5b8116af62ae34dd5078e74b384eb54e5c446d8a 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,14 @@ app_port: 7860
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
+---
+
+# 🆕 最新项目:《世界计算机施工图》—— 以太坊 2013→2026 发展史(蓝图风格)
+
+> 目录:[`eth-blueprint/`](eth-blueprint/README.md) | 完整计划与进度日志见 **`eth-blueprint/README.md`**。
+> 本仓库原有项目全部保留;Space 首页切换为新项目,原项目仍可在 `/earth-online-sun/` 访问。
+
+
---
# 《地球 Online · 2026 版本更新公告》—— 孙宇晨最新访谈像素解说片
diff --git a/eth-blueprint/README.md b/eth-blueprint/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..da5f62f6955c071931d2ed3d8c998a6827f0fe12
--- /dev/null
+++ b/eth-blueprint/README.md
@@ -0,0 +1,93 @@
+# 《世界计算机施工图》—— 以太坊 2013→2026 发展史 · 蓝图风格解说片
+
+> 新增项目目录:`eth-blueprint/`(本仓库原有内容一律保留,只做新增)。
+> 本文件是**施工计划 + 进度日志**:先写计划,再动手;每完成一个大动作就推送一次到本 Space。
+
+## 0. 一句话
+
+把以太坊 13 年的发展史画成**一张会自己画图的工程蓝图**:
+2013 年一个 19 岁的年轻人在纸上画了一台不存在的机器——"世界计算机"。之后每一次升级,
+都是这张图纸上的一次**修订(REV)**:创世、分叉、发币标准、DeFi、销毁、合并(空中换引擎)、
+Blob、PeerDAS……直到今天仍是"施工中"的 Glamsterdam。
+
+## 1. 风格选择(lemo-opuscar)
+
+从 lemo-opuscar 的 43 种风格中选用 **`styles/blueprint`(蓝图 / 工程制图)**,理由:
+
+- 以太坊本身就是一台"被设计出来的机器",路线图天然就是一份不断修订的工程图纸;
+- 风格自带的"修订栏(title block)讲故事""FIG. 编号即转场""起草顺序""实时标注"几种原生手法,
+ 正好对应"版本升级""时间线""数据变化";
+- 风格规定:蓝白两色 + **全片只用一次的红色印章**——留给 2022 年"合并"这个情绪顶点。
+
+只学风格语法(STYLE.md / DIRECTOR.md / TECHNIQUE.md),**不看、不抄 demo 代码与资产**;
+也**不参考本仓库已有项目的代码与文案**,渲染引擎、场景、音乐、旁白全部从零编写。
+
+## 2. 需求逐条对应
+
+| # | 要求 | 实现方式 |
+|---|---|---|
+| 1 | lemo-opuscar 合适风格做 ETH 发展史 | Blueprint 蓝图风格,13 个 FIG.,时间线 2013→2026 |
+| 2 | 音效 + 音乐 + 旁白 | 旁白:edge-tts 中文神经语音逐句合成;音乐:Python 原创合成(拨弦羽管键琴 / 拨奏低音 / 八音盒 / 巴松管式簧音,复调"齿轮动机");音效:笔尖划纸、纸张拍落、齿轮擒纵、硬币、裂纹、印章、引擎轰鸣等程序合成;三层混音、旁白闪避、两遍 loudnorm −14 LUFS |
+| 3 | 网页看视频,不直接交 MP4 | Canvas 2D 确定性 `render(t)` 逐帧绘制 + 预混音轨同步播放 |
+| 4 | 浏览器本地导出 MP4 | WebCodecs(H.264 + AAC,不支持 AAC 时自动回退 Opus)+ MP4 封装,全程在访问者浏览器里完成 |
+| 5 | 手机竖屏 | 引擎与分辨率无关:横屏 1920×1080 / 竖屏 1080×1920;镜头按画幅自动取景,注释框按画幅重新排版;手机访问自动切竖屏 |
+| 6 | 导出 24 / 30 / 60 帧可选 | 导出面板单选,按所选帧率逐帧编码 |
+| 7 | 可复制标题/描述/标签 | 小红书 ≤20 字 ≤10 话题;抖音 ≤30 字 ≤5 话题;B站 ≤80 字 ≤10 话题;视频号 ≤16 字 不限话题;页面实时字数校验 + 一键复制 |
+| 8 | 平台封面 | 小红书 4:3 + 3:4;抖音 4:3 + 3:4;B站 4:3 + 个人空间 16:9;视频号 主页卡片 3:4 + 分享卡片 4:3;同一蓝图引擎绘制,单张下载 + 全部打包 |
+| 9 | livecanvas 实况图(jpg + mov) | 同一引擎在 Node(@napi-rs/canvas)离线渲染 1080×1440、30fps、3 秒动效卡 → ffmpeg 编 H.264 → livecanvas 的 `pair_live_photo.py` 写入 Apple Live Photo 元数据(content identifier + still-image-time)→ ZIP,网页提供下载 |
+| 10 | 进度可监督 | 本 README 先写计划;每个大动作完成后推送到 HF Space `Ethscriptions/tz` |
+
+## 3. 故事大纲(TREATMENT 摘要,详见 `TREATMENT.md`)
+
+结构:**一张图纸的 REV.0 → REV.12**。镜头像眼睛在一张大图纸上移动,从 FIG.1 横移到 FIG.13,
+右下角修订栏(title block)随剧情一行行填满日期——片尾拉远看到整张图纸,就是以太坊 13 年。
+
+| FIG. | 年份 | 画面(起草顺序画出) | 原生手法 |
+|---|---|---|---|
+| 0 | 2013 | 空白图纸,一条中心线划过,折成以太坊菱形 | 起草顺序 |
+| 1 | 2013 | 白皮书:计算器(只会记账)vs 计算机(可编程) | 对比图 Fig.1a / 1b |
+| 2 | 2014 | 众售 42 天,约 3.1 万枚 BTC 落入料斗 | 实时尺寸标注 |
+| 3 | 2015.07.30 | 创世区块,齿轮擒纵开始一格一格转动 | 机构按节拍步进 |
+| 4 | 2016 | The DAO 金库被剖开,约 360 万 ETH 流出;区块 1,920,000 处轨道一分为二 | 剖切线 + 真静默 |
+| 5 | 2017 | ERC-20 冲压机批量冲出代币,ICO 狂潮、管道拥堵 | 生成式堆积 |
+| 6 | 2020–21 | DeFi 积木拼接(x·y=k 曲线实时绘出)、NFT 画框、信标链并行启动 | 爆炸视图拼装 |
+| 7 | 2021.08.05 | 伦敦升级 EIP-1559:基础费进入销毁室 | 斜投影获得厚度 |
+| 8 | 2022.09.15 | **合并**:飞行中更换引擎,PoW 引擎脱离、PoS 引擎对接;能耗 −99.95%;**全片唯一的红色印章** | 静默 → 印章 |
+| 9 | 2023–24 | 上海升级开放提取;坎昆 Blob 侧挂集装箱;二层网络卫星机 | 横移 Fig.→Fig. |
+| 10 | 2025 | Pectra 智能账户;Fusaka PeerDAS 抽样验证(只取一部分数据格) | 数据格点亮 |
+| 11 | 2015–2026 | 真实 ETH 日收盘价曲线(Surf 数据)画在坐标纸上,标注历史高点 | 实时标注 |
+| 12 | 2026 | Glamsterdam:虚线(幻影线)画出尚未施工的零件,状态 PENDING | 修订栏讲故事 |
+| 13 | 尾声 | 拉远整张图纸,灯熄,白线微光;菱形成为"唯一真实之物" | 符号变真实 |
+
+## 4. 技术方案
+
+```
+script.ts(唯一时间线:场景、旁白、音效节拍)
+ ├─► tools/tts.py edge-tts 逐句合成 → vo/*.mp3 + 时长 vo.json
+ ├─► timeline.ts 按 96 BPM 小节网格排布场景(切换落在强拍)
+ ├─► dump-events.ts 导出 events.json(场景、旁白、音效时间)
+ ├─► tools/mix.py 音乐 + 音效 + 旁白 → 闪避/压缩 → loudnorm −14 LUFS → soundtrack.mp3
+ ├─► engine/*.ts Canvas 2D 蓝图引擎:氰版纸、四级线宽、虚线规范、起草动画、笔尖光、阴影线、尺寸线、零件气泡、注释框、修订栏
+ ├─► 网页播放器 音频为主时钟,requestAnimationFrame 调 render(t)
+ ├─► 浏览器导出 WebCodecs + MP4 封装,24/30/60 fps,横/竖屏
+ ├─► 封面 同一引擎绘制 8 张平台封面
+ └─► tools/live.ts Node 渲染实况动效卡 → ffmpeg → pair_live_photo.py → ZIP
+```
+
+数据:ETH 价格来自 Surf 数据 API(日收盘,2015-08-07 → 2026-10-10 快照);
+史实经联网核对(以太坊基金会博客等),来源列在 `TREATMENT.md`。
+
+## 5. 步骤与推送节点
+
+1. [x] 写本计划 → **推送 #1**
+2. [ ] 字体子集、数据快照、TREATMENT.md → 推送 #2
+3. [ ] 蓝图引擎 + 13 个场景(横竖屏)→ 推送 #3
+4. [ ] 旁白 + 音乐 + 音效 + 混音 → 推送 #4
+5. [ ] 播放器 + 浏览器 MP4 导出(24/30/60)→ 推送 #5
+6. [ ] 平台封面 + 文案 → 推送 #6
+7. [ ] 实况图 jpg+mov + ZIP → 推送 #7
+8. [ ] 构建静态站、Dockerfile 部署、验收 → 推送 #8
+
+## 6. 进度日志
+
+- 计划已写好,开始施工。
diff --git a/eth-blueprint/tools/push.sh b/eth-blueprint/tools/push.sh
new file mode 100755
index 0000000000000000000000000000000000000000..55facbb2a1c6492a9dcf85c66cb2b4b5462fa321
--- /dev/null
+++ b/eth-blueprint/tools/push.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Sync this project into the HF Space clone and push. Usage: push.sh "commit message"
+set -e
+SPACE=/tmp/hfspace
+DST=$SPACE/eth-blueprint
+mkdir -p $DST/web
+rsync -a --delete --exclude dist --exclude web /workspaces/eth-blueprint/ $DST/ --exclude .venv --exclude out
+rsync -a --delete --exclude node_modules --exclude dist --exclude .vite /workspaces/frontend/src/ $DST/web/src/
+rsync -a --delete /workspaces/frontend/public/ $DST/web/public/ 2>/dev/null || true
+cp /workspaces/frontend/package.json $DST/web/package.json
+[ -d /workspaces/frontend/dist/client ] && rsync -a --delete /workspaces/frontend/dist/client/ $DST/dist/ || true
+cd $SPACE
+git lfs install --local >/dev/null
+git add -A
+git -c user.name="Surf Studio" -c user.email="studio@surf.local" commit -q -m "$1" || echo "nothing to commit"
+git push -q origin HEAD:main 2>&1 | tail -3
+git log --oneline | head -1
diff --git a/eth-blueprint/web/package.json b/eth-blueprint/web/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..dde01b135753893610e49bbbfa223ae9a3dba4a5
--- /dev/null
+++ b/eth-blueprint/web/package.json
@@ -0,0 +1,83 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "node scripts/check-env.cjs vite",
+ "build": "node scripts/check-env.cjs npm run build:client && npm run build:server",
+ "build:client": "vite build --outDir dist/client",
+ "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
+ "lint": "eslint .",
+ "preview": "vite preview",
+ "type-check": "tsc --noEmit --incremental"
+ },
+ "dependencies": {
+ "@surf-ai/theme": "latest",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "tailwind-merge": "2.6.1",
+ "lucide-react": "0.454.0",
+ "next-themes": "0.4.6",
+ "echarts": "5.6.0",
+ "echarts-for-react": "3.0.6",
+ "@radix-ui/react-accordion": "1.2.12",
+ "@radix-ui/react-aspect-ratio": "1.1.8",
+ "@radix-ui/react-avatar": "1.1.11",
+ "@radix-ui/react-checkbox": "1.3.3",
+ "@radix-ui/react-collapsible": "1.1.12",
+ "@radix-ui/react-context-menu": "2.2.16",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-dropdown-menu": "2.1.16",
+ "@radix-ui/react-hover-card": "1.1.15",
+ "@radix-ui/react-label": "2.1.8",
+ "@radix-ui/react-menubar": "1.1.16",
+ "@radix-ui/react-navigation-menu": "1.2.14",
+ "@radix-ui/react-popover": "1.1.15",
+ "@radix-ui/react-progress": "1.1.8",
+ "@radix-ui/react-radio-group": "1.3.8",
+ "@radix-ui/react-scroll-area": "1.2.10",
+ "@radix-ui/react-select": "2.2.6",
+ "@radix-ui/react-separator": "1.1.8",
+ "@radix-ui/react-slider": "1.3.6",
+ "@radix-ui/react-slot": "1.2.4",
+ "@radix-ui/react-switch": "1.2.6",
+ "@radix-ui/react-tabs": "1.1.13",
+ "@radix-ui/react-toast": "1.2.15",
+ "@radix-ui/react-toggle": "1.1.10",
+ "@radix-ui/react-toggle-group": "1.1.11",
+ "@radix-ui/react-tooltip": "1.2.8",
+ "sonner": "1.7.4",
+ "cmdk": "1.1.1",
+ "vaul": "1.1.2",
+ "embla-carousel-react": "8.6.0",
+ "react-day-picker": "9.14.0",
+ "react-resizable-panels": "4.7.6",
+ "react-hook-form": "7.72.0",
+ "@hookform/resolvers": "5.2.2",
+ "date-fns": "4.1.0",
+ "zod": "3.25.76",
+ "@tanstack/react-query": "5.94.5",
+ "@tanstack/query-core": "5.94.5",
+ "scheduler": "0.27.0"
+ },
+ "devDependencies": {
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "@types/node": "22.19.15",
+ "@eslint/js": "9.39.4",
+ "@vitejs/plugin-react": "4.7.0",
+ "@tailwindcss/vite": "4.2.2",
+ "eslint": "9.39.4",
+ "eslint-plugin-react-hooks": "5.2.0",
+ "eslint-plugin-react-refresh": "0.4.26",
+ "globals": "16.5.0",
+ "tailwindcss": "4.2.2",
+ "tw-animate-css": "1.4.0",
+ "typescript-eslint": "8.57.1",
+ "typescript": "5.9.3",
+ "vite": "6.4.2"
+ }
+}
diff --git a/eth-blueprint/web/src/App.tsx b/eth-blueprint/web/src/App.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9f12606de706e7d27baae53e89f18955d586f3be
--- /dev/null
+++ b/eth-blueprint/web/src/App.tsx
@@ -0,0 +1,24 @@
+const SURF_LOGO_PATH = "M14.6875 13.333C15.0977 13.333 15.4859 13.5196 15.7422 13.8398L17.8721 16.502C17.9009 16.538 17.9308 16.5721 17.9619 16.6035C18.3091 16.9542 18.7529 17.3405 18.7529 17.834C18.7528 18.3399 18.3428 18.75 17.8369 18.75H17.5713C17.2123 18.75 16.8727 18.5869 16.6484 18.3066L15.4678 16.8311C14.761 15.9479 13.3369 16.4478 13.3369 17.5791V18.6494C13.3369 18.7062 13.2882 18.75 13.2314 18.75H11.3584C10.7127 18.75 10.1016 18.4564 9.69824 17.9521L8.80176 16.8311C8.09497 15.9477 6.66993 16.4478 6.66992 17.5791V18.6494C6.66991 18.7062 6.62124 18.75 6.56445 18.75H4.69141C4.04589 18.75 3.43548 18.4562 3.03223 17.9521L2.13477 16.8311C1.77885 16.3862 1.25293 15.931 1.25293 15.3613V13.4395C1.253 13.3822 1.29729 13.3331 1.35449 13.333C1.7646 13.3332 2.15295 13.5196 2.40918 13.8398L4.53809 16.502C5.24478 17.3853 6.66962 16.886 6.66992 15.7549V14.6836C6.67018 13.9379 7.27483 13.3332 8.02051 13.333C8.43065 13.333 8.81884 13.5197 9.0752 13.8398L11.2051 16.502C11.9097 17.3827 13.3282 16.8887 13.3369 15.7646V14.6836C13.3372 13.9378 13.9417 13.3331 14.6875 13.333ZM14.6875 6.66699C15.0978 6.66702 15.4859 6.85347 15.7422 7.17383L17.8721 9.83594C18.2276 10.2801 18.7526 10.7339 18.7529 11.3027V13.2266C18.7528 13.2833 18.7091 13.333 18.6523 13.333C18.2421 13.3329 17.8539 13.1465 17.5977 12.8262L15.4678 10.1641C14.7609 9.28113 13.3369 9.78089 13.3369 10.9121V11.9824C13.3369 12.7282 12.7321 13.3328 11.9863 13.333C11.5761 13.3329 11.187 13.1465 10.9307 12.8262L8.80176 10.1641C8.09497 9.28072 6.66993 9.78076 6.66992 10.9121V11.9824C6.66992 12.7284 6.06525 13.3329 5.31934 13.333C4.90908 13.3329 4.52094 13.1465 4.26465 12.8262L2.13477 10.1641C1.7788 9.71925 1.25293 9.26403 1.25293 8.69434V6.77344C1.253 6.71621 1.29728 6.6671 1.35449 6.66699C1.76461 6.66714 2.15295 6.85359 2.40918 7.17383L4.53809 9.83594C5.24487 10.7193 6.66989 10.2192 6.66992 9.08789V8.01758C6.66992 7.27168 7.27467 6.66722 8.02051 6.66699C8.43082 6.66699 8.81885 6.85345 9.0752 7.17383L11.2051 9.83594C11.9097 10.7163 13.3279 10.2223 13.3369 9.09863V8.01758C13.3369 7.27161 13.9416 6.6671 14.6875 6.66699ZM2.43555 1.25C2.79488 1.25 3.13499 1.41367 3.35938 1.69434L4.53809 3.16895C5.24485 4.05241 6.66991 3.55228 6.66992 2.4209V1.35059C6.66993 1.29391 6.71874 1.25017 6.77539 1.25H8.64844C9.29394 1.25 9.90438 1.5438 10.3076 2.04785L11.2051 3.16895C11.9097 4.04967 13.3282 3.55564 13.3369 2.43164V1.35059C13.3369 1.29391 13.3857 1.25017 13.4424 1.25H15.3154C15.9609 1.25 16.5714 1.5438 16.9746 2.04785L17.8721 3.16895C18.2275 3.61314 18.7526 4.06689 18.7529 4.63574V6.56055C18.7528 6.61728 18.7091 6.66699 18.6523 6.66699C18.2421 6.66688 17.8539 6.4795 17.5977 6.15918L15.4678 3.49805C14.761 2.61474 13.3371 3.11388 13.3369 4.24512V5.31543C13.3369 6.06126 12.7321 6.66673 11.9863 6.66699C11.5761 6.66693 11.187 6.47955 10.9307 6.15918L8.80176 3.49805C8.095 2.61474 6.67006 3.11388 6.66992 4.24512V5.31543C6.66992 6.06137 6.06525 6.66691 5.31934 6.66699C4.90908 6.66693 4.52094 6.47955 4.26465 6.15918L2.13477 3.49805C2.10558 3.46158 2.07543 3.42725 2.04395 3.39551C1.6966 3.04528 1.25293 2.6583 1.25293 2.16504C1.25317 1.65963 1.66348 1.25 2.16895 1.25H2.43555Z"
+
+function SurfLogo() {
+ return (
+
+ )
+}
+
+// Frontend API calls should use `src/lib/api.ts`, not absolute `/api/...` URLs.
+export default function App() {
+ return (
+
+
+
+ GM, Builder.
+
+
+ Start your crypto project here!
+
+
+ )
+}
diff --git a/eth-blueprint/web/src/ErrorBoundary.tsx b/eth-blueprint/web/src/ErrorBoundary.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..46b25fd85f8f22e4ff300deaa13e6f88cd82bd53
--- /dev/null
+++ b/eth-blueprint/web/src/ErrorBoundary.tsx
@@ -0,0 +1,106 @@
+import { Component, type ReactNode } from 'react'
+
+interface Props { children: ReactNode; fallback?: ReactNode }
+interface State { error: Error | null }
+
+// Auto-reload config for transient dep-optimization errors.
+// Shares the same key as entry-client's cold-start guard so reload
+// attempts are counted together, preventing double reload loops.
+const RELOAD_KEY = '__dep_reload'
+const MAX_RELOADS = 6
+// If the last reload was more than this many ms ago, reset the counter.
+// This prevents stale counters from blocking legitimate retries on a
+// later visit, while still capping rapid reload loops.
+const RELOAD_WINDOW_MS = 60_000
+
+// Patterns that indicate React modules loaded as stubs (dep optimization in progress)
+const DEP_OPT_PATTERNS = [
+ "reading 'useState'",
+ "reading 'useEffect'",
+ "reading 'useRef'",
+ "reading 'useCallback'",
+ "reading 'useMemo'",
+ "reading 'useContext'",
+ "reading 'useReducer'",
+]
+
+function isDepOptError(msg: string): boolean {
+ return DEP_OPT_PATTERNS.some((p) => msg.includes(p))
+}
+
+// Shared format with entry-client: { c: count, t: timestamp }
+function getReloadState(): { c: number; t: number } {
+ try {
+ const raw = sessionStorage.getItem(RELOAD_KEY)
+ if (raw) return JSON.parse(raw)
+ } catch { /* ignore parse errors */ }
+ return { c: 0, t: 0 }
+}
+
+export default class ErrorBoundary extends Component {
+ state: State = { error: null }
+
+ static getDerivedStateFromError(error: Error) {
+ return { error }
+ }
+
+ componentDidCatch(error: Error) {
+ if (!isDepOptError(error.message)) return
+ // Vite dep optimization may serve React stubs during cold start.
+ // Auto-reload so the browser fetches the real modules once ready.
+ const prev = getReloadState()
+ // Reset counter if outside the rapid-reload window (stale from earlier visit)
+ const count = (Date.now() - prev.t > RELOAD_WINDOW_MS) ? 0 : prev.c
+ if (count < MAX_RELOADS) {
+ sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ c: count + 1, t: Date.now() }))
+ setTimeout(() => location.reload(), 3000)
+ }
+ }
+
+ render() {
+ if (this.state.error) {
+ if (this.props.fallback) return this.props.fallback
+
+ // Show a friendlier message for dep optimization errors that will auto-reload
+ if (isDepOptError(this.state.error.message)) {
+ const { c, t } = getReloadState()
+ const fresh = (Date.now() - t <= RELOAD_WINDOW_MS)
+ if (!fresh || c < MAX_RELOADS) {
+ return (
+
+
Loading dependencies...
+
Reloading automatically
+
+ )
+ }
+ }
+
+ return (
+
+
Component Error
+
{this.state.error.message}
+
+ )
+ }
+ return this.props.children
+ }
+}
diff --git a/eth-blueprint/web/src/components/ui/accordion.tsx b/eth-blueprint/web/src/components/ui/accordion.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e1797c93458f3751a2e0f1189ff50d77f20ae3ed
--- /dev/null
+++ b/eth-blueprint/web/src/components/ui/accordion.tsx
@@ -0,0 +1,55 @@
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/eth-blueprint/web/src/components/ui/alert.tsx b/eth-blueprint/web/src/components/ui/alert.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5afd41d142c95c74069ebb5460117e74ead3b9df
--- /dev/null
+++ b/eth-blueprint/web/src/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/eth-blueprint/web/src/components/ui/aspect-ratio.tsx b/eth-blueprint/web/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c4abbf37f217c715a0eaade7f45ac78600df419f
--- /dev/null
+++ b/eth-blueprint/web/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,5 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+const AspectRatio = AspectRatioPrimitive.Root
+
+export { AspectRatio }
diff --git a/eth-blueprint/web/src/components/ui/avatar.tsx b/eth-blueprint/web/src/components/ui/avatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..991f56ecb117e96284bf0f6cad3b14ea2fdf5264
--- /dev/null
+++ b/eth-blueprint/web/src/components/ui/avatar.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/eth-blueprint/web/src/components/ui/badge.tsx b/eth-blueprint/web/src/components/ui/badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e87d62bf1a2b2a0e1cf0b0dc94c2836525f40b67
--- /dev/null
+++ b/eth-blueprint/web/src/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/eth-blueprint/web/src/components/ui/breadcrumb.tsx b/eth-blueprint/web/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..60e6c96f72f0350d08b47e4730cab8f3975dc853
--- /dev/null
+++ b/eth-blueprint/web/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,115 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Breadcrumb = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode
+ }
+>(({ ...props }, ref) => )
+Breadcrumb.displayName = "Breadcrumb"
+
+const BreadcrumbList = React.forwardRef<
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbList.displayName = "BreadcrumbList"
+
+const BreadcrumbItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbItem.displayName = "BreadcrumbItem"
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+})
+BreadcrumbLink.displayName = "BreadcrumbLink"
+
+const BreadcrumbPage = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbPage.displayName = "BreadcrumbPage"
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) => (
+ svg]:w-3.5 [&>svg]:h-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+)
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More
+
+)
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/eth-blueprint/web/src/components/ui/button.tsx b/eth-blueprint/web/src/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..65d4fcd9ca74240125c5f72cf84c873781141fea
--- /dev/null
+++ b/eth-blueprint/web/src/components/ui/button.tsx
@@ -0,0 +1,57 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/eth-blueprint/web/src/components/ui/calendar.tsx b/eth-blueprint/web/src/components/ui/calendar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..665b02995a6f9bb0abff2eb9bc9f8a0b5c4f1fc7
--- /dev/null
+++ b/eth-blueprint/web/src/components/ui/calendar.tsx
@@ -0,0 +1,211 @@
+import * as React from "react"
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react"
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "bg-popover absolute inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
+ defaultClassNames.weekday
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-[--cell-size] select-none",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-muted-foreground select-none text-[0.8rem]",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "bg-accent rounded-l-md",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+