# 邀请卡自动弹出开关 + 移动端悬浮按钮 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为嵌入式组件 `widget.js` 新增两个 `data-*` 配置开关——`data-auto-invite`(控制邀请卡是否 1.8s 自动弹出)与 `data-mobile`(切换到移动端竖排悬浮按钮 D2),默认行为 100% 向后兼容。
**Architecture:** 复用 `widget.js` 既有的 `script.getAttribute("data-*")` 配置范式。`data-auto-invite` 在 `startInviteTimer()` 开头加一句短路;`data-mobile` 走「独立移动端 DOM 模板」分支(桌面模板逐字不动),并配套一份贴角的位置表 `positionStylesMobile` 与移动端 CSS。
**Tech Stack:** 原生 JavaScript(ES5 风格 IIFE + Shadow DOM)、静态 HTML。无构建步骤、无打包器。
**Spec:** [docs/superpowers/specs/2026-06-23-auto-invite-toggle-and-mobile-button-design.md](../specs/2026-06-23-auto-invite-toggle-and-mobile-button-design.md)
---
## ⚠️ 关于测试方式(重要)
本仓库为**纯静态 JS 前端**,**没有任何 JS 测试框架**(无 `package.json`,`tests/` 只有空的 `__init__.py`)。为本改动引入 JS 测试框架违反 YAGNI。
因此本计划**不使用自动化 TDD**,而是采用**手动浏览器验证**:每个 Task 末尾用浏览器打开页面、通过 DevTools 检查实际 DOM / 行为。验证标准写明「期望看到的结果」,与自动化断言等价。
**验证通用方法:**
- 方式 A(推荐,贴近真实部署):启动后端服务(`bash deploy.sh` 或 `docker compose up`),浏览器访问页面。
- 方式 B(最快,免启动服务):直接用浏览器打开本地 HTML 文件(`file://`)。组件的 WebSocket 会连不上,但**不影响本特性的验证**——邀请卡弹出与按钮渲染都在初始化时完成,与 WS 无关。
- 检查 Shadow DOM 内部:DevTools → Elements → 展开组件 Shadow Root,或在 Console 执行下文给出的命令。
---
## 文件结构(File Structure)
| 文件 | 职责 | 本次改动 |
|------|------|----------|
| `static/widget.js` | 嵌入式组件(第三方站点通过 `
```
刷新页面,等待 5 秒以上。
Expected:**邀请卡不再自动弹出**;悬浮按钮照常显示,点击仍能打开聊天面板。
验证通过后,**把 `data-auto-invite="false"` 从该标签删掉**(恢复成 Step 3 的状态,保持测试页的邀请卡回归用途)。Task 3 不会重新加它。
- [ ] **Step 5: 提交**
```bash
git add static/widget.js
git commit -m "$(cat <<'EOF'
feat(widget): 新增 data-auto-invite 控制邀请卡自动弹出
默认(true)保持 1.8s 自动弹出;设 data-auto-invite="false" 时
startInviteTimer 直接返回,邀请卡不再自动弹,悬浮按钮照常可用。
Co-Authored-By: Claude
EOF
)"
```
---
## Task 2: `data-mobile` —— 移动端竖排悬浮按钮(D2)+ 贴角位置
**Files:**
- Modify: `static/widget.js`(IIFE 顶部新增 `isMobile` 读取 + `positionStylesMobile` + `pos` 选择)
- Modify: `static/widget.js`(样式数组新增移动端 CSS)
- Modify: `static/widget.js`(`toggleBtn` 改为按 `isMobile` 分两套模板)
- [ ] **Step 1: 新增 `isMobile` 配置读取**
在 Task 1 新增的 `var autoInvite = ...` 之后、`var wsBase = ...` 之前,插入:
```js
var isMobile = script.getAttribute("data-mobile") === "true";
```
插入后该区域应为:
```js
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
var isMobile = script.getAttribute("data-mobile") === "true";
var wsBase = new URL(script.src).origin;
```
- [ ] **Step 2: 新增 `positionStylesMobile` 并切换 `pos`**
定位 `positionStyles` 定义(约第 16-21 行):
```js
var positionStyles = {
"bottom-right": { bottom: "80px", right: "10px" },
"bottom-left": { bottom: "20px", left: "20px" },
"top-right": { top: "20px", right: "20px" },
"top-left": { top: "20px", left: "20px" },
};
```
在其**下方**紧接着新增一份移动端位置表:
```js
var positionStylesMobile = {
"bottom-right": { bottom: "20px", right: "16px" },
"bottom-left": { bottom: "20px", left: "16px" },
"top-right": { top: "20px", right: "16px" },
"top-left": { top: "20px", left: "16px" },
};
```
然后定位 `pos` 计算(约第 29 行,紧接 `var c = colors[theme] || colors.light;` 之后):
```js
var pos = positionStyles[position] || positionStyles["bottom-right"];
```
改为按 `isMobile` 选择表:
```js
var pos = (isMobile ? positionStylesMobile : positionStyles)[position]
|| (isMobile ? positionStylesMobile : positionStyles)["bottom-right"];
```
> 目的:移动端圆钮仅 56px,沿用桌面 `bottom-right` 的 `bottom:80px` 会离底边偏高;移动端改用贴角的 ~20px。桌面端 `positionStyles` 逐字不动。
- [ ] **Step 3: 样式数组新增移动端 CSS**
定位样式数组中 `.toggle-btn:hover` 那一行(约第 47 行):
```js
".toggle-btn:hover { transform:scale(1.1); }",
```
在其**下方**紧接着插入 5 行移动端按钮样式:
```js
".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }",
".toggle-btn.mobile:hover { transform:scale(1.05); }",
".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:#f08a31; display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(240,138,49,0.45); }",
".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid #f08a31; }",
".toggle-btn.mobile .mobile-label { color:#8C4D1D; font-size:10px; font-weight:600; line-height:1.1; }",
```
- [ ] **Step 4: `toggleBtn` 改为按 `isMobile` 分两套模板**
定位 `toggleBtn` 创建处(约第 233-235 行):
```js
var toggleBtn = document.createElement("button");
toggleBtn.className = "toggle-btn";
toggleBtn.innerHTML = `
```
**改动一**:把 className 改为带 mobile 修饰:
```js
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
```
**改动二**:把紧跟其后的单行 `toggleBtn.innerHTML = \`...桌面模板...\`;` 改成 `if/else` 分支——在它**前面**插入移动端分支、把它本身包进 `else`。
具体:在 `toggleBtn.className = ...` 这一行之后、原 `toggleBtn.innerHTML = \`` 这一行之前,插入:
```js
if (isMobile) {
toggleBtn.innerHTML = `
🤖
Chat with us
`;
} else {
```
然后在原桌面模板赋值的结束反引号 `` `; `` **之后**,补上闭合的 `}`:
```js
}
```
改动后该区域结构应为(桌面模板内容逐字不变,仅被包进 `else { ... }`):
```js
var toggleBtn = document.createElement("button");
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
if (isMobile) {
toggleBtn.innerHTML = `
🤖
Chat with us
`;
} else {
toggleBtn.innerHTML = `
... (现有桌面模板,原样不动) ...
`;
}
```
> `toggleBtn` 仍是接收点击的元素,[widget.js:367](../../../static/widget.js#L367) 的 `toggleBtn.addEventListener("click", toggle)` 无需改动,点击圆钮或文字均触发 `toggle()`。
- [ ] **Step 5: 手动验证——默认值(向后兼容,桌面款按钮不变)**
`widget-test.html` 此刻未加 `data-mobile` → `isMobile=false` → 走桌面分支。
打开 `static/widget-test.html`(方式 A 或 B),DevTools Console 执行:
```js
const host = document.querySelector('#cc-widget-host');
const btn = host.shadowRoot.querySelector('.toggle-btn');
console.log('class:', btn.className);
console.log('html:', btn.innerHTML.slice(0, 60));
```
Expected:
- `class:` → `toggle-btn`(**不含** `mobile`)
- `html:` → 以桌面模板开头(含 `position:relative` 的 div)
视觉上:右下角是**现有的宽药丸按钮**(🤖 图标 + "Chat with us" / "Usually replies quickly" 两行文字),与改动前一致 → **向后兼容验证通过**。
- [ ] **Step 6: 手动验证——`data-mobile="true"` 切到 D2(临时)**
临时编辑 `static/widget-test.html:69`,给组件 script 标签加上 `data-mobile="true"`(**保留** Step 1 Task 1 回退后去掉 `data-auto-invite` 的状态,即当前标签不含 auto-invite):
```html
```
刷新页面,DevTools Console 执行:
```js
const host = document.querySelector('#cc-widget-host');
const btn = host.shadowRoot.querySelector('.toggle-btn');
const container = host.shadowRoot.querySelector('.widget-container');
console.log('class:', btn.className);
console.log('circle:', !!btn.querySelector('.mobile-circle'), 'label:', btn.querySelector('.mobile-label')?.textContent);
console.log('position bottom/right:', getComputedStyle(container).bottom, getComputedStyle(container).right);
```
Expected:
- `class:` → `toggle-btn mobile`
- `circle:` → `true`,`label:` → `Chat with us`
- `position bottom/right:` → `20px` / `16px`(贴角,**非** 桌面的 `80px`)
视觉上:右下角是 **D2 竖排按钮**——56px 橙色圆钮 🤖 在上、"Chat with us" 文字外置在下(非橙色底),且**更贴近屏幕右下角**。点击圆钮或文字均能开关聊天面板。
验证通过后,**保留** `data-mobile="true"` 不要回退——这正是 Task 3 要定稿的演示状态。
- [ ] **Step 7: 手动验证——`data-position` 其它取值不报错(临时)**
把 Step 6 标签里的 `data-position` 分别临时改成 `bottom-left`、`top-right`、`top-left`,各刷新一次。
Expected:D2 按钮出现在对应角,位置贴角(~20px),无 JS 报错(DevTools Console 无红色)。验证完**恢复** `data-position="bottom-right"`。
- [ ] **Step 8: 提交**
```bash
git add static/widget.js
git commit -m "$(cat <<'EOF'
feat(widget): 新增 data-mobile 切换移动端竖排悬浮按钮(D2)
data-mobile="true" 时悬浮按钮改为竖排样式(56px 圆钮在上、"Chat with us"
文字外置在下),并切换到贴角位置表 positionStylesMobile。桌面款模板与
位置逻辑逐字不动,默认行为完全向后兼容。
Co-Authored-By: Claude
EOF
)"
```
---
## Task 3: `widget-test.html` 演示属性 + 更新测试清单
**Files:**
- Modify: `static/widget-test.html:69`(确认 `data-mobile="true"` 演示属性到位,Task 2 Step 6 已加)
- Modify: `docs/test-checklist.md`(补充手动验证条目)
- [ ] **Step 1: 确认 `widget-test.html` 演示属性到位**
确认 `static/widget-test.html:69` 已是(Task 2 Step 6 改成的):
```html
```
若之后被改动过,恢复成上面这样。
> 故意**不加** `data-auto-invite="false"`:widget-test.html 的核心用途就是测试邀请卡自动弹出(页面标题即 "Widget Invite Card Test Page"),加 false 会让邀请卡不弹、破坏其回归用途。`data-auto-invite` 的关闭路径靠临时编辑验证(见测试清单)。
- [ ] **Step 2: 更新测试清单文档**
在 `docs/test-checklist.md` 末尾追加一节:
```markdown
## 邀请卡自动弹出开关 + 移动端悬浮按钮(2026-06-23)
### data-auto-invite
- [ ] **默认(未传)**:组件邀请卡在 ~1.8s 后自动弹出,与历史一致。
- [ ] **`data-auto-invite="false"`**:邀请卡不再自动弹出;悬浮按钮照常显示,点击仍能打开聊天面板。
- [ ] **空值/非法值**:`data-auto-invite=""` 或 `data-auto-invite="0"` 等价于未传,仍自动弹。
- [ ] **与 data-mobile 独立**:`data-mobile="true" data-auto-invite="false"` 时竖排按钮 + 不弹邀请卡,互不影响。
### data-mobile
- [ ] **默认(未传)**:悬浮按钮为桌面款宽药丸(`toggle-btn`,不含 `mobile`),与历史一致。
- [ ] **`data-mobile="true"`**:悬浮按钮为 D2 竖排(`toggle-btn mobile`,含 `.mobile-circle` + `.mobile-label`="Chat with us"),点击圆钮或文字均能开关聊天面板。
- [ ] **移动端位置贴角**:`data-mobile="true"` + `data-position="bottom-right"` 时容器 `bottom/right` 为 `20px`/`16px`(非桌面 `80px`)。
- [ ] **位置覆盖各角**:`data-mobile="true"` 配合 `data-position` 取 `bottom-left`/`top-right`/`top-left`,按钮出现在对应角、贴角、无 JS 报错。
- [ ] **空值/非法值**:`data-mobile=""` 或 `data-mobile="yes"` 等价于未传,回退桌面款。
```
- [ ] **Step 3: 提交**
```bash
git add static/widget-test.html docs/test-checklist.md
git commit -m "$(cat <<'EOF'
docs(test): widget-test 演示 data-mobile + 补充两个开关测试清单
Co-Authored-By: Claude
EOF
)"
```
---
## 完成标准(Definition of Done)
- [ ] `static/widget.js` 读取 `data-auto-invite` 与 `data-mobile` 两个新属性;不传时默认行为与改动前**完全一致**(桌面款按钮 + 邀请卡自动弹)。
- [ ] `data-auto-invite="false"` 时邀请卡不再自动弹出,悬浮按钮仍可用。
- [ ] `data-mobile="true"` 时悬浮按钮为 D2 竖排样式,且贴角位置生效。
- [ ] 桌面分支(按钮模板、`positionStyles`)逐字未改,`grep` 确认无意外改动。
- [ ] 3 个 commit 已提交,每个自包含、可独立验证。
- [ ] `docs/test-checklist.md` 已补充两个开关的手动验证条目。
## 风险与回滚
- **风险点**:悬浮按钮是所有嵌入共享的代码路径,Task 2 改动它时若手滑可能影响桌面款。规避:桌面模板逐字包进 `else`、`positionStyles` 不动;Task 2 Step 5 专门做桌面款回归验证。
- **回滚**:`git revert` 对应 commit 即可;未传新属性的部署本来就无任何行为变化(零破坏)。