@firetable/project-xiaochun 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-CN.md CHANGED
@@ -137,9 +137,9 @@ const { containerRef, client, ready, state } = useXiaochun({ width: 280, height:
137
137
  // <div ref={containerRef} style={{ width: 280, height: 420 }} />
138
138
  ```
139
139
 
140
- * **Props**:`createXiaochun` 除 `container` 外的全部选项,加上 `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onError / onDestroy`、`className`、`style`、`paused`、`mic`。
141
- * **重建与热更新**:创建期选项(`src`、`lazy`、`transparent`、`width`、`height`、`position` 等)变化会重建实例,所以不要在每次渲染里传新值。`lang`、`model`、`paused`、`mic` 与回调会原地更新,不重建 iframe。
142
- * **ref 方法**:`say`、`speakAudio`、`speakAudioStream`、`motion`、`expression`、`lookAt`、`setModel`、`setConfig`、`startListening`、`stopListening`、`mic`、`pause`、`resume`、`activate`、`destroy`,以及 `ready` 与 `instance`。组件挂载前,返回 Promise 的方法会 reject。
140
+ * **Props**:`createXiaochun` 除 `container` 外的全部选项,加上 `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onMove / onResize / onError / onDestroy`、`className`、`style`、`paused`、`mic`。
141
+ * **重建与热更新**:创建期选项(`src`、`lazy`、`transparent`、`position` 等)变化会重建实例,所以不要在每次渲染里传新值。`width`、`height`、`draggable`、`resizable`(effect 调 `setSize` / `setDraggable` / `setResizable`)、`lang`、`outfit`、`scene`(以及已弃用的 `model`)、`paused`、`mic` 与回调会原地更新,不重建 iframe(由 effect 调 `setOutfit` / `setScene` / `setConfig`)。另有回调 `onOutfitChanged`、`onSceneChanged`。
142
+ * **ref 方法**:`say`、`speakAudio`、`speakAudioStream`、`motion`、`expression`、`lookAt`、`setOutfit`、`setScene`、`getOutfits`、`getScenes`、`prefetch`、`setModel`、`setConfig`、`startListening`、`stopListening`、`mic`、`pause`、`resume`、`activate`、`destroy`,以及 `ready` 与 `instance`。组件挂载前,返回 Promise 的方法会 reject。
143
143
 
144
144
  ---
145
145
 
@@ -165,13 +165,64 @@ s.write(int16Chunk); s.write(next); s.end(); await s.done; // s.abort() 立即
165
165
 
166
166
  ---
167
167
 
168
+ ## 👗 换装、换场景与预取
169
+
170
+ ```ts
171
+ const xc = createXiaochun({ container: '#avatar', outfit: 'xiaochun_maid', scene: 'light', persist: 'host' });
172
+ await xc.ready;
173
+
174
+ const outfits = await xc.getOutfits(); // [{ id, name }](裸模永远不会出现在列表里)
175
+ const scenes = await xc.getScenes(); // [{ id: 'light' | 'dark' | 'transparent', transparent }]
176
+
177
+ await xc.setOutfit('xiaochun_cheongsam'); // 新服装生效后才 resolve
178
+ await xc.setScene('transparent'); // 外壳背景和穿透开关自动跟随
179
+ xc.on('outfit-changed', (p) => console.log(p.id, p.previous));
180
+ xc.on('scene-changed', (p) => console.log(p.id));
181
+ ```
182
+
183
+ * **id 严格校验**(`/^[a-z][a-z0-9_]{0,63}$/` + 自有属性白名单)。格式不对本地直接 reject `[bad_request]`(不发消息);格式合法但不存在(`constructor`、`base` 等)reject `[unknown_id]`,iframe 一侧会独立再校验。
184
+ * **并发**:换装**串行 + last-wins**。正在加载的不会被中止;排队中的请求被更新的调用顶掉时 reject `[busy]`(可忽略)。同目标请求合并;请求当前服装直接 resolve。**说话不会被打断**:正在说话时新服装在后台加载,好了再换上。
185
+ * **场景**:只有 3 个内置主题。运行时切换会同步外壳背景、开关指针穿透监听、非透明场景强制 `pointer-events: auto`、重置命中缓存。
186
+ * **能力协商**:协议仍是 v1。对旧版 `/embed`(`xc.ready` 里没有 `capabilities.outfits` / `scenes` / `prefetch`),新方法 reject `[unsupported]`,`getOutfits()` / `getScenes()` 返回 `[]`。
187
+ * **偏好保存**:iframe 用**自己的** localStorage 记住用户最近的服装 / 场景(键 `xiaochun_wearing_outfit` / `xiaochun_scene_theme`,与主站一致)。优先级:显式的 `outfit` / `scene`(URL 或 SDK 选项)> 已保存 > 默认;存的 id 不在白名单会被忽略并清掉;存储被拦截 / 分区时静默回退默认。第三方存储按顶层站点分区,每个宿主站各一份。
188
+ * **`persist`**(可选)另外把它们存在宿主页 localStorage,并作为显式值传回 iframe,因此会盖过 iframe 自己存的。默认 `false`。
189
+ * **`prefetch`**(默认关)只把服装文件下载进 iframe 的 IndexedDB(不解压不合成),串行、排在 EMAGE 加载之后,除非你点名否则不含婚纱。`await xc.prefetch(['xiaochun_cheongsam'])` 任何模式都能用;自动预取需要 `heavy: 'eager'`。用户开了省流量模式则跳过。之后 `setOutfit` 不再走网络(解压合成约 0.9 秒仍在)。
190
+ * **内置按钮**:见下一节(`ui: ['outfit', 'scene', 'lang', 'github']`、`uiAutoHide`)。
191
+ * **自定义模型**:`setModel({ url })` **默认关闭**,需要 `allowCustomModel: true` 显式开启。
192
+ * **迁移**:选项 `model` → `outfit`;旧的 `setModel('base')` 不再可用(列表请用 `getOutfits()`)。
193
+
194
+ ### 🔘 内置按钮(`ui`、`uiAutoHide`)
195
+
196
+ `ui` 是**部件名数组**,不写就什么都不显示。
197
+
198
+ ```ts
199
+ createXiaochun({ container: '#avatar', ui: ['outfit', 'scene', 'lang', 'github'] }); // 部件:chat · bubble · outfit · scene · lang · github
200
+ ```
201
+ ```html
202
+ <xiaochun-avatar ui="outfit,scene,lang,github" ui-autohide="false"></xiaochun-avatar> <!-- URL 写法: /embed?ui=outfit,scene,lang,github&uiAutoHide=false -->
203
+ ```
204
+ React:`<Xiaochun ui={['outfit', 'scene']} />`。运行时:`xc.setConfig({ ui: ['outfit'] })`。
205
+
206
+ * **何时显示(`uiAutoHide`,与桌面端一致)**:默认 `'transparent'`——**透明**场景下按钮和聊天栏初始隐藏,**单击角色**才出现(再点角色或点空白收起;10 秒无操作也自动收起,悬停在它们上面或菜单打开时不收);亮 / 暗场景一直显示。`true` 把"点击才出现"扩展到所有场景,`false` 保持一直显示。拖动 iframe(`draggable`)不算单击(位移 ≤ 6px 才算)。限制:透明场景下点宿主页空白处到不了 iframe(穿透),所以收起靠 10 秒超时或再点角色;触屏第一次轻触只用来唤醒命中检测。需要 iframe 声明 `capabilities.ui.autoHide`,旧 iframe 忽略该选项、常显。
207
+ * **语言 / GitHub 按钮**:`lang` 弹出菜单(简体中文 / English / 日本語),选择后立即生效,存在 **iframe 自己的** localStorage(`xiaochun_embed_lang`),并发 `lang-changed`(`{ lang, previous?, initial? }`,握手后会发一次 `initial: true`)。只记用户自己点的——显式 `lang` 选项和 `setConfig({ lang })` 不写存储。`github` 是 `<a target="_blank" rel="noopener noreferrer">`,指向项目仓库。
208
+ * **和主站 TopHeader 同一套组件**:玻璃质感按钮(触屏 44×44,桌面 36×36)、下拉菜单、`Shirt` / `MountainSnow` / `Check` / `Loader2` 图标、同一批 i18n 文案(随 `lang`)。放在右上角,不遮角色。服装列表来自 `capabilities.outfits`(不含裸模、没有自定义 URL),每行只显示名字(菜单里不显示文件体积)、加载中转圈、当前穿着打 ✓。
209
+ * **与 `setOutfit` / `setScene` 同一条路径**:同样的白名单、同一个串行 last-wins 队列,连点会出现轻提示"busy",并照常发 `outfit-changed` / `scene-changed`。宿主用 SDK 调用时按钮状态同步。点按钮不会触发转身/拖动手势。
210
+ * **透明场景**:按钮参与穿透命中(点按钮不会漏到宿主页,空白处仍穿透)。触屏 + 透明场景下,第一次轻触只用来唤醒命中检测(落在宿主页上),第二次才落到按钮。
211
+ * **偏好保存**:iframe 把按钮 / `setOutfit` / `setScene` 引起的变化存在自己的 localStorage,刷新后自动恢复(显式 `outfit` / `scene` 仍然优先)。想自己留一份(例如跨站),监听事件并把值作为显式选项传回:
212
+ ```ts
213
+ xc.on('outfit-changed', (p) => { if (!p.initial) localStorage.setItem('my-outfit', p.id); });
214
+ xc.on('scene-changed', (p) => { if (!p.initial) localStorage.setItem('my-scene', p.id); });
215
+ // 或直接 persist: 'host'
216
+ ```
217
+ * **滚轮缩放**(`controls`,默认开)见上面的选项表,包括"不透明且铺满时会吞该区域页面滚动"的副作用;想保持旧行为用 `controls: false`。
218
+
168
219
  ## 🎨 样式 (CSS 变量与 `::part`)
169
220
 
170
221
  宿主只能调整**外壳**。角色在跨域 iframe 里,你的 CSS 无法影响 iframe 内部。
171
222
 
172
223
  | 变量 | 默认值 | 作用 |
173
224
  | :--- | :--- | :--- |
174
- | `--xc-radius` | `0` | 圆角(任意 CSS 长度,如 `24px`;`50%` 为圆形头像框)。调大更圆,过大会裁掉头和脚 |
225
+ | `--xc-radius` | 非透明 `20px` / 透明 `0` | 圆角(任意 CSS 长度,如 `24px`;`50%` 为圆形头像框)。调大更圆,过大会裁掉头和脚 |
175
226
  | `--xc-shadow` | `none` | `box-shadow` 简写。透明悬浮头像请保持 `none` |
176
227
  | `--xc-z-index` | `2147483000` | 仅悬浮模式。调小可以让你的弹窗和导航盖在头像上面 |
177
228
  | `--xc-offset-x` / `--xc-offset-y` | `16px` | 仅悬浮模式:距屏幕侧边 / 底边的距离 |
@@ -188,6 +239,8 @@ xiaochun-avatar::part(iframe) { outline: 1px solid #0002; }
188
239
 
189
240
  可用的 part:`mount` · `wrapper` · `iframe` · `placeholder`。
190
241
 
242
+ **不会被选区染蓝**:宿主页的文字选区(拖选划过、Cmd/Ctrl+A)跨过 iframe 时,Chrome 会给整块 iframe 盖一层蓝色高亮。SDK 给外壳、占位图、iframe 都设了 `user-select: none`(CSSOM 内联样式,不注入 `<style>`,不受宿主 CSP 影响);宿主自己的文字照常可选,`pointer-events` 和透明穿透不变。别在 `::part(iframe)` 上把 `user-select` 改回 `auto`。
243
+
191
244
  ---
192
245
 
193
246
  ## 🔌 `xiaochun://` 与 `xc.*`
@@ -306,14 +359,25 @@ createXiaochun({
306
359
  | `lazy` | `true` | `true` / `'idle'`:进入视口且空闲 · `'click'`:点击或首次调用 API · `false`:立即创建 |
307
360
  | `lazyMargin` | `200` | 可见性触发的 rootMargin(px)。调大更早加载、更耗流量 |
308
361
  | `placeholder` | 内置 SVG | 图片 URL、元素或 `false` |
309
- | `transparent` | `false` | 背景透明叠在页面上(同时开启指针穿透) |
310
- | `width`、`height` | `320`、`480` | px 或任意 CSS 长度,务必设置 |
362
+ | `transparent` | `false` | 背景透明叠在页面上(同时开启指针穿透),等价于 `scene: 'transparent'` |
363
+ | `scene` | — | 初始场景:`'light' \| 'dark' \| 'transparent'`(见 `getScenes()`)。未知 id 会被忽略并触发 `error { code: 'unknown_id' }` |
364
+ | `width`、`height` | `600`、`1080` | px 或任意 CSS 长度。**默认值受视口限制**:宽 = `min(600px, 100vw)`(外壳另有 `max-width: 100%`,窄容器不溢出),高 = `min(1080px, 100svh)`(悬浮 `position` 还会扣掉 `--xc-offset-x/y` 边距,整块不会顶出屏幕);显式传入的值原样使用。运行时 `setSize(w, h)`(传 `undefined` = 恢复默认) |
311
365
  | `position` | `'inline'` | `'inline' \| 'bottom-right' \| 'bottom-left'` |
312
- | `draggable` | `false` | 悬浮模式下显示拖动手柄 |
313
- | `lang`、`model` | — | `'zh-CN' \| 'en' \| 'ja'`;服装 key(如 `xiaochun_maid`)或 https `.vrm` URL |
314
- | `ui` | `false` | 显示 embed 内置的聊天栏 |
366
+ | `draggable` | `false` | 手势拖动:按住角色(不要压在内置按钮上)拖 = 移动 iframe,限制在视口内;内联 / 悬浮模式都生效。见下文「手势」 |
367
+ | `borderRadius` | 非透明 `20px` / 透明 `0` | 外壳圆角(数字 = px 或任意 CSS 长度)。light / dark 场景默认与桌面版窗口圆角同值(20px),透明场景不裁角。运行时用 `setBorderRadius()`;`--xc-radius` CSS 变量优先;`0` = 方角 |
368
+ | `resizable` | `false` | 拖四个角缩放 iframe(与桌面版同一套 40px 热区 / 光标 / 圆弧),运行时状态,**不重建 iframe**。可传 `true` 或 `{ minWidth, minHeight, maxWidth, maxHeight }`(默认最小 120×180,最大 = 视口) |
369
+ | `lang` | 自动 | `'zh-CN' \| 'en' \| 'ja'`。优先级:此选项 > 用户上次在内置语言按钮里选的(存在 iframe 自己的 localStorage `xiaochun_embed_lang`)> 浏览器语言 > `zh-CN`。运行时 `setConfig({ lang })` 不持久化。变化时发 `lang-changed` |
370
+ | `outfit` | 默认服装 | 初始服装 id(如 `xiaochun_maid`,见 `getOutfits()`)。未知 id 回退默认服装并触发 `error { code: 'unknown_id' }` |
371
+ | `model` | — | **已弃用**,请改用 `outfit`(会打印 `console.warn`)。不再接受 https URL,见 `allowCustomModel` |
372
+ | `allowCustomModel` | `false` | 显式开启后才允许 `setModel({ url })` 加载任意 https `.vrm` / `.vrmaddon` / `.vrmbase`。第三方文件会在 iframe 里解析,仅对可信 URL 打开 |
373
+ | `camera` | — | 相机取景 `{ fov, distance, height, intro }`:`fov` 15–60°(默认 30,视距按 fov 自动补偿,角色大小基本不变),`distance` 1–15 米(默认约 2.5;首次取景时盖过 iframe 保存的缩放 / 俯仰),`height` ±1 米取景高度偏移(iframe 不保存),`intro: false` 不播推镜头。越界值夹到范围;没有 `pitch`(相机只绕角色上下俯仰,左右是角色自身的 bodyTurn)。创建期写进 `?cameraFov=` 等 URL 参数;运行时用 `setConfig({ camera })`(缺省键不变、`null` 恢复默认,立即重新取景并取消进行中的推镜头)。旧版 `/embed`(无 `capabilities.camera`)忽略。优先级:显式 > iframe 保存的视角 > 默认。 |
374
+ | `persistBox` | `false` | 在**宿主页** localStorage 记住拖动 / 缩放后的位置和大小,下次创建时恢复:`true`(key `xiaochun:box`)或命名空间字符串(`xiaochun:box:<名字>`)。需要 `draggable` / `resizable`。优先级:显式 `width` / `height` > 已保存 > 默认;恢复时钳制到当前视口。`clearPersistedBox({ reset? })` 清除。见「手势」一节 |
375
+ | `persist` | `false` | 可选:另把服装 + 场景偏好保存在**宿主页**的 localStorage(iframe 本来就会存一份自己的):`false` \| `'host'`(`xiaochun:prefs`)\| 自定义 key。显式的 `outfit` / `scene` 选项优先于已保存的偏好;宿主保存的优先于 iframe 自己存的 |
376
+ | `prefetch` | `false` | `true`(全部服装,婚纱 13.9 MB 除外)或 `string[]`。首次加载完成后自动发一次,**仅在 `heavy: 'eager'` 时**;否则请自己调用 `prefetch()` |
377
+ | `ui` | `[]` | iframe 内要显示的内置界面部件,数组,可选 `'chat'`(聊天栏)· `'bubble'`(头顶气泡)· `'outfit'`(换装按钮)· `'scene'`(换场景按钮)· `'lang'`(语言切换)· `'github'`(GitHub 链接,新标签页打开)。不写/空 = 都不显示;未知名字被忽略并 `console.warn`。见下文「内置按钮」 |
378
+ | `uiAutoHide` | `'transparent'` | 内置界面何时显示,**与桌面端一致**:`'transparent'` = 只有透明(桌宠)场景初始隐藏、单击角色才出现,亮 / 暗场景常显;`true` = 所有场景都点击才出现;`false` = 一直显示(旧行为)。运行时 `setConfig({ uiAutoHide })` 热切换 |
315
379
  | `heavy` | `'lazy'` | `'lazy'`:首次使用才加载 WebLLM / EMAGE · `'eager'`:预加载 |
316
- | `controls` | `false` | 放开 iframe 内滚轮缩放(会吞掉页面滚动) |
380
+ | `controls` | `true` | iframe 内滚轮缩放,默认开(与主站一致)。透明场景:只有指针在角色上才缩放,其余位置滚轮仍滚动宿主页;不透明场景:iframe 铺满,该区域内滚轮 = 缩放,**会吞掉该区域的页面滚动**。`false` 锁定(`?controls=0`) |
317
381
  | `autoPause` | `true` | 滚出视口自动暂停 |
318
382
  | `passthrough` | = `transparent` | 按"鼠标是否在角色上"切换 iframe 的 pointer-events |
319
383
  | `sandbox` | scripts + same-origin + popups | iframe `sandbox`;`false` = 不加。去掉 `allow-same-origin` 会让 IndexedDB 和麦克风失效 |
@@ -321,32 +385,66 @@ createXiaochun({
321
385
  | `crossOriginIsolated` | `false` | 给 iframe 的 `allow` 追加 `cross-origin-isolated`(默认仍是 `microphone; autoplay`)。要求宿主页自己已跨源隔离,见 [可选:跨源隔离](#可选跨源隔离让-emage-用多线程) |
322
386
  | `zIndex` | `2147483000` | 悬浮模式层级(`--xc-z-index` 变量优先) |
323
387
 
324
- **实例**:`ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setModel(outfitOrUrl)` · `setConfig(cfg)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(协议已预留,目前返回 `unsupported`)*。
388
+ **实例**:`ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setOutfit(id)` · `setScene(id)` · `getOutfits()` · `getScenes()` · `prefetch(ids?)` · `outfit` / `scene`(只读)· `setModel(outfitOrUrl)` *(旧)* · `setConfig(cfg)` · `setSize(w, h)` · `getBox()` · `setDraggable(on)` · `setResizable(on | limits)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(协议已预留,目前返回 `unsupported`)*。
325
389
 
326
- **事件**:`handshake` · `ready` · `progress` · `state` · `stt` · `utterance`(`phase: 'start' | 'end'`,`kind: 'text' | 'audio'`)· `hit-region` · `error` · `destroy`。
390
+ **事件**:`handshake` · `ready` · `progress` · `state` · `stt` · `utterance`(`phase: 'start' | 'end'`,`kind: 'text' | 'audio'`)· `hit-region` · `outfit-changed` · `scene-changed` · `lang-changed`(`{ lang, previous?, initial? }`)· `move` / `resize`(`{ phase: 'start' | 'move' | 'end', left, top, width, height }`)· `error`(新增 `busy`、`unknown_id`;旧 iframe 上开手势会收到一次 `unsupported`)· `destroy`。`progress.phase` 为 `'model' | 'outfit' | 'prefetch'`。
327
391
 
328
392
  ### `<xiaochun-avatar>`
329
393
 
330
394
  | 属性 | 默认值 | 说明 |
331
395
  | :--- | :--- | :--- |
332
396
  | `src` | 官方 `/embed` | 修改会重建 iframe |
333
- | `model` | — | 服装 key,或 https `.vrm` / `.vrmaddon` / `.vrmbase` URL;运行时修改 = `setModel` |
334
- | `lang` | — | `zh-CN` · `en` · `ja`;运行时修改 = `setConfig` |
397
+ | `outfit` | — | 服装 id;运行时修改 = `setOutfit`(**热更新,不重建 iframe**) |
398
+ | `scene` | — | `light` · `dark` · `transparent`;运行时修改 = `setScene`(热更新) |
399
+ | `model` | — | `outfit` 的**已弃用**别名(`outfit` 优先) |
400
+ | `camera-fov` / `camera-distance` / `camera-height` / `camera-intro` | — | 同 `camera`(热更新 `setConfig({ camera })`,不重建;去掉属性 = 恢复默认) |
401
+ | `persist` / `persist-box` / `prefetch` / `allow-custom-model` | — | 同对应选项(`persist-box=""` = 默认 key,其它字符串 = 命名空间);修改会重建 |
402
+ | `lang` | 自动 | `zh-CN` · `en` · `ja`;不写 = 用户上次的选择 > 浏览器语言 > `zh-CN`;运行时修改 = `setConfig`(不持久化) |
335
403
  | `mic` | `false` | 开关听写(模型加载完成后生效) |
336
404
  | `transparent` | `true` | `"false"` 关闭 |
337
- | `draggable` | `false` | 仅悬浮模式 |
405
+ | `draggable` | `false` | 手势拖动(内联 / 悬浮都行);运行时修改 = `setDraggable`(热更新) |
406
+ | `resizable` | `false` | 拖角缩放;运行时修改 = `setResizable`(热更新) |
407
+ | `border-radius` | 非透明 `20px` / 透明 `0` | 外壳圆角(数字 = px 或 CSS 长度);运行时修改 = `setBorderRadius`(热更新) |
408
+ | `min-size` / `max-size` | — | 缩放限幅,格式同 `size`(如 `min-size="160x240"`) |
338
409
  | `position` | `inline` | `inline` · `bottom-right` · `bottom-left` |
339
- | `size` | `320x480` | `"280"`(高 = 宽 × 1.5)、`"320x480"`、`"100%x480px"` |
410
+ | `size` | `600x1080`(受视口限制) | `"280"`(高 = 宽 × 1.5)、`"320x480"`、`"100%x480px"`;运行时修改 = `setSize`(**热更新,不重建 iframe**) |
340
411
  | `lazy` | 空闲 + 视口 | `"click"` 仅点击;`"false"` 立即创建 |
341
412
  | `paused` | `false` | `pause()` / `resume()` |
342
- | `placeholder` / `heavy` / `ui` / `controls` / `allowed-origins` | — | 同 `createXiaochun` |
413
+ | `ui` | — | 逗号分隔的部件名,如 `ui="outfit,scene,lang,github"`(不写 = 都不显示;未知项忽略并 warn)。改它会重建;运行时用 `setConfig({ ui })` |
414
+ | `ui-autohide` | `transparent` | `"transparent"`(默认,仅透明场景点击才出现)· `"true"`(所有场景)· `"false"`(一直显示)。改它会重建;运行时用 `setConfig({ uiAutoHide })` |
415
+ | `controls` | 开 | `controls="false"` 锁定 iframe 内滚轮缩放 |
416
+ | `placeholder` / `heavy` / `allowed-origins` | — | 同 `createXiaochun` |
343
417
  | `cross-origin-isolated` | `false` | 同 `createXiaochun({ crossOriginIsolated })`;修改会重建 iframe |
344
418
 
345
- **事件**(`CustomEvent`,`composed`,`detail` = 协议 payload):`xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-error`。
346
- **方法**:`say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `destroy`;`el.client` 可拿到完整的 SDK 实例。
419
+ **事件**(`CustomEvent`,`composed`,`detail` = 协议 payload):`xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-outfit-changed` · `xc-scene-changed` · `xc-lang-changed` · `xc-move` · `xc-resize` · `xc-error`。
420
+ **方法**:`say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `setOutfit` · `setScene` · `getOutfits` · `getScenes` · `prefetch` · `destroy`;`el.client` 可拿到完整的 SDK 实例。
347
421
 
348
422
  ---
349
423
 
424
+ ### 手势(拖动与角落缩放)
425
+
426
+ iframe 可以拥有和桌面版一样的体验:**按住角色拖动 = 移动,拖四个角 = 缩放**。两者**默认关闭**并按实例协商:宿主没开时,iframe 不识别手势、不拦截指针事件、也不画角落圆弧。
427
+
428
+ ```ts
429
+ const xc = createXiaochun({
430
+ container: '#avatar', width: 320, height: 480,
431
+ draggable: true, // 移动:拖角色
432
+ resizable: { minWidth: 160, minHeight: 240, maxWidth: 640, maxHeight: 960 }, // 也可直接 true(最小 120×180,最大 = 视口)
433
+ });
434
+ xc.on('move', (b) => console.log(b.phase, b.left, b.top));
435
+ xc.on('resize', (b) => console.log(b.phase, b.width, b.height));
436
+ xc.setResizable(false); // 运行时开关,不重建 iframe
437
+ xc.setSize(240, 360); // 程序化改尺寸(同样热更新)
438
+ ```
439
+
440
+ * 识别逻辑复用应用共用的手势状态机(`src/core/gesture/`);iframe 只发 `xc.gesture-move` / `xc.gesture-resize` 增量(`gesture`、`seq`、`phase: start | move | end`、`dx/dy`、累计 `totalDx/totalDy`、缩放带 `corner`),**由宿主 SDK 执行**:校验 origin / 端口,丢弃伪造、重放、乱序的消息,并按最小 / 最大尺寸与视口限幅(内联模式用 CSS `translate` 移动,悬浮模式改 `left/top`)。
441
+ * 缩放是运行时状态:`width` / `height` / `size` / `draggable` / `resizable` 变化都**不会重建 iframe**(模型、动画、对话状态保留)。
442
+ * 透明场景 + 穿透:只有角色、(开了 `resizable` 时)四角 40px 热区会接管指针,其余位置仍穿透到你的页面;内置按钮不会成为拖动 / 缩放起点。
443
+ * 拖动时的选中蓝框两侧都已抑制(`user-select: none`、阻止 `selectstart` / `dragstart`、缩放时用 pointer capture)。
444
+ * 旧版 `/embed`(没有 `capabilities.gestures`)上开内联 `draggable` 或 `resizable`,会触发一次 `error { code: 'unsupported', command: 'gestures' }`。
445
+ * **记住位置和大小(`persistBox`,默认关)**:开了 `draggable` / `resizable` 后,`persistBox: true`(或命名空间字符串 → key `xiaochun:box:<名字>`)会在每次拖动 / 缩放**结束**时把盒子存进**宿主页**的 localStorage(读写都包了 try/catch,隐私模式 / 配额满只是不记忆),下次创建时恢复。优先级:显式 `width` / `height` > 已保存 > 默认(600×1080);位置没有显式选项(`position` 只是预设锚点),所以已保存的位置始终生效。**想恢复用户缩放后的大小,就不要传 `width` / `height`。** 恢复时钳制到*当前*视口(大小夹在 `[最小, 视口]`,最小 120×180 或你的 `resizable` 限幅;悬浮盒子整块拉回屏幕内;内联盒子横向夹进视口、纵向不越过文档顶部)。保存时的 `position` 模式与当前不同、或数据损坏,都会被忽略并清掉。只在开了手势时恢复。`xc.clearPersistedBox()` 删除已保存的值;`xc.clearPersistedBox({ reset: true })` 还会把盒子还原到初始位置 / 大小。元素属性 `persist-box`(`""` / `"true"` / 命名空间),React `persistBox` prop + `ref.clearPersistedBox()`。
446
+ * 试玩:`examples/embed-host.html?draggable=1&resizable=1&persistBox=1`。
447
+
350
448
  ## 🧪 本地试玩 (Try It Locally)
351
449
 
352
450
  打开 [`examples/embed-host.html`](./examples/embed-host.html),文件头部注释写了启动步骤。要连本地的小蠢开发服务器,传 `src: 'https://localhost:5185/embed'`。
package/README.md CHANGED
@@ -137,9 +137,9 @@ const { containerRef, client, ready, state } = useXiaochun({ width: 280, height:
137
137
  // <div ref={containerRef} style={{ width: 280, height: 420 }} />
138
138
  ```
139
139
 
140
- * **Props**: every `createXiaochun` option except `container`, plus `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onError / onDestroy`, `className`, `style`, `paused`, and `mic`.
141
- * **Rebuild vs. live update**: creation-time options (`src`, `lazy`, `transparent`, `width`, `height`, `position`, …) rebuild the instance when they change, so avoid passing fresh values on every render. `lang`, `model`, `paused`, `mic` and the callbacks update in place without recreating the iframe.
142
- * **Ref methods**: `say`, `speakAudio`, `speakAudioStream`, `motion`, `expression`, `lookAt`, `setModel`, `setConfig`, `startListening`, `stopListening`, `mic`, `pause`, `resume`, `activate`, `destroy`, plus `ready` and `instance`. Methods that return a Promise reject until the component is mounted.
140
+ * **Props**: every `createXiaochun` option except `container`, plus `onHandshake / onReady / onProgress / onState / onStt / onUtterance / onHitRegion / onMove / onResize / onError / onDestroy`, `className`, `style`, `paused`, and `mic`.
141
+ * **Rebuild vs. live update**: creation-time options (`src`, `lazy`, `transparent`, `position`, …) rebuild the instance when they change, so avoid passing fresh values on every render. `width`, `height`, `draggable`, `resizable` (effects call `setSize` / `setDraggable` / `setResizable`), `lang`, `outfit`, `scene` (and the deprecated `model`), `paused`, `mic` and the callbacks update in place without recreating the iframe (effects call `setOutfit` / `setScene` / `setConfig`). Also available: `onOutfitChanged`, `onSceneChanged`.
142
+ * **Ref methods**: `say`, `speakAudio`, `speakAudioStream`, `motion`, `expression`, `lookAt`, `setOutfit`, `setScene`, `getOutfits`, `getScenes`, `prefetch`, `setModel`, `setConfig`, `startListening`, `stopListening`, `mic`, `pause`, `resume`, `activate`, `destroy`, plus `ready` and `instance`. Methods that return a Promise reject until the component is mounted.
143
143
 
144
144
  ---
145
145
 
@@ -165,13 +165,64 @@ s.write(int16Chunk); s.write(next); s.end(); await s.done; // s.abort() stops
165
165
 
166
166
  ---
167
167
 
168
+ ## 👗 Outfits, Scenes & Prefetch
169
+
170
+ ```ts
171
+ const xc = createXiaochun({ container: '#avatar', outfit: 'xiaochun_maid', scene: 'light', persist: 'host' });
172
+ await xc.ready;
173
+
174
+ const outfits = await xc.getOutfits(); // [{ id, name }] (the bare base model is never listed)
175
+ const scenes = await xc.getScenes(); // [{ id: 'light' | 'dark' | 'transparent', transparent }]
176
+
177
+ await xc.setOutfit('xiaochun_cheongsam'); // resolves when the new outfit is live
178
+ await xc.setScene('transparent'); // wrapper background + pass-through follow automatically
179
+ xc.on('outfit-changed', (p) => console.log(p.id, p.previous));
180
+ xc.on('scene-changed', (p) => console.log(p.id));
181
+ ```
182
+
183
+ * **Ids are validated** (`/^[a-z][a-z0-9_]{0,63}$/` + an own-property whitelist). A malformed id rejects locally with `[bad_request]`; a well-formed unknown id (`constructor`, `base`, ...) rejects with `[unknown_id]`. Nothing is sent for the former, and the iframe re-validates the latter on its own.
184
+ * **Concurrency**: swaps are serial and **last-wins**. A swap that is loading is never aborted; a queued swap superseded by a newer call rejects with `[busy]` (safe to ignore). Same-target requests are merged; asking for the current outfit resolves immediately. **Speech is never interrupted**: if the avatar is talking, the new outfit loads in the background and appears when ready.
185
+ * **Scenes**: only the three built-in themes. Switching at runtime syncs the wrapper background, enables/disables the pointer pass-through listeners, forces `pointer-events: auto` for opaque scenes and resets the hit cache.
186
+ * **Capability negotiation**: protocol stays v1. Against an older `/embed` (no `capabilities.outfits` / `scenes` / `prefetch` in `xc.ready`), the new methods reject with `[unsupported]` and `getOutfits()` / `getScenes()` return `[]`.
187
+ * **Preferences**: the iframe remembers the user's last outfit/scene in **its own** localStorage (`xiaochun_wearing_outfit` / `xiaochun_scene_theme`, the same keys as the main site). Priority: explicit `outfit` / `scene` (URL or SDK options) > saved > default; unknown saved ids are ignored and cleared; if storage is blocked or partitioned it silently falls back to the defaults. Third-party storage is partitioned per top-level site, so each host site has its own copy.
188
+ * **`persist`** (optional) additionally keeps them in the host page's localStorage and passes them back as explicit values, so it overrides the iframe's own copy. Default `false`.
189
+ * **`prefetch`** (off by default) downloads outfit files into the iframe's IndexedDB only (no decode/compose), serially, after EMAGE is loaded, never the wedding outfit unless you name it. `await xc.prefetch(['xiaochun_cheongsam'])` works in any mode; the automatic variant needs `heavy: 'eager'`. Skipped when the user enabled Data Saver. A later `setOutfit` then skips the network (the ~0.9 s decode/compose remains).
190
+ * **Built-in buttons**: see the next section (`ui: ['outfit', 'scene', 'lang', 'github']`, `uiAutoHide`).
191
+ * **Custom models**: `setModel({ url })` is **off by default**; pass `allowCustomModel: true` to opt in.
192
+ * **Migrating**: option `model` → `outfit`; the legacy `setModel('base')` no longer works (use `getOutfits()` for the list).
193
+
194
+ ### 🔘 Built-in buttons (`ui`, `uiAutoHide`)
195
+
196
+ `ui` is an **array of part names**; nothing is shown unless you list it.
197
+
198
+ ```ts
199
+ createXiaochun({ container: '#avatar', ui: ['outfit', 'scene', 'lang', 'github'] }); // parts: chat · bubble · outfit · scene · lang · github
200
+ ```
201
+ ```html
202
+ <xiaochun-avatar ui="outfit,scene,lang,github" ui-autohide="false"></xiaochun-avatar> <!-- URL form: /embed?ui=outfit,scene,lang,github&uiAutoHide=false -->
203
+ ```
204
+ React: `<Xiaochun ui={['outfit', 'scene']} />`. At runtime: `xc.setConfig({ ui: ['outfit'] })`.
205
+
206
+ * **When it shows (`uiAutoHide`, same as the desktop app)**: default `'transparent'` — in the **transparent** scene the buttons and chat bar start hidden and a **single click on the character** shows them (click the character again, or empty space, to hide; they also hide after 10 s idle, and stay while you hover them or a menu is open); in light / dark scenes they are always visible. `true` extends click-to-show to every scene, `false` keeps them always visible. Dragging the iframe (`draggable`) never counts as a click (≤ 6 px movement). Limits: in the transparent scene a click on blank host-page space never reaches the iframe (pass-through), so hiding relies on the 10 s timeout or clicking the character; on touch the first tap only wakes hit detection. Needs an iframe with `capabilities.ui.autoHide`; older iframes ignore it and always show.
207
+ * **Language / GitHub buttons**: `lang` opens a menu (简体中文 / English / 日本語); the choice applies immediately, is saved in the **iframe's own** localStorage (`xiaochun_embed_lang`) and fires `lang-changed` (`{ lang, previous?, initial? }`; one `initial: true` after the handshake). Only the user's own pick is saved — an explicit `lang` option and `setConfig({ lang })` are not. `github` is an `<a target="_blank" rel="noopener noreferrer">` to the project repo.
208
+ * **Same components as the main site's TopHeader**: glass buttons (44×44 on touch, 36×36 on desktop), the dropdown menu, the `Shirt` / `MountainSnow` / `Check` / `Loader2` icons and the same i18n strings (follows `lang`). Placed top-right so the character stays uncovered. The list comes from `capabilities.outfits` (no bare model, no custom URLs) and shows just the outfit names (no file sizes in the menu; `capabilities.outfits[].sizeMB` is still provided for your own UI), a spinner on the loading row and a ✓ on the worn one.
209
+ * **Same path as `setOutfit` / `setScene`**: same whitelist, same serial last-wins queue, a light "busy" hint when you click fast, and the usual `outfit-changed` / `scene-changed` events. Calls you make through the SDK keep the buttons in sync. Clicking a button never triggers the body-turn / drag gestures.
210
+ * **Transparent scene**: the buttons take part in the pass-through hit test (clicks on them do not fall through to your page; blank space still does). On touch + transparent, the first tap only wakes hit detection (it lands on your page), the second tap reaches the button.
211
+ * **Persistence**: the iframe saves button / `setOutfit` / `setScene` changes in its own localStorage and restores them on reload (explicit `outfit` / `scene` still win). To keep your own copy (e.g. across sites), listen to the events and pass the values back as explicit options:
212
+ ```ts
213
+ xc.on('outfit-changed', (p) => { if (!p.initial) localStorage.setItem('my-outfit', p.id); });
214
+ xc.on('scene-changed', (p) => { if (!p.initial) localStorage.setItem('my-scene', p.id); });
215
+ // or simply persist: 'host'
216
+ ```
217
+ * **Wheel zoom** (`controls`, default on) is described in the options table above, including the "swallows page scrolling over an opaque iframe" side effect; use `controls: false` to keep the old behaviour.
218
+
168
219
  ## 🎨 Styling (CSS Variables & `::part`)
169
220
 
170
221
  The host can restyle the **shell** only. The character lives in a cross-origin iframe, so your CSS cannot reach inside it.
171
222
 
172
223
  | Variable | Default | Effect |
173
224
  | :--- | :--- | :--- |
174
- | `--xc-radius` | `0` | Corner radius (any CSS length, e.g. `24px`; `50%` makes a round frame). Larger is rounder; too large clips the head and feet |
225
+ | `--xc-radius` | opaque `20px` / transparent `0` | Corner radius (any CSS length, e.g. `24px`; `50%` makes a round frame). Larger is rounder; too large clips the head and feet |
175
226
  | `--xc-shadow` | `none` | `box-shadow` shorthand. Keep `none` for transparent floating avatars |
176
227
  | `--xc-z-index` | `2147483000` | Floating mode only. Lower it so your modals and nav sit above the avatar |
177
228
  | `--xc-offset-x` / `--xc-offset-y` | `16px` | Floating mode only: distance from the side / bottom edge |
@@ -188,6 +239,8 @@ xiaochun-avatar::part(iframe) { outline: 1px solid #0002; }
188
239
 
189
240
  Parts: `mount` · `wrapper` · `iframe` · `placeholder`.
190
241
 
242
+ **No blue selection tint**: when a host-page text selection crosses the iframe (drag-select, Cmd/Ctrl+A), Chrome paints the whole iframe blue. The SDK sets `user-select: none` (inline CSSOM styles, no `<style>` injected, so host CSP is unaffected) on the shell, placeholder and iframe; your own text stays selectable and pointer-events / transparent pass-through are untouched. Don't override `user-select` on `::part(iframe)` back to `auto`.
243
+
191
244
  ---
192
245
 
193
246
  ## 🔌 `xiaochun://` and `xc.*`
@@ -306,14 +359,25 @@ createXiaochun({
306
359
  | `lazy` | `true` | `true` / `'idle'`: visible and idle · `'click'`: on click or first API call · `false`: immediately |
307
360
  | `lazyMargin` | `200` | rootMargin in px for the visibility trigger. Larger loads earlier and uses more data |
308
361
  | `placeholder` | built-in SVG | Image URL, element, or `false` |
309
- | `transparent` | `false` | Transparent background over your page (with pointer pass-through) |
310
- | `width`, `height` | `320`, `480` | px or any CSS length. Always set them |
362
+ | `transparent` | `false` | Transparent background over your page (with pointer pass-through). Same as `scene: 'transparent'` |
363
+ | `scene` | — | Initial scene: `'light' \| 'dark' \| 'transparent'` (see `getScenes()`). Unknown id → ignored + `error { code: 'unknown_id' }` |
364
+ | `width`, `height` | `600`, `1080` | px or any CSS length. **Defaults are clamped to the viewport**: width = `min(600px, 100vw)` (and the shell has `max-width: 100%`, so it never overflows a narrow container), height = `min(1080px, 100svh)` (floating `position`s also subtract the `--xc-offset-x/y` margins so the box never sticks out of the screen). Explicit values are used as-is. Runtime: `setSize(w, h)` (`undefined` = back to the default) |
311
365
  | `position` | `'inline'` | `'inline' \| 'bottom-right' \| 'bottom-left'` |
312
- | `draggable` | `false` | Drag handle in floating mode |
313
- | `lang`, `model` | — | `'zh-CN' \| 'en' \| 'ja'`; outfit key (e.g. `xiaochun_maid`) or an https `.vrm` URL |
314
- | `ui` | `false` | Show the embed's built-in chat bar |
366
+ | `draggable` | `false` | Gesture drag: press and drag the character (not a built-in button) to move the iframe, clamped to the viewport. Works in inline and floating modes. See [Gestures](#gestures-drag-and-corner-resize) |
367
+ | `borderRadius` | opaque `20px` / transparent `0` | Shell corner radius (number = px, or any CSS length). The default for light / dark scenes equals the desktop app's window radius (20 px); transparent scenes are not clipped. Live via `setBorderRadius()`; the `--xc-radius` CSS variable wins; `0` = square corners |
368
+ | `resizable` | `false` | Drag a corner (same 40 px hot zone / cursors / arcs as the desktop app) to resize the iframe at runtime, **without rebuilding it**. `true` or `{ minWidth, minHeight, maxWidth, maxHeight }` (default min 120×180, max = viewport) |
369
+ | `lang` | auto | `'zh-CN' \| 'en' \| 'ja'`. Priority: this option > the language the user last picked in the built-in language button (saved in the iframe's own localStorage `xiaochun_embed_lang`) > browser language > `zh-CN`. `setConfig({ lang })` at runtime is not persisted. Changes fire `lang-changed` |
370
+ | `outfit` | default outfit | Initial outfit id (e.g. `xiaochun_maid`, see `getOutfits()`). Unknown id → default outfit + `error { code: 'unknown_id' }` |
371
+ | `model` | — | **Deprecated**, use `outfit` (a `console.warn` is printed). An https URL is no longer accepted here; see `allowCustomModel` |
372
+ | `allowCustomModel` | `false` | Opt in to `setModel({ url })` for arbitrary https `.vrm` / `.vrmaddon` / `.vrmbase`. Third-party files are parsed inside the iframe, so enable it only for URLs you trust |
373
+ | `camera` | — | Camera framing `{ fov, distance, height, intro }`: `fov` 15–60° (default 30; distance auto-compensates so the character keeps its size), `distance` 1–15 m (default ≈2.5; overrides the iframe's saved zoom/pitch on first fit), `height` ±1 m framing offset (not saved by the iframe), `intro: false` skips the dolly-in. Out-of-range values are clamped; there is no `pitch` (the camera only tilts up/down around the character, left/right is the character's body turn). Sent as `?cameraFov=` etc. at create time; at runtime use `setConfig({ camera })` (omitted key = unchanged, `null` = back to default; re-frames immediately and cancels a running dolly-in). Old `/embed` (no `capabilities.camera`) ignores it. Priority: explicit > the iframe's saved view > default. |
374
+ | `persistBox` | `false` | Remember the iframe's position & size after drag / resize in **your page's** localStorage and restore it on the next create: `true` (key `xiaochun:box`) or a namespace string (`xiaochun:box:<name>`). Needs `draggable` / `resizable`. Priority: explicit `width` / `height` > saved > default; restore is clamped to the current viewport. `clearPersistedBox({ reset? })` clears it. See [Gestures](#gestures-drag-and-corner-resize) |
375
+ | `persist` | `false` | Optionally also remember outfit + scene in **the host page's** localStorage (the iframe already keeps its own copy): `false` \| `'host'` (`xiaochun:prefs`) \| a custom key. Explicit `outfit` / `scene` options win over saved values; the saved copy wins over the iframe's own |
376
+ | `prefetch` | `false` | `true` (all outfits except the 13.9 MB wedding) or `string[]`. Auto-sent once after the first load, **only with `heavy: 'eager'`**; otherwise call `prefetch()` yourself |
377
+ | `ui` | `[]` | Built-in UI parts to show inside the iframe: an array of `'chat'` (chat bar) · `'bubble'` (speech bubble) · `'outfit'` (outfit button) · `'scene'` (scene button) · `'lang'` (language switcher) · `'github'` (GitHub link, opens in a new tab). Unset / empty = none. Unknown names are ignored with a `console.warn`. See "Built-in buttons" below |
378
+ | `uiAutoHide` | `'transparent'` | When the built-in UI is visible, **same as the desktop app**: `'transparent'` = only the transparent (desk-pet) scene hides it until you click the character; light / dark scenes always show it. `true` = click-to-show in every scene; `false` = always visible (the previous behaviour). Live via `setConfig({ uiAutoHide })` |
315
379
  | `heavy` | `'lazy'` | `'lazy'`: load WebLLM / EMAGE on first use · `'eager'`: preload |
316
- | `controls` | `false` | Allow wheel-zoom inside the iframe (swallows page scrolling) |
380
+ | `controls` | `true` | Wheel zoom inside the iframe, on by default like the main site. Transparent scene: zooms only while the pointer is on the character, everywhere else the wheel scrolls your page. Opaque scenes: the iframe fills its area, so the wheel zooms there and **swallows page scrolling over that area**. `false` locks it (`?controls=0`) |
317
381
  | `autoPause` | `true` | Pause when out of the viewport |
318
382
  | `passthrough` | = `transparent` | Toggle the iframe's pointer-events depending on whether the cursor is over the character |
319
383
  | `sandbox` | scripts + same-origin + popups | iframe `sandbox`; `false` = none. Dropping `allow-same-origin` breaks IndexedDB and the mic |
@@ -321,32 +385,66 @@ createXiaochun({
321
385
  | `crossOriginIsolated` | `false` | Append `cross-origin-isolated` to the iframe `allow` (default stays `microphone; autoplay`). Needs a host page that is itself isolated; see [Opt-in: cross-origin isolation](#opt-in-cross-origin-isolation-multi-threaded-emage) |
322
386
  | `zIndex` | `2147483000` | Floating mode layer (the `--xc-z-index` variable takes precedence) |
323
387
 
324
- **Instance**: `ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setModel(outfitOrUrl)` · `setConfig(cfg)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(reserved in the protocol; currently returns `unsupported`)*.
388
+ **Instance**: `ready` · `say(text, { mode: 'speak' \| 'chat' })` · `speakAudio(source, opts)` · `speakAudioStream(opts)` · `motion(nameOrUrlOrOptions)` · `expression(name)` · `setOutfit(id)` · `setScene(id)` · `getOutfits()` · `getScenes()` · `prefetch(ids?)` · `outfit` / `scene` (getters) · `setModel(outfitOrUrl)` *(legacy)* · `setConfig(cfg)` · `setSize(w, h)` · `getBox()` · `setDraggable(on)` · `setResizable(on | limits)` · `startListening()` / `stopListening()` / `mic(on)` · `pause()` / `resume()` · `activate()` · `destroy()` · `on(event, cb)` · `lookAt()` *(reserved in the protocol; currently returns `unsupported`)*.
325
389
 
326
- **Events**: `handshake` · `ready` · `progress` · `state` · `stt` · `utterance` (`phase: 'start' | 'end'`, `kind: 'text' | 'audio'`) · `hit-region` · `error` · `destroy`.
390
+ **Events**: `handshake` · `ready` · `progress` · `state` · `stt` · `utterance` (`phase: 'start' | 'end'`, `kind: 'text' | 'audio'`) · `hit-region` · `outfit-changed` · `scene-changed` · `lang-changed` (`{ lang, previous?, initial? }`) · `move` / `resize` (`{ phase: 'start' | 'move' | 'end', left, top, width, height }`) · `error` (now also `busy`, `unknown_id`; one `unsupported` if gestures are enabled on an old iframe) · `destroy`. `progress.phase` is `'model' | 'outfit' | 'prefetch'`.
327
391
 
328
392
  ### `<xiaochun-avatar>`
329
393
 
330
394
  | Attribute | Default | Description |
331
395
  | :--- | :--- | :--- |
332
396
  | `src` | official `/embed` | Changing it rebuilds the iframe |
333
- | `model` | — | Outfit key or https `.vrm` / `.vrmaddon` / `.vrmbase` URL; changing it at runtime = `setModel` |
334
- | `lang` | — | `zh-CN` · `en` · `ja`; runtime change = `setConfig` |
397
+ | `outfit` | — | Outfit id; changing it at runtime = `setOutfit` (**live update, no iframe rebuild**) |
398
+ | `scene` | — | `light` · `dark` · `transparent`; runtime change = `setScene` (live) |
399
+ | `model` | — | **Deprecated** alias of `outfit` (`outfit` wins) |
400
+ | `camera-fov` / `camera-distance` / `camera-height` / `camera-intro` | — | Same as `camera` (hot-updated via `setConfig({ camera })`, no rebuild; removing an attribute = default) |
401
+ | `persist` / `persist-box` / `prefetch` / `allow-custom-model` | — | Same as the options (`persist-box=""` = default key, any other string = namespace); changing them rebuilds |
402
+ | `lang` | auto | `zh-CN` · `en` · `ja`; unset = the user's saved pick > browser language > `zh-CN`; runtime change = `setConfig` (not persisted) |
335
403
  | `mic` | `false` | Toggle dictation (takes effect once the model is loaded) |
336
404
  | `transparent` | `true` | `"false"` turns it off |
337
- | `draggable` | `false` | Floating mode only |
405
+ | `draggable` | `false` | Gesture drag (inline and floating). Runtime change = `setDraggable` (live) |
406
+ | `resizable` | `false` | Corner drag resize; runtime change = `setResizable` (live) |
407
+ | `border-radius` | opaque `20px` / transparent `0` | Shell corner radius (number = px or CSS length); runtime change = `setBorderRadius` (live) |
408
+ | `min-size` / `max-size` | — | Resize limits, same format as `size` (e.g. `min-size="160x240"`) |
338
409
  | `position` | `inline` | `inline` · `bottom-right` · `bottom-left` |
339
- | `size` | `320x480` | `"280"` (height = width × 1.5), `"320x480"`, `"100%x480px"` |
410
+ | `size` | `600x1080` (viewport-clamped) | `"280"` (height = width × 1.5), `"320x480"`, `"100%x480px"`; runtime change = `setSize` (**live, no iframe rebuild**) |
340
411
  | `lazy` | idle + viewport | `"click"` for click only; `"false"` for immediate |
341
412
  | `paused` | `false` | `pause()` / `resume()` |
342
- | `placeholder` / `heavy` / `ui` / `controls` / `allowed-origins` | — | Same as `createXiaochun` |
413
+ | `ui` | — | Comma-separated parts, e.g. `ui="outfit,scene,lang,github"` (unset = none; unknown names ignored with a warn). Changing it rebuilds; use `setConfig({ ui })` at runtime |
414
+ | `ui-autohide` | `transparent` | `"transparent"` (default; click-to-show only in the transparent scene) · `"true"` (every scene) · `"false"` (always visible). Changing it rebuilds; use `setConfig({ uiAutoHide })` at runtime |
415
+ | `controls` | on | `controls="false"` locks wheel zoom inside the iframe |
416
+ | `placeholder` / `heavy` / `allowed-origins` | — | Same as `createXiaochun` |
343
417
  | `cross-origin-isolated` | `false` | Same as `createXiaochun({ crossOriginIsolated })`; changing it rebuilds the iframe |
344
418
 
345
- **Events** (`CustomEvent`, `composed`, `detail` = protocol payload): `xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-error`.
346
- **Methods**: `say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `destroy`; `el.client` gives you the full SDK instance.
419
+ **Events** (`CustomEvent`, `composed`, `detail` = protocol payload): `xc-ready` · `xc-progress` · `xc-state` · `xc-stt` · `xc-utterance` · `xc-outfit-changed` · `xc-scene-changed` · `xc-lang-changed` · `xc-move` · `xc-resize` · `xc-error`.
420
+ **Methods**: `say` · `speakAudio` · `speakAudioStream` · `motion` · `expression` · `setOutfit` · `setScene` · `getOutfits` · `getScenes` · `prefetch` · `destroy`; `el.client` gives you the full SDK instance.
347
421
 
348
422
  ---
349
423
 
424
+ ### Gestures (drag and corner resize)
425
+
426
+ The iframe can behave like the desktop app: **press and drag the character to move it, drag a corner to resize it**. Both are **off by default** and negotiated per instance, so an iframe whose host did not opt in does not recognise gestures, does not intercept pointer events, and draws no corner arcs.
427
+
428
+ ```ts
429
+ const xc = createXiaochun({
430
+ container: '#avatar', width: 320, height: 480,
431
+ draggable: true, // move: drag the character
432
+ resizable: { minWidth: 160, minHeight: 240, maxWidth: 640, maxHeight: 960 }, // or just `true` (min 120×180, max = viewport)
433
+ });
434
+ xc.on('move', (b) => console.log(b.phase, b.left, b.top));
435
+ xc.on('resize', (b) => console.log(b.phase, b.width, b.height));
436
+ xc.setResizable(false); // runtime switches, no iframe rebuild
437
+ xc.setSize(240, 360); // programmatic resize (also live)
438
+ ```
439
+
440
+ * Recognition reuses the app's shared gesture state machine (`src/core/gesture/`); the iframe only posts `xc.gesture-move` / `xc.gesture-resize` increments (`gesture`, `seq`, `phase: start | move | end`, `dx/dy`, cumulative `totalDx/totalDy`, `corner` for resize). **The host SDK executes them**: it checks the origin/port, rejects forged, replayed or out-of-order messages, and clamps to the min/max size and the viewport (inline mode moves with a CSS `translate`; floating mode moves `left/top`).
441
+ * Resizing is runtime state: `width` / `height` / `size` / `draggable` / `resizable` changes never rebuild the iframe (the model, animation and chat state survive).
442
+ * Transparent scene + pass-through: only the character and (when `resizable`) the four 40 px corner zones take over the pointer; everywhere else still falls through to your page. Built-in buttons are never a drag / resize start.
443
+ * The blue text-selection box during a drag is suppressed on both sides (`user-select: none`, `selectstart` / `dragstart` blocked, pointer capture while resizing).
444
+ * Against an older `/embed` without `capabilities.gestures`, enabling inline `draggable` or `resizable` emits one `error { code: 'unsupported', command: 'gestures' }`.
445
+ * **Remember position & size (`persistBox`, off by default)**: with `draggable` / `resizable` on, `persistBox: true` (or a namespace string → key `xiaochun:box:<name>`) saves the box to **your page's** localStorage when a drag / resize **ends** (all reads / writes are try/catch'd — private mode or a full quota just means "not remembered") and restores it the next time the instance is created. Priority: explicit `width` / `height` > saved > default (600×1080); position has no explicit option (`position` is only an anchor preset), so a saved position always wins. **To get a user-resized size back, don't pass `width` / `height`.** Restoring is clamped to the *current* viewport (size within `[min, viewport]`, min 120×180 or your `resizable` limits; floating boxes pulled fully on-screen; inline boxes clamped horizontally and kept below the document top). A saved box whose `position` mode differs from the current one, or corrupt data, is ignored and removed. Only restored when a gesture is enabled. `xc.clearPersistedBox()` removes the saved value; `xc.clearPersistedBox({ reset: true })` also puts the box back to its initial place / size. Element: `persist-box` (`""` / `"true"` / a namespace), React: `persistBox` prop + `ref.clearPersistedBox()`.
446
+ * Try it: `examples/embed-host.html?draggable=1&resizable=1&persistBox=1`.
447
+
350
448
  ## 🧪 Try It Locally
351
449
 
352
450
  Open [`examples/embed-host.html`](./examples/embed-host.html); the header comment lists the steps. To point it at a local XiaoChun dev server, pass `src: 'https://localhost:5185/embed'`.
@@ -1,18 +1,30 @@
1
1
  /**
2
2
  * avatar-element.ts — `<xiaochun-avatar>` Web Component (Shadow DOM 内包 iframe)。
3
3
  *
4
- * 属性: src model lang mic transparent draggable position size lazy paused placeholder heavy ui controls allowed-origins cross-origin-isolated
4
+ * 属性: src outfit scene model(deprecated) lang mic transparent draggable resizable min-size max-size border-radius position size lazy paused placeholder heavy ui ui-autohide controls allowed-origins cross-origin-isolated persist allow-custom-model
5
5
  * - 布尔属性: 缺省取默认值; "" / "true" = true; "false" = false。
6
- * - size="320x480" 或 size="320" (高 = 宽 × 1.5); 也可写 CSS 长度 "100%x480px"。
7
- * - model: 内置服装 key (xiaochun_maid) 或 https .vrm/.vrmaddon/.vrmbase URL。
6
+ * - size="320x480" 或 size="320" (高 = 宽 × 1.5); 也可写 CSS 长度 "100%x480px"。缺省 = 600x1080 (宽不超过容器、高不超过视口)。改 size **热更新** (setSize, 不重建 iframe)。
7
+ * - draggable: 手势拖动 (角色上按住拖, 内联 / 悬浮都行); resizable: 四角缩放热区 (对角固定, 不重建 iframe); 两者默认关, 改属性热更新。
8
+ * min-size / max-size="120x180": resizable 的最小 / 最大尺寸 (px, 缺省最小 120x180、最大只受视口限制)。
9
+ * - border-radius: 外壳圆角 (数字 = px 或 CSS 长度); 缺省: 非透明场景 20px (与 Tauri 桌宠窗口同值) / 透明 0; 改属性热更新 (setBorderRadius)。
10
+ * - outfit: 内置服装 id (xiaochun_maid); 改属性**热更新** (setOutfit, 不重建 iframe)。model 是 outfit 的旧别名 (deprecated)。
11
+ * - scene: light | dark | transparent; 改属性**热更新** (setScene, 不重建 iframe)。
12
+ * - ui: 要显示的 iframe 内置界面部件, 逗号分隔: chat,bubble,outfit,scene,lang,github (例 ui="outfit,scene,lang,github"); 缺省 = 都不显示; 改它会重建。
13
+ * 旧写法 ui / ui="true" 已弃用 (= chat,bubble, 会 console.warn)。
14
+ * - ui-autohide: 内置界面的显示策略: 缺省 / "transparent" = 与 Tauri 一致 (只在透明场景点击出现, 亮暗场景常显); "true" = 所有场景点击出现; "false" = 一直显示。改它会重建。
15
+ * - persist-box: 记住用户拖动 / 缩放后的位置和大小 (宿主 localStorage): "" / "true" = 默认 key, 其它字符串 = 命名空间 (xiaochun:box:<名字>); 需配合 draggable / resizable; 显式 size 优先于已保存的大小。改它会重建。
16
+ * - persist: "host" 或自定义 localStorage key; 另把服装/场景偏好存在宿主页 (可选; 默认不存, iframe 自己的 localStorage 仍会记住)。
17
+ * - prefetch: "" / "true" = 预取全部内置服装 (婚纱除外), 或逗号分隔的 id 列表; 只在 heavy="eager" 时自动触发 (默认关)。
18
+ * - allow-custom-model: 允许 setModel({url}) 加载任意 https 模型 (默认关闭)。
8
19
  * 样式 (CSS 自定义属性, 可写在 <xiaochun-avatar> 上或任意祖先上; 取值范围/效果见 client.ts 里的注释与 docs/EMBED.md §样式):
9
20
  * --xc-radius --xc-shadow --xc-z-index --xc-offset-x --xc-offset-y --xc-bg
10
21
  * 可用 ::part() 定制外壳: ::part(mount) ::part(wrapper) ::part(iframe) ::part(placeholder) (iframe 内部不可被宿主 CSS 影响)
11
- * 事件 (CustomEvent, composed, detail = 协议 payload): xc-ready(模型加载完) xc-progress xc-state xc-stt xc-utterance xc-error
12
- * 方法: say(text) speakAudio(source, opts) speakAudioStream(opts) motion(m) expression(name) destroy() (+ client 属性拿到完整 SDK 实例)
22
+ * 事件 (CustomEvent, composed, detail = 协议 payload): xc-ready(模型加载完) xc-progress xc-state xc-stt xc-utterance xc-error xc-outfit-changed xc-scene-changed xc-lang-changed
23
+ * xc-move / xc-resize (用户拖动 / 缩放, detail = {phase, left, top, width, height})
24
+ * 方法: say(text) speakAudio(source, opts) speakAudioStream(opts) motion(m) expression(name) setOutfit(id) setScene(id) getOutfits() getScenes() destroy() (+ client 属性拿到完整 SDK 实例)
13
25
  */
14
26
  import { type XiaochunAudioOptions, type XiaochunAudioSource, type XiaochunAudioStream, type XiaochunInstance } from './client';
15
- import type { XcExpressionPayload, XcMotionPayload } from './protocol';
27
+ import type { XcExpressionPayload, XcMotionPayload, XcOutfitInfo, XcPrefetchedPayload, XcSceneInfo } from './protocol';
16
28
  /** 公开类型 (类本身在浏览器里才创建, 这样 SSR / Node 下 import 本包不会因 HTMLElement 缺失而抛错)。 */
17
29
  export interface XiaochunAvatarElement extends HTMLElement {
18
30
  client: XiaochunInstance | null;
@@ -26,6 +38,17 @@ export interface XiaochunAvatarElement extends HTMLElement {
26
38
  }): XiaochunAudioStream;
27
39
  motion(m: XcMotionPayload | string): Promise<void>;
28
40
  expression(name: XcExpressionPayload['name']): Promise<void>;
41
+ /** 换内置服装 (串行 + last-wins, 见 XiaochunInstance.setOutfit)。 */
42
+ setOutfit(id: string): Promise<void>;
43
+ setScene(id: string): Promise<void>;
44
+ getOutfits(): Promise<XcOutfitInfo[]>;
45
+ getScenes(): Promise<XcSceneInfo[]>;
46
+ /** 清除 persist-box 保存的位置 / 大小; { reset: true } 同时还原到初始位置和大小。 */
47
+ clearPersistedBox(opts?: {
48
+ reset?: boolean;
49
+ }): void;
50
+ /** 预取服装资源到 iframe 的 IndexedDB; ids 省略 = 全部 (婚纱除外)。 */
51
+ prefetch(ids?: string[]): Promise<XcPrefetchedPayload>;
29
52
  destroy(): void;
30
53
  }
31
54
  export declare const XIAOCHUN_ELEMENT_TAG = "xiaochun-avatar";