---
name: vibe-store
description: "Publish and manage mini games and apps on VibeVibi (vibevibi.com) using only curl and its HTTP API, with nothing to install: device-code login, upload a build as a preview, go live only after the user confirms, list a work already hosted on another site by its URL, edit info, change the cover or app icon, unlist or relist, view stats, customize the profile banner, and fix blank-screen problems. Triggers: publish to VibeVibi, upload my game, deploy my mini game, update my VibeVibi work, vibe store stats, unlist my work, VibeVibi blank page, install or update the VibeVibi skill. 触发词：发布到 VibeVibi、安装 VibeVibi skill、更新 VibeVibi skill、上传小游戏、发布小工具、更新作品、VibeVibi 预览、确认上线、改封面、改图标、改标题、下架作品、重新上架、主页头图、自定义主页、站外作品、登记网址、作品已经部署在别处、我的作品、作品数据、游玩量、VibeVibi 登录、作品白屏、页面打不开、资源 404。"
---

# VibeVibi：用 curl 发布和管理作品

VibeVibi 是给用 AI 做小游戏和 App 的人发布作品的平台。**不需要安装任何程序**：本文件就是全部说明，所有操作都用 `curl` 调用站点的 HTTP API。最新版在 `https://vibevibi.com/skill.md`，可以装成全局 skill，见「安装」。

## 何时使用

- 用户想把本地做好的网页（游戏 / App）发布到 VibeVibi，或者更新已发布的作品。
- 用户想看自己的作品列表、游玩数据，改标题、简介、分类、封面、图标，上架或下架。
- 用户想自定义自己主页最上面的头图，见「主页头图」。
- 用户的作品已经放在别的网站上，想登记到 VibeVibi，见「站外作品」。
- 发布后页面白屏、图片或脚本 404。

平台只托管**纯前端**（HTML / CSS / JS 和图片、音频等资源），不运行服务端代码。作品可以访问任何外部 API。已经部署在别处的作品可以只登记网址（「站外作品」）。

## 安装（以后的对话也能用）

本文件可以装成你的全局 skill（名字是 `vibe-store`）。装好后，用户在任何项目、任何新对话里说「发布到 VibeVibi」就能直接用，不用再给你网址；登录一次后 token 存在 `~/.config/vibe-store/token`，以后的对话也能用。

**如果你是通过网址读到本文件的（不是从已安装的 skill 加载的），先装一次：**

```sh
npx -y skills add https://vibevibi.com -g -y
```

- 会装到本机检测到的 AI 助手（Claude Code、Codex、Cursor 等）的全局 skills 目录。只装给自己时加 `-a`，例如 `-a claude-code`、`-a codex`、`-a cursor`。
- 没有 Node.js（`npx` 用不了）时，直接把本文件下载到你的全局 skills 目录。Claude Code：

  ```sh
  mkdir -p ~/.claude/skills/vibe-store && curl -sS https://vibevibi.com/skill.md -o ~/.claude/skills/vibe-store/SKILL.md
  ```

  Codex 放在 `~/.agents/skills/vibe-store/SKILL.md`，Cursor 放在 `~/.cursor/skills/vibe-store/SKILL.md`，其他助手放到它自己的全局 skills 目录。
- 装好后告诉用户：以后在任何对话里说「发布到 VibeVibi」就行。装不上也不影响这次发布，照本文件继续操作。

## 每次开始前：检查更新

装好的副本不会自动更新，VibeVibi 改了接口或流程时你手里的说明可能已经过时。**每次开始处理 VibeVibi 的任务时，先跑一次检查**（它不需要登录）：

```sh
VIBE=${VIBE:-https://vibevibi.com}
SKILL_VERSION=1.0.1
curl -sS "$VIBE/api/cli/skill?v=$SKILL_VERSION"
```

- 返回 `"latest": true`：已是最新，继续。
- 返回 `"latest": false`：有新版。把 `message`（里面有这次改了什么）告诉用户，然后更新，**重新读取更新后的说明**（例如 `cat ~/.claude/skills/vibe-store/SKILL.md`，或者 `curl -sS https://vibevibi.com/skill.md`），按新说明继续：

  ```sh
  npx -y skills update vibe-store -g -y
  ```

  手动下载安装的，重新下载一遍覆盖原文件。更新失败时，直接读 `https://vibevibi.com/skill.md` 的新说明继续这次任务，并告诉用户 skill 没更新成功。
- 用户说「更新 VibeVibi skill」时，也照上面更新。
- 接口返回 426 `skill_outdated` 时，说明这份说明太旧、已经不能用了：照上面更新、重新读取，再重试原操作一次。

## 准备

每条命令开头都设置这三个变量（Agent 的 shell 状态通常不会保留，所以每次都要带上）：

```sh
VIBE=${VIBE:-https://vibevibi.com}   # 本地测试时用 VIBE=http://localhost:5173
TOKEN=${VIBE_TOKEN:-$(cat ~/.config/vibe-store/token 2>/dev/null)}
SKILL_VERSION=1.0.1                  # 本说明的版本，不要改
```

- 带 token 的请求都要加上 `-H "X-Vibe-Skill: $SKILL_VERSION"`（下面的命令已经带上）。说明太旧时，平台靠它提醒你更新。

- 接口返回 JSON。出错时 HTTP 状态码不是 2xx，返回体是 `{"error":{"code":"…","message":"…","details":{…}}}`。`message` 是给人看的中文，直接转告用户。
- 用 `curl -sS`（**不要加 `-f`**，否则看不到错误内容）。需要看状态码时加 `-w '\nHTTP %{http_code}\n'`。
- 时间戳都是 Unix 毫秒。
- Windows PowerShell 里请用 `curl.exe`（`curl` 是 `Invoke-WebRequest` 的别名，参数不一样）。
- token 按站点区分。`~/.config/vibe-store/token` 只存正式站的；本地测试时让用户设置 `VIBE_TOKEN`，或者另存一个文件。

## 铁律

1. **先预览，用户明确同意后才上线。** 上传只会生成预览。把预览链接和所有警告告诉用户后，**主动问一次**「确认上线吗？」，并说明要上线的作品标题、slug、这次改了什么。只有用户明确回答「上线 / 确认 / 发布」这类话，才能调用 `publish`。用户没回答、回答含糊（「嗯」「好的」但没说上线）或者说还要改，都**不要**上线。
2. **服务端警告要逐条告诉用户。** 上传成功也可能带 `warnings`（例如绝对路径会导致白屏），不能因为上传成功就跳过。
3. **slug（作品网址）定了就不能改。** 新建前和用户确认 slug；更新时永远用原来的作品，不要为了换名字删掉重建。
4. **登录失效就重新登录一次，不要循环重试。** 接口返回 401 时，按「登录」重新走一遍；重试原操作一次，还是失败就停下来告诉用户。
5. **删除作品要先征得用户同意。** 删除会清掉文件，slug 也不能再用。只想暂时不让人看到时，用「下架」。
6. **不要把 token 打印出来**，也不要写进项目文件或提交到 git。

## 登录（设备码）

1. 发起登录，`clientName` 写你自己的名字（例如 `Claude Code`、`Codex`）：

   ```sh
   VIBE=${VIBE:-https://vibevibi.com}
   curl -sS -X POST "$VIBE/api/cli/login" -H 'Content-Type: application/json' -d '{"clientName":"Claude Code"}'
   ```

   返回 `{"deviceCode","userCode","verificationUrl","intervalSeconds","expiresAt"}`。

2. 把 `verificationUrl` 发给用户，请他在浏览器里打开、登录并点「确认」，同时告诉他确认码 `userCode`，让他核对网页上显示的是同一个码。可以顺便尝试帮用户打开浏览器（macOS `open`，Linux `xdg-open`，Windows `start`），打不开也没关系。

3. 轮询直到用户确认。下面的循环拿到 token 后直接写入文件、不打印出来。用户最多有 10 分钟时间，请把这条命令的超时设长一些，或者放到后台运行：

   ```sh
   VIBE=${VIBE:-https://vibevibi.com}
   DEVICE_CODE='上一步返回的 deviceCode'
   INTERVAL=3   # 上一步返回的 intervalSeconds
   mkdir -p ~/.config/vibe-store
   while :; do
     R=$(curl -sS -X POST "$VIBE/api/cli/login/poll" -H 'Content-Type: application/json' \
       -d "{\"deviceCode\":\"$DEVICE_CODE\"}")
     case "$R" in
       *'"pending"'*) sleep "$INTERVAL" ;;
       *'"rate_limited"'*) sleep 20 ;;
       *'"approved"'*)
         (umask 077; printf '%s' "$R" | sed -n 's/.*"token" *: *"\(vst_[^"]*\)".*/\1/p' > ~/.config/vibe-store/token)
         [ -s ~/.config/vibe-store/token ] && echo "登录成功" || echo "没取到 token，请重新登录"
         break ;;
       *) echo "$R"; break ;;
     esac
   done
   ```

   - `denied`：用户在网页上拒绝了，问用户是否要重新登录。
   - `expired`：超时了，重新从第 1 步开始。
   - token 只返回一次。如果没存下来，只能重新登录。

4. 确认身份：

   ```sh
   curl -sS "$VIBE/api/me" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION"
   ```

退出登录（同时让 token 在服务器上失效）：

```sh
curl -sS -X POST "$VIBE/api/auth/logout" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION" && rm -f ~/.config/vibe-store/token
```

## 发布新作品

### 1. 准备构建产物

- 用 React / Vue / Vite 等框架做的项目，要先构建（通常是 `npm run build`），**上传构建出来的目录**（通常是 `dist/`，有的是 `build/`、`out/`），不要上传项目源码。
- 构建前确认资源用的是相对路径，见下面「常见白屏」。Vite 项目在 `vite.config` 里设置 `base: './'`。
- 只有一个 html 文件的作品可以直接上传这个文件。
- 限制：入口必须是 `index.html`，放在包的最外层（或者唯一的一层文件夹里）；压缩包 ≤ 20MB，解压后 ≤ 50MB，文件 ≤ 900 个。

### 2. 判断是新作品还是更新

```sh
curl -sS "$VIBE/api/my/works" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION"
```

返回 `MyWork[]`，每项有 `workId`、`slug`、`title`、`status`、`version`。如果里面已经有同名或者 slug 对得上的作品，按「更新作品」走；拿不准时问用户。

### 3. 新建作品

先和用户确认标题、分类、slug：

- `title`：≤ 40 字。
- `category`：`game`（游戏）或 `tool`（App）。做主页头图时用 `banner`，见「主页头图」。
- `slug`：作品网址 `https://vibevibi.com/<你的 handle>/<slug>` 的最后一段（全站唯一），**发布后不能改**。3–40 位小写字母、数字、`-`，以字母或数字开头。不填时自动生成一个随机的。
- `summary`：一句话简介，可选，≤ 200 字。

```sh
curl -sS -X POST "$VIBE/api/my/works" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION" -H 'Content-Type: application/json' \
  -d '{"title":"贪吃蛇","category":"game","slug":"snake-classic","summary":"经典贪吃蛇，手机也能玩"}'
```

返回 `MyWork`。记下 `workId`，后面的接口都用它（不是 slug）。返回 `conflict` 说明 slug 被占用了，请用户换一个。

**新作品第一次上线前必须上传图标**（主页头图不用）：别人在作品页「安装」时装的是这个作品本身，用这张图当 App 图标。项目里有 logo、favicon 或 PWA 图标时问用户要不要用它；没有就请用户提供一张（正方形，铺满画面、四周别留太多空白）。接口见「其他操作」里的「改图标」。

### 4. 打包

在构建产物目录**里面**打包，让 `index.html` 在压缩包最外层。先删掉旧包（`zip` 会往已有的包里追加）：

```sh
rm -f /tmp/vibe-pkg.zip
cd dist && zip -qr /tmp/vibe-pkg.zip . -x '*node_modules/*' '*.git/*' '*.DS_Store' '__MACOSX/*' '.env*'
```

没有 `zip` 命令时用 Python（`*` 不会匹配 `.git`、`.env` 这类以点开头的文件）：

```sh
rm -f /tmp/vibe-pkg.zip
cd dist && python3 -m zipfile -c /tmp/vibe-pkg.zip *
```

Windows PowerShell：

```powershell
Compress-Archive -Path dist\* -DestinationPath $env:TEMP\vibe-pkg.zip -Force
```

### 5. 上传，生成预览

```sh
curl -sS -X PUT "$VIBE/api/my/works/$WORK_ID/package" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION" \
  -H 'Content-Type: application/zip' --data-binary @/tmp/vibe-pkg.zip
```

单个 html 文件：`-H 'Content-Type: text/html' --data-binary @game.html`。

返回 `{"work": MyWork, "previewUrl": "…", "warnings": ["…"]}`：

- 把 `previewUrl` 原样发给用户，请他在手机和电脑上都打开试一下（电脑上打开时，预览页顶部可以切换手机 / 电脑尺寸）。预览 1 小时内有效，过期了重新上传一次即可。
- `warnings` **逐条**告诉用户，并说明怎么改（参考「常见白屏」）。改完后重新构建、打包、上传，会生成新的预览。
- 失败返回 `invalid_upload` 时，按 `message` 修正后重新上传。

### 6. 用户确认后上线

按铁律 1 主动询问。用户明确同意后：

```sh
curl -sS -X POST "$VIBE/api/my/works/$WORK_ID/publish" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION" \
  -H 'Content-Type: application/json' -d '{"version": 3}'
```

上线接口可能要十几秒才返回，耐心等，不要重复调用。返回 `bad_request` 且 `details.field` 为 `icon` 时，说明新作品还没有图标，先上传图标再上线。`version` 用上传返回的 `work.version`，它保证上线的就是用户看过的那一版。返回 `content_rejected` 时，把 `message` 原样告诉用户，请他修改对应的文字或画面，重新构建、上传、确认后再上线，**不要**换个写法绕过审核（没通过的原因也会显示在创作者中心）。返回 `conflict` 时先 `GET /api/my/works/$WORK_ID`：如果 `status` 是 `live`、`preview` 为 `null`、`version` 没变，说明用户已经在网页上自己点了上线，直接把正式网址给他；否则是之后又上传过新版本，取最新的预览让用户再看一次。

用户也可以不回复你、直接在预览页或创作者中心（`/-/settings`）点「确认上线」，这同样算用户确认过。

上线后把正式网址 `work.url`（`https://vibevibi.com/@<handle>/<slug>`）告诉用户。网址后面加 `?full`（`https://vibevibi.com/@<handle>/<slug>?full`）打开时整页只显示作品，像一个独立网页，适合直接发给别人玩；站外作品没有这个模式。

## 更新作品

1. `GET /api/my/works` 找到作品的 `workId`。
2. 重新构建 → 打包 → `PUT /api/my/works/$WORK_ID/package` → 把预览链接和警告给用户。
3. 用户确认后 `POST /api/my/works/$WORK_ID/publish`。确认之前，线上一直是旧版本。

## 主页头图

用户想自定义自己主页最上面那块区域时用这一节。头图是一个特殊的作品（`category` 为 `banner`）：上传、预览、确认上线的流程和普通作品**完全一样**（铁律照样适用），但它只显示在用户主页顶部，不进发现页、搜索和作品列表，没有单独的作品页，也不算作品数。**每个账号只有一个。**

**1. 先找有没有。** `GET /api/my/works`，找 `category` 为 `banner` 的那一项。有就按「更新作品」给它上传新版本；没有再新建：

```sh
curl -sS -X POST "$VIBE/api/my/works" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION" -H 'Content-Type: application/json' \
  -d '{"title":"主页头图","category":"banner"}'
```

已经有头图时会返回 `conflict`，`error.details.workId` 就是已有的那个，直接用它。

**2. 尺寸。** 页面要铺满整个窗口（`100vw × 100vh`），不能出现滚动条，宽高变化时要跟着自适应：

- 手机：宽度等于屏幕宽度，宽高比 3:2，例如 390×260（平板上最高 440px，会更扁一些）。
- 电脑：全宽，高度是宽度的 1/4，最高 360px，例如 1280×320；更宽的屏幕会更扁，例如 1920×360。
- 重要内容放在中间。左下角会被用户头像挡住一块，左上、右上角手机上有「返回」「通知」按钮。

**3. 运行限制。** 头图在只开了脚本的沙箱 iframe 里运行：

- **不能用** `localStorage`、`sessionStorage`、`IndexedDB`、cookie（访问会抛异常，要么不用，要么 `try/catch`）。
- **不能** `alert` / `confirm` / `prompt`，不能 `window.open` 弹窗，不能提交表单，不能跳转页面。
- 可以用 JS、Canvas、WebGL、CSS 动画、音频（用户和它互动之后才能出声），可以 `fetch` 允许跨域的外部接口。
- 手机上访客直接触摸头图就能互动，电脑上鼠标直接可用。不要拦截纵向滑动（不要对整个页面设 `touch-action: none`，或在 `touchmove` 里一律 `preventDefault()`），否则访客在头图上划不动主页；需要拖动的元素只在它自己身上拦截。

**4. 预览和上线。** 上传后把 `previewUrl` 给用户，预览页会按手机、电脑两种尺寸各显示一遍，请用户都看一下。用户确认后 `publish`，然后把主页地址 `work.url` 告诉用户。想撤掉头图用「下架」（主页恢复默认色块），想换样子直接上传新版本。

## 站外作品

用户的作品已经放在别的网站上（Vercel、Netlify、GitHub Pages、itch.io、自己的服务器等），或者依赖自己的后端、不方便打包上传时，可以只登记网址。站外作品和普通作品一样有作品页、进榜单、能点赞评论，但作品页**不嵌入原站**：访客点「打开外部作品」后在新标签页打开原站。按钮上方会标明「非 VibeVibi 托管的站外作品」；列表里的卡片封面右上角会有一个外链图标。

- **优先推荐托管。** 只要作品能构建成纯前端（作品照样可以请求用户自己的后端接口），就先建议用户按「发布新作品」托管到 VibeVibi：作品页里直接玩、打开更快，创作者中心能看到每天的游玩、访客等数据，原来的网站照常保留。站外作品访客要跳到原站，平台也拿不到它的访问数据（没有每日统计）。只有用户确实不方便迁过来（例如页面要靠自己的服务器生成），或者明确要求登记网址时，才用站外作品。
- 网址必须以 `https://` 开头，用公开的域名，不能是 IP、内网地址，也不能带端口。
- 站外作品**不上传作品包**，没有预览和图标，也不能做主页头图。建好后不能改成托管作品，托管作品也不能改成站外作品；想换方式只能另建一个作品。
- 用户也可以自己在创作者中心（`/-/settings`）点「添加站外作品」，填网址直接上线。

**1. 确认网址能打开。** 先 `curl -sSIL "$URL"` 看一下状态码，再和用户确认标题、分类、slug 和网址。

**2. 新建时带上 `url`：**

```sh
curl -sS -X POST "$VIBE/api/my/works" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION" -H 'Content-Type: application/json' \
  -d '{"title":"贪吃蛇","category":"game","slug":"snake-classic","summary":"经典贪吃蛇","url":"https://snake.example.com/"}'
```

**3. 用户确认后上线。** 原站就是预览：请用户自己打开网址看一遍，按铁律 1 问「确认上线吗？」。用户明确同意后上线，不用传 `version`：

```sh
curl -sS -X POST "$VIBE/api/my/works/$WORK_ID/publish" -H "Authorization: Bearer $TOKEN" -H "X-Vibe-Skill: $SKILL_VERSION" \
  -H 'Content-Type: application/json' -d '{}'
```

上线前平台会打开原站、截首屏审核，可能要十几秒。返回 `content_rejected` 时把 `message` 告诉用户，请他改好原站或换个网址后再上线（原站改好后可以直接再调一次 `publish`）。上线后把正式网址 `work.url` 告诉用户，并建议上传一张封面（作品页显示封面和「打开外部作品」按钮）。

**4. 换网址。** `PATCH /api/my/works/$WORK_ID`，JSON `{"url":"https://…"}`。已上线的作品换网址等于重新上线：要先征得用户同意；平台会审核新网址的首屏，通过后立即生效，没通过时保留原来的网址。还没上线的作品换网址后，照第 3 步上线。

## 其他操作

单个作品可以用 slug 或 workId 查询：`GET /api/my/works/<slug 或 workId>`。其余接口都要用 `workId`。

| 做什么 | 请求 | 说明 |
|---|---|---|
| 我的作品 | `GET /api/my/works` | `status`：`draft` 还没上线，`live` 已上线，`unlisted` 已下架，`removed` 被平台下架。`review` 不为 `null` 时是最近一次审核结果：`result` 为 `rejected` 表示第 `version` 版上线时没通过（`label` 是违规分类），`removed` 表示被平台下架（`reason`） |
| 改标题、简介、分类 | `PATCH /api/my/works/$WORK_ID`，JSON `{"title","summary","category"}`，只传要改的 | 立即生效，不需要预览。站外作品还可以传 `url`，见「站外作品」 |
| 改封面 | `PUT /api/my/works/$WORK_ID/cover`，`-H 'Content-Type: image/png' --data-binary @cover.png` | png / jpeg / webp / gif，≤ 10MB，Content-Type 要和真实格式一致。平台会统一裁成 **3:4 竖图（960×1280）** 并转成 WebP，主体请放在画面中间；最好直接给 3:4 竖图 |
| 改图标 | `PUT /api/my/works/$WORK_ID/icon`，`-H 'Content-Type: image/png' --data-binary @icon.png` | 格式要求同封面。平台会裁成 **正方形 512×512** 的 PNG。别人在作品页「安装」时装的是这个作品本身，用这张图当 App 图标。新作品第一次上线前必须设置（见「新建作品」）。项目里有 logo、favicon 或 PWA 图标时可以问用户要不要用它；图标要铺满正方形、四周别留太多空白。主页头图不能设图标 |
| 下架 | `POST /api/my/works/$WORK_ID/unlist` | 文件保留，别人打不开，随时可以重新上架 |
| 重新上架 | `POST /api/my/works/$WORK_ID/relist` | |
| 看数据 | `GET /api/my/works/$WORK_ID/stats?days=7`（或 `30`） | 每天的游玩、访客、点赞、评论、分享，日期按 UTC。站外作品拿不到原站的访问数据，没有每日统计 |
| 删除 | `DELETE /api/my/works/$WORK_ID` | 按铁律 5 先确认。删除文件，slug 不能再用 |
| 我的资料 | `GET /api/me` | |

`MyWork` 里的计数（`playCount`、`likeCount`、`commentCount`、`shareCount`）是累计值。

## 错误处理

| 状态码 / code | 怎么办 |
|---|---|
| 401 `unauthorized` / `session_expired` | 重新登录一次，再重试原操作一次。不要循环 |
| 400 `bad_request` | 参数不对，看 `message` 和 `details.field` |
| 400 `invalid_upload` | 包或图片不合格，按 `message` 修正（例如没有 `index.html`、上传了源码、文件太多） |
| 403 `forbidden` | 不是你的作品，或者账号被封禁。告诉用户，不要重试 |
| 404 `not_found` | workId 或 slug 不对，用 `GET /api/my/works` 核对 |
| 409 `conflict` | 新建时：slug 被占用，请用户换一个。上线时：版本变了，重新取最新预览给用户确认；站外作品已经上线过了，不用再上线 |
| 413 `payload_too_large` | 包太大，删掉或压缩大文件（视频、高清图、音频） |
| 422 `content_rejected` | 标题、简介、封面或作品内容没通过内容审核。把 `message` 告诉用户，请他修改内容，不要自己换说法重试 |
| 426 `skill_outdated` | 本说明太旧，已经不能用了。按「每次开始前：检查更新」更新、重新读取，再重试一次 |
| 429 `rate_limited` | 太频繁，等一分钟再试 |
| 503 `unavailable` | 内容审核服务暂时不可用，过一会儿重试一次 |
| 5xx | 服务器出错，稍后再试一次；还不行就告诉用户 |

## 常见白屏和 404

作品运行在内容域名的子路径下（`https://vibe-store-content.imsai.top/<slug>/<版本目录>/index.html`），嵌在作品页的 iframe 里。最常见的问题：

1. **用了以 `/` 开头的绝对路径。** `/assets/app.js` 会去内容域名的根目录找，结果 404、白屏。改成相对路径 `./assets/app.js`。构建工具设置：
   - Vite：`base: './'`
   - Webpack：`output.publicPath = './'`
   - Vue CLI：`publicPath: './'`
   - Create React App：`package.json` 里加 `"homepage": "."`
   - JS 里的 `fetch('/data.json')`、`location.href = '/xxx'` 也要改成相对路径。
2. **用了 history 路由。** `/level/2` 这种路径刷新后会 404。改用 hash 路由（React Router 用 `HashRouter` / `createHashRouter`，Vue Router 用 `createWebHashHistory()`），或者不用路由。
3. **上传了源码，没有上传构建产物。** 表现为 `index.html` 里引用 `/src/main.tsx` 之类的文件。先 `npm run build`，再上传 `dist/`。
4. **入口文件名不对。** 入口必须叫 `index.html`（小写），放在包的最外层。

其他注意：

- 所有作品共用同一个内容域名，`localStorage`、`IndexedDB` 是共享的。存数据时 key 加上作品自己的前缀（例如 `snake-classic:best`），避免和别的作品冲突。
- 作品在 iframe 里运行，不能跳转顶层页面（`window.top.location` 无效）。可以弹窗、全屏、播放声音、用手柄。
- 手机和电脑都会访问，请提醒用户在手机上也试一下预览。

## 更新记录

版本号是「主版本.次版本.修订号」，新的在前。修订号变了是改了说明，次版本变了是加了功能，旧说明都还能用；主版本变了是改了接口，主版本更旧的说明调用接口会收到 426 `skill_outdated`。

- 1.0.1：手机上访客直接触摸主页头图就能互动，头图不要拦截纵向滑动（见「主页头图」）。
- 1.0.0：可以装成全局 skill（见「安装」）；每次开始前检查更新；带 token 的请求加上 `X-Vibe-Skill` 头；作品网址加 `?full` 可以整页只显示作品。
