主题
ReAI Board 插件设计规范 v1
配套示例页:https://ai-board.reai.com/design/plugin-v1/ —— 本文每一档都在那里 真实画了出来,可切明暗主题、抽屉能真的开关。做插件时对着看,比读数字准得多。 (在仓库里读这份文档的话,同目录下的
plugin-design-system-v1.html用浏览器直接打开就是同一个页面。)前置阅读:插件开发指南 · ReAI App 开发指南 v1.1 §13
0. 这份规范管什么,不管什么
开发指南 §13「界面克制」管的是什么该出现在屏幕上——别乱加按钮、别在插件里造第二个 通知铃铛、留白怎么分组。那一节优先于本文:一个控件做得再漂亮,出现在不该出现的时刻, 它就是干扰。
本文管的是另一半:决定要出现的东西,该长什么样。颜色从哪来、图标什么线宽、 按钮多高、抽屉的关闭按钮放哪。
| 本文管 | 本文不管 |
|---|---|
| 颜色 Token 怎么用、有哪些 | 用什么 UI 框架(Vue / React / 原生 DOM 都行) |
| 图标的来源与参数 | 插件的信息架构与业务流程 |
| 按钮 / 输入 / 面板的形态档位 | 该不该做这个功能(见开发指南 §13) |
| 间距、圆角、层级刻度 | Host 自己的界面(见下方「存量豁免」) |
存量豁免:Host 现有界面里有 9px、13px 这类历史取值,与本文刻度不一致。 不为对齐规范返工存量——那是纯风险没有收益。规则是新写的插件必须符合, Host 侧新写的界面向本文靠拢,存量随手改到的顺带收敛。
为什么需要这份规范:一个真实的反例。Codex Link 的 Skills 抽屉,关闭按钮用的是 键盘上的乘号字符 ×,而它正上方 Host 标题栏的通知铃铛是矢量描边图标。两者线宽、 端点、光学重心完全不同,并排就是不搭。更要命的是那颗 × 没有内边距、没有最小点击区、 没有悬停反馈——用户看不出它能点。这不是插件作者偷懒,是平台既没给图标,也没说清尺寸。
1. 颜色:只用 Host Token,不写死颜色
1.1 它已经能用了,但不是靠继承
插件跑在完全隔离的 WebView里,有自己的 origin,看不到 Host 的 DOM,也读不到 Host 的 任何样式表。所以主题不是「继承」来的——是 Host 主动送进来的:
- 打开插件时:Host 把当前主题的全部 39 个 Token 渲染进插件页面的
:root; - 用户切主题时:Host 遍历每一个开着的插件 WebView,直接改写它们
:root上的变量值。 不销毁、不重建、不发事件。
这是 VSCode 给 webview 注入 --vscode-* 的同一套路子,对插件的意义是: 你写 var(--accent) 就完事了,切主题时它自己会变,你不需要订阅任何事件、 不需要写任何同步逻辑、也不需要知道当前是明是暗。
除了颜色,Host 还注入一个平台布局 Token:--reai-plugin-titlebar-safe-top。macOS 为 44px, 没有原生 sibling Title Bar 的平台为 0px。它只解决顶部系统命中区,不是通用间距系统;插件根 框架只消费一次,正文间距仍由设计 Token 和语义分组决定。见 插件开发指南 §3.1。
⚠️ 绝对不要用自己的样式去覆盖 Token(比如在样式表里写 :root { --accent: red })。 它不会当场报错——恰恰相反,一开始它看起来是生效的,这才是坑:
- 插件打开时,Host 注入的是一段普通
:root规则,而你的样式表排在它后面。 同名声明按源顺序后来居上,你的覆盖会赢,界面正是你想要的样子; - 用户切一次主题,Host 改走行内样式直接写
:root,优先级压过一切 CSS 规则, 你的覆盖被抹掉且再也回不来——切回原主题也不恢复。
所以症状是「用着用着突然变了样,重开才好」,排查时极难联想到几十行外的那句覆盖。 要自己的颜色,就用自己的变量名(见 §1.3)。
css
/* 对:跟随主题 */
.my-panel { background: var(--card-bg); color: var(--text-primary); }
/* 错:写死。用户切到深色主题,这块就成了一张刺眼的白纸 */
.my-panel { background: #ffffff; color: #202033; }1.2 有哪些 Token
事实源是 主题 Token 表(仓库内 driver-v2/src-tauri/src/theme/tokens.rs), 拿不准某个名字存不存在时以它为准。那份表是从设计稿的 CSS 自动生成的(文件头写着「请勿手改」), 明暗两套值一并列出,Host 与本文档共用同一份,不存在「文档说有、Host 说没有」。
39 个 Token 全部注入给插件,没有黑名单。下表是其中最常用的一批,附上语义和用途—— 它是导读,不是白名单:
| Token | 语义 | 典型用途 |
|---|---|---|
--bg | 页面底色 | 插件根容器背景 |
--card-bg | 卡片 / 面板底 | 列表项、卡片、抽屉面板 |
--card-border | 卡片描边 | 卡片、输入框边框 |
--card-shadow | 卡片投影 | 浮起的卡片 |
--divider | 分隔线 | 列表分隔、区块分界 |
--text-primary | 主文字 | 标题、正文 |
--text-secondary | 次要文字 | 说明、辅助信息、图标按钮默认色 |
--text-tertiary | 三级文字 | 占位符、禁用态文字 |
--accent | 品牌强调色 | 主按钮底、选中态、链接 |
--accent-soft | 强调色浅底 | 选中背景、徽标底 |
--accent-glow | 强调色光晕 | 焦点环、悬停光晕 |
--alert / --alert-soft / --alert-line | 错误 / 危险 | 失败状态、删除操作 |
--warn / --warn-soft / --warn-line | 警告 | 需注意但未失败 |
--bound / --bound-soft / --bound-line | 成功 / 已完成 | 成功状态、完成标记 |
--panel-bg / --panel-border / --panel-shadow | 浮层面板 | 弹出层、下拉、浮窗 |
--scroll-thumb / --scroll-thumb-hi | 滚动条 | 自定义滚动条 |
三色组(alert / warn / bound)的用法固定:主色画图标和文字,-soft 画背景, -line 画描边。不要用主色当背景——饱和度太高,一块状态提示会盖过页面主体。
1.3 允许起别名,但要指回 Token
插件可以给自己起短别名,前提是值必须来自 Token:
css
:root { --my-accent: var(--accent); } /* 对 */
:root { --my-accent: var(--accent, #6558d8); } /* 也对,兜底不影响正常路径 */
:root { --my-accent: #6558d8; } /* 错:脱离主题 */别名的名字要和 Token 错开(--my-accent 可以,--accent 不行)。撞名的后果见 §1.1: 不是当场失效,是等用户切一次主题才失效,而且不可逆——最难查的那一类。
⚠️ 只有变量名正确、Host 已注入且变量值有效时,才不会走兜底。写错名字或使用未声明的别名, 即使装进 App 也会一直使用 fallback;CSS 不会因此报错。兜底值应和对应 Token 的浅色取值一致, 但兜底不能代替主题合同校验。
1.4 不许做的事
- 不写死颜色,也不用
prefers-color-scheme自己判明暗——Host 注入的 Token 已经是当前主题的值, 再判一次只会和 Host 打架; - 不监听主题切换事件手动改色——用 Token 就不需要监听,Host 会直接改写你的变量;
- 别名不要和 Token 同名(见 §1.3)。
有几件事不用写进禁令,因为物理上就做不到:插件在隔离 WebView 里,读不到 Host 的样式表、 拿不到 Host 的 DOM、也影响不了 Host 的任何元素。不必为此设防,也不要指望能反过来利用。
1.5 状态条的可读性与兜底陷阱
Voice 命令回复曾使用 --text / --text-2 / --surface / --border,但这些不是 Host Token, 插件也没有声明这些别名。结果是暗色背景上的文字仍回退为浅色主题的深字,图标又用了固定白底。 修复时必须同时检查文字、背景、图标和边框,不能只把文字临时改成白色。
css
/* 错:变量不存在时永远使用深字,浅色预览很容易漏掉 */
.tool-status { color: var(--text, #1c1c2e); }
/* 对:引用真实合同;自己的别名也必须最终指向这些 Token */
.tool-status {
color: var(--text-primary);
background: var(--accent-soft);
border: 1px solid var(--divider);
}
.tool-status-icon { color: var(--accent); background: var(--card-bg); }- 工具进度、完成回执和失败原因属于用户需要阅读的信息,默认使用主文字色;不要用禁用态文字色或降低整卡 opacity。
- 检查
var()引用时,应分别确认 Host Token 与插件自己声明的别名;有 fallback 不代表名字有效。 - 明暗主题都检查运行中、完成、失败和缺能力卡;在插件保持打开时来回切主题,确认文字与图标同步变化。
- 用真实合成后的背景检查对比度,特别是半透明卡片;不能只比较两个原始 Token,也不能只测 DOM 中存在文案。
- 加载使用 SVG spinner,完成使用 SVG 对勾,不用旋转
↗等方向字符表示加载;尊重prefers-reduced-motion。
2. 图标:用 Host 图标库,别用字符凑
2.1 现在怎么做:插件自带一份精灵图
⚠️ 与颜色 Token 不同,Host 目前不往插件页面里送图标——插件在隔离 WebView 里, import 不到 Host 的图标表。所以
#reai-icon-x这类名字不会凭空存在,你得自己带一份同名的。 (标题栏动作是例外,那条路已经能按名字用 Host 图标,见 §2.5。)
做法是在插件页面里内嵌一份 SVG 精灵图,把用到的图标一次性定义好,之后处处 <use> 引用。 图标从 Host 图标表(仓库内 driver-v2/src/shell/icons.ts)里照抄 path——共 49 个,lucide 描边风格, 只抄用得到的那几个:
html
<!-- 页面里放一次。必须带 class 让它脱离布局流,见下方样式 -->
<svg class="icon-sprite" aria-hidden="true"><defs>
<symbol id="reai-icon-x" viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</symbol>
</defs></svg>
<!-- 之后处处可用 -->
<svg class="icon" aria-hidden="true"><use href="#reai-icon-x"></use></svg>配套的基线样式(参数照抄,别改,理由见 §2.2):
css
/* 精灵图必须脱离布局流 */
.icon-sprite { position:absolute; width:0; height:0; overflow:hidden; }
.icon { width:16px; height:16px; fill:none; stroke:currentColor;
stroke-width:2; stroke-linecap:round; stroke-linejoin:round; flex:none; }
.icon--sm { width:14px; height:14px; stroke-width:2.4; }
.icon--lg { width:20px; height:20px; stroke-width:2; }⚠️ 只把宽高写成 0 是不够的,这一条踩过:<svg> 是 inline 替换元素, 0×0 在块流里照样撑出一个行盒(约 26px 空白),在 flex / grid 容器里照样占一格。 真实案例:Codex Link 把 sprite 放在两列网格的第一个子元素上,主内容区当场被挤进 侧栏那条窄列、侧栏掉到第二行——整页是散的,而单元测试只验元素存在,一点没察觉。 所以样式表里那条 position:absolute 不是装饰,是必需的。
两个关键性质:
- 颜色自动跟随:
stroke: currentColor意味着图标颜色 = 父元素文字颜色。 按钮变色、悬停变色,图标自己跟着变,不用写第二遍。 - 写错名字只是空白,不会渲染出奇怪的东西,也不构成注入点——
<use>只能引用 同文档内已定义的 symbol,引不到外部资源。
2.2 尺寸三档,线宽跟着 Host 走
| 档位 | 尺寸 | 线宽 | 用在哪 |
|---|---|---|---|
| 小 | 14px | 2.4 | 密集列表内的行内图标、徽标 |
| 标准(默认) | 16px | 2 | 绝大多数场景:按钮、菜单、状态 |
| 大 | 20px | 2 | 空态插画、页面级主图标 |
标准档那个 2 是本文最要紧的一个数字,因为它不是设计偏好,是对齐 Host: Host 的图标组件默认就是 stroke-width: 2,标题栏那颗通知铃铛是 15px / 线宽 2, 侧栏图标也都在这个值上。插件图标只要偏离它,跟 Host 的图标并排就会显得细、显得飘—— 这正是本规范要解决的原始问题,别在这里自作主张。
小尺寸必须加粗:2 的线在 14px 下会糊成一团,Host 自己在 11–13px 的小图标上 也加粗到 2.2–2.6。这不是审美偏好,是渲染事实。
需要 24px 以上的图标,说明那是插画不是图标,自己画,但仍要保持描边风格与 currentColor。注意线宽不随尺寸放大而加粗——Host 在 28px 上用的还是默认的 2, 只有 40px 那种页面级大图才降到 1.5。
2.3 自己画图标的规矩
图标库没有你要的东西时可以自画,但必须能和库里的图标并排放而看不出区别:
- 画布
viewBox="0 0 24 24",描边风格(fill:none+stroke:currentColor); - 端点和拐角一律
round; - 线宽交给
.icon基线控制,不要在 path 上写死stroke-width, 写死了尺寸档位就失效了; - 不要混入实心图标——除非整组都是实心(比如状态点),单个实心图标混在描边图标里最扎眼。
⚠️ 自画的图标要用你自己的 id 前缀(比如 myapp-icon-refresh),别占用 reai-icon-。 这两个前缀将来的命运不同:reai-icon-* 是从 Host 图标表摘的,等平台注入公共精灵图后 删掉自带的 <defs> 就能无缝换过去;而自画的图标 Host 那边并不存在,一旦你把它也叫 reai-icon-xxx,平台注入的同名图标会悄悄替换掉你画的那个——不报错、不告警, 只是某天图标突然换了个样子。
绝对禁止:用文字字符当图标(× → ✓ ⚙ ↻)。三个理由,一个比一个实际: 粗细随系统字体变化,对不齐旁边图标的光学重心;不同平台字形不同,同一个符号在 Windows 和 macOS 上长相不一样;字符没有独立的尺寸控制,只能靠 font-size 撑, 撑出来的高度和图标的方形盒子对不齐。
无障碍上它也更容易出事:忘了写 aria-label 时,读屏会直接把字符念出来(「乘号」「箭头」), 而图标至少是静默的。
2.4 无障碍
图标是纯装饰(旁边有文字)时加 aria-hidden="true";图标是按钮的唯一内容时, 按钮必须有 aria-label,否则屏幕阅读器用户会听到一颗无名按钮。
html
<button class="btn-icon" aria-label="关闭 Skills">
<svg class="icon" aria-hidden="true"><use href="#reai-icon-x"></use></svg>
</button>2.5 有一条路已经能用 Host 图标:标题栏动作
插件声明 titlebarActions 时,图标是按名字点的(settings、plus、search、clock、 external-link、more-vertical、rotate-cw、square、mic、pen、folder), 由 Host 自己渲染在标题栏上——插件不碰 DOM,也不用自带任何图标资源, 自然就和 Host 的图标完全一致。声明方式见插件开发指南 §3.2。
这条路证明了方向是通的,但它只覆盖标题栏。插件页面内部的图标仍然要按 §2.1 自带。
终局是由 Host 往插件页面注入一份公共精灵图(走和主题 Token 同一条注入通道), 插件直接 <use href="#reai-icon-x"> 引用。落地后 §2.1 的自带方案退化成兼容路径, 尺寸与线宽档位不变——现在照 §2.2 写的插件,将来只需要删掉自带的那段 <defs>。
3. 控件:三档按钮,认得出属于哪档
3.1 全局基线(每个插件必须有)
任何 <button> 只要样式没写全,浏览器默认的灰底黑框就会顶出来,在一整套设计语言里 格外刺眼。所以样式表第一件事是把它归零:
css
button { font:inherit; color:inherit; border:0; background:transparent; cursor:pointer;
-webkit-appearance:none; appearance:none; text-align:inherit; }
button:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
button:disabled { cursor:not-allowed; opacity:.45; }:focus-visible 那条不许删。键盘用户看不见焦点就等于用不了。
⚠️ 样式必须写在插件自己的样式表里,HTML 里不能用内联 style="…" 属性。插件页面的 CSP 只放行带 nonce 的样式,没有 unsafe-inline,内联样式属性会被直接拦掉—— 表现是「本地用浏览器打开好好的,装进 App 样式全丢」。同理,脚本走打包后的 ESM 模块, 不写内联 <script>。
被拦的只有写在 HTML 标签上的那种。运行时用 JS 改样式不受影响 (el.style.setProperty(...)、加减 class 都正常)——Host 的主题热更新用的就是这条路。
3.2 三档
| 档位 | 长相 | 用在哪 | 一屏最多 |
|---|---|---|---|
| 主按钮 | --accent 实底 + 白字 | 这一屏最想让用户点的那一个 | 1 个 |
| 次按钮 | 透明底 + --card-border 描边 + --text-primary | 取消、返回、并列的次要操作 | 不限 |
| 图标按钮 | 无底无边 + --text-secondary | 关闭、更多、工具条 | 不限 |
| 参数 | 主 / 次按钮 | 图标按钮 |
|---|---|---|
| 高度 | 36px(紧凑 32px) | 32px 见方 |
| 内边距 | 0 16px | 0(图标居中) |
| 圆角 | 10px | 8px |
| 字号 | 14px | — |
判据:遮住页面其余部分,单看这颗按钮,说得出它属于哪一档吗?说不出就是造型跑了。
3.3 最小点击区
按钮的可视尺寸可以是 32px,但可点击区域要撑到 44×44px。视觉上不想让按钮 显得笨重时,用透明外扩,不要缩小点击区:
css
.btn-icon { position:relative; width:32px; height:32px; border-radius:8px;
display:grid; place-items:center; color:var(--text-secondary); }
/* 看不见的外扩,把 32px 的按钮撑到 44px 可点 */
.btn-icon::after { content:""; position:absolute; inset:-6px; }这不是无障碍教条。触控板和键盘用户点一颗 18px 的字符要瞄准,瞄不准就是"这按钮坏了"。
两类例外。 第一类是密集排布的成组按钮——列表项里并排的几颗操作按钮(批准 / 拒绝 / 更多这种), 如果每颗都外扩到 44px,相邻按钮的命中区会互相重叠——点「批准」可能触发「拒绝」, 这比按钮小危险得多。这类按钮按下表来:
| 场景 | 要求 |
|---|---|
| 独立的图标按钮(关闭、更多、工具条) | 命中区 44×44,用透明外扩实现 |
| 密集成组的次要操作按钮 | 可视高度至少 28px、按钮间距至少 8px,且命中区不得重叠 |
| Host Title Bar 动作 | 不由插件 CSS 决定;通过 titlebarActions 声明,由 Host 统一提供尺寸、命中区、hover 与 focus |
Title Bar 动作的命中区由 Host 负责。插件不要为了追求 44px 点击区,在自己的 main-body 上方 叠一个透明按钮,也不要用负 margin 把内容按钮拉进 Host 区域。具体边界以 插件开发指南 §3.1 为准。
判据很简单:外扩之后,相邻两颗按钮的命中区还能不能分开。分不开就别外扩, 改成把按钮本身和它们之间的间距一起加大。
3.4 三个状态一个都不能少
| 状态 | 必须有的变化 |
|---|---|
:hover | 底色变化(图标按钮用 --accent-soft,主按钮压暗)+ 文字/图标提亮到 --text-primary |
:focus-visible | 可见描边(基线已给) |
:disabled | 降透明度 + cursor:not-allowed,且不能再有 hover 反应 |
没有 hover 的按钮,用户不确定它是不是能点——这是这次 Skills 抽屉暴露的问题之一: 整个插件样式表里一处 :hover 都没有。
耗时操作还要有第四态:进行中。按钮禁用 + 文案改成进行时("发送中…"), 不要让用户对着一颗没反应的按钮连点三次。点下去之后的完整要求见 §3.6。
3.5 按钮四周都要留白
规则:只要按钮有交互背景——悬停或按下会铺底色、常显描边或底色、焦点框画在按钮盒子上—— 文字(或图标)到背景边缘的上下左右四个方向都必须留白。底色贴着字,用户看到的是 「字被一块色涂住了」,分不清按钮的边界在哪。
真实反例:Driver 订阅页右上角的「管理订阅」写的是 padding: 6px 0,悬停时 --accent-soft 底色左右紧贴着字。这不是那一处写漏了:任何「文字按钮 + 悬停底色」只要沿用了纯文字链接的 padding: 0,都会出同样的问题。
最小值(标准档取自 §4.1 的 4 倍数刻度;紧凑档是不许再低的下限,不是推荐值):
| 按钮类型 | 左右(各) | 上下(各) |
|---|---|---|
| 文字按钮、链接按钮、图标 + 文字按钮(标准) | ≥ 8px | ≥ 4px |
| 紧凑:密集工具条、列表行内小按钮、侧栏窄轨按钮(字号 ≤ 11px) | ≥ 4px | ≥ 2px |
| 主 / 次按钮 | 按 §3.2:0 16px | 由固定高度 36 / 32px 保证 |
| 图标按钮 | 固定方形尺寸、图标居中(§3.2 32px),不靠 padding 撑;只有嵌在另一个可点元素里的附属小钮(如标签页上的关闭钮)可小到 16px,图标四周仍各留 ≥ 3px。成组操作按钮的可视高度与命中区仍按 §3.3 | 同左 |
上下方向可以用 padding,也可以用「固定高度 + 垂直居中」实现,只要等效留白达标(例如 11px 字号的紧凑按钮高度 ≥ 22px)。
Token 名:Host 注入给插件的 39 个 Token 全是颜色(另有 §1.1 的布局安全区 --reai-plugin-titlebar-safe-top),没有间距 Token——插件在自己的样式表里 写数值,或起自己的别名(名字别用 --btn-pad-*,避免将来和 Host 撞名,理由见 §1.3):
css
:root { --my-btn-pad-x: 8px; --my-btn-pad-y: 4px; }Host 自有界面用共享变量 --btn-pad-x(8px)/ --btn-pad-y(4px)/ --btn-pad-x-compact(4px)/ --btn-pad-y-compact(2px),定义在 Host 的 shell-adapt.css。它们不注入插件,插件引用不到。
对齐技巧:加了留白,文字不挪位。 文字按钮常常贴着内容边缘对齐(行尾、卡片右上角)。补上 左右内边距后,用等量负 margin 抵消,文字位置不变,只有悬停背景向外多出一圈:
css
/* 贴右边缘的文字按钮:文字仍对齐右内容边,悬停底色向右多出 8px */
.manage-link { padding: 6px 8px; margin-right: -8px; border-radius: 8px; }
/* 不想撑高所在行:上下同理 */
.tab-close { padding: 3px 5px; margin: -2px 0; }前提是外侧还有 ≥ 同等宽度的容器内边距,否则悬停背景会被裁掉或顶到容器边上——这时改为 按设计稿收窄对齐线,不要把留白省掉。
例外(必须在样式旁注明是刻意的,否则按违规处理):
- 纯文字链接:悬停 / 按下不铺底色、无边框,只变色或加下划线。它本来就没有背景, 可以不留内边距。焦点框要用正的
outline-offset(全局基线的2px)画在字外, 不许用负 offset 或inset阴影压到字上; - 整行可点的列表行:留白由行自己的 padding 提供(如
12px 16px),行里的文字不再单独加; - 开关、滑块、复选框这类非文字控件不适用本条,按各自的尺寸规格走。
css
/* 错:悬停铺底色,左右却没有内边距——底色贴着字 */
.manage-link { padding: 6px 0; background: transparent; }
.manage-link:hover { background: var(--accent-soft); }
/* 错:零内边距 + 负 offset 焦点框,框画在字上 */
.crumb { padding: 0; }
.crumb:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* 对:四周留白;贴右对齐的位置用负 margin 抵消 */
.manage-link { padding: 6px 8px; margin-right: -8px; border-radius: 8px; background: transparent; }
.manage-link:hover { background: var(--accent-soft); }
/* 对:刻意的纯文字链接(§3.5 例外)——悬停只变色,不铺底色,所以不留内边距 */
.text-link { padding: 0; background: none; color: var(--accent); }
.text-link:hover { text-decoration: underline; }
.text-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }判据:把鼠标移上去、再按 Tab 聚焦一次,看底色和焦点框的四条边——哪一条贴着字,就是缺留白。 明暗主题各看一遍:浅色主题下 --accent-soft 很淡,贴边最容易被漏看。
3.6 操作后立即反馈(Host 与插件通用,2026-09-28)
用户点了按钮,界面却没有任何变化,他就不知道刚才那一下有没有用——只能干等或者再点一次。 这是基本素养,不是锦上添花。真实反例:Driver 通知里的「安装浏览器插件」,点下去之后按钮不变、 没有进度,9 秒后突然装好了。
适用范围:凡是点下去要等网络、磁盘、Host 调用或另一个进程的一次性操作——安装、更新、 下载、发送、保存、同步、登录、授权、生成。纯本地、瞬间完成的操作(开关、展开折叠、切标签) 本身的状态变化就是反馈,不需要进度条。
最小要求(四条都要满足):
- 100ms 内有可见变化。 点击后 100ms 内,触发它的按钮必须变样:文案改成进行时 (「正在安装…」)并禁用;需要等待的,按钮正下方同时出现进度区。实现上就是: 在第一个
await之前改界面状态。先await再改,等待的那几秒就是空白。 - 超过 1 秒要说清进行到哪。 能拿到真实字节或条目计数(已下载 12.4 / 38 MB、第 3 / 5 个) 就显示确定进度;拿不到就显示当前阶段名 + 已用时间 + 不确定进度条(来回跑的短条)。 阶段名来自真实的调用边界或事件(「正在下载并校验安装包」「正在启用」),不许编百分比, 也不许按时间匀速涨一个假进度——走到 90% 卡住比没有进度更伤信任。已用时间超过 1 秒再显示, 免得瞬间完成的操作闪一下「已用 0 秒」。
- 进行中禁止重复触发。 按钮禁用只是第一道;处理函数入口再用一个进行中标志挡一次 (键盘回车、跨窗口转发来的迟到点击都绕得过禁用态)。同一件事的第二次请求并入进行中的那一次, 不要把进度重置回起点。
- 完成和失败都有明确终态。
- 完成:显示一句完成态(「已安装,正在继续刚才的搜索」),停留 1.5–3 秒再收起;或者由 接下来的界面直接接管(卡片变成「已安装」、页面打开新内容)。不能「进度条一消失就什么都没了」。
- 失败:按 §6.0 显示真实原因、错误码、版本号、可复制诊断,并给 「重试」。失败不自动消失;用户关掉了就在他回来时还在。「已取消」只留给用户自己点了取消。
- 进行中状态不得引起布局跳动(2026-09-29 补)。状态切换(未安装 → 正在安装 → 就绪) 如果增删了占位元素,按钮所在的行或卡片会忽然高一点、矮一点,整片网格跟着跳一下—— 用户正盯着这里,跳动比没有反馈更打断。状态行要有恒定高度:给容器统一
min-height(覆盖三态中最高的一种),或为进度条预留固定槽位;文字行数会变的, 用固定行数高度(如副文恒定两行)兜底。判据:对着同一个位置连录三态截图,容器 四条边一像素都不动。
位置:进度与终态紧贴在触发按钮下面(或按钮所在的卡片、行里),不要只在页面底部或另一个 区域出一行字——用户的视线停在他刚点的地方。同一屏有多个同类按钮(一列「获取」)时,只有被点的 那一个变「正在安装…」并出进度,其余只是按不动。
ts
// 错:先 await 再改界面——这 9 秒里按钮毫无变化,用户只能连点或干等
installButton.addEventListener("click", async () => {
await installPackage();
installButton.textContent = t("installed");
});
// 错:编一个会走的百分比。它不知道终点,走到 95% 就只能停在那里
let fake = 0;
const timer = setInterval(() => {
fake = Math.min(fake + 7, 95);
bar.style.setProperty("--progress", `${fake}%`);
}, 500);ts
// 对:第一个 await 之前就改状态;阶段来自真实步骤;完成与失败都有终态
// (progress 是插件自己的进度小组件:进度条 + 阶段名 + 每秒刷新的已用时间)
let busy = false;
installButton.addEventListener("click", async () => {
if (busy) return; // 第 3 条:进行中不重复触发
busy = true;
const startedAt = Date.now();
installButton.disabled = true; // 第 1 条:同一帧就变
installButton.textContent = t("installing"); // 「正在安装…」
progress.show({ stage: t("stageDownloading"), startedAt }); // 按钮正下方:阶段 + 已用时间 + 不确定条
try {
const file = await download({
// 第 2 条:只有真实字节才给确定进度
onBytes: (received, total) => progress.update({ fraction: received / total }),
});
progress.show({ stage: t("stageEnabling"), startedAt }); // 进入下一个真实阶段
await enable(file);
progress.done(t("installedContinuing")); // 第 4 条:完成终态
setTimeout(() => progress.hide(), 2000);
installButton.textContent = t("installed");
} catch (error) {
progress.fail(describeError(error)); // 第 4 条:原因、错误码、版本、复制诊断(§6.0)
installButton.disabled = false;
installButton.textContent = t("retry"); // 「重试」
} finally {
busy = false;
}
});不确定进度条的样式(颜色只用 Host Token;动效尊重系统「减少动态效果」设置):
css
.op-progress { height: 3px; border-radius: 2px; background: var(--divider); overflow: hidden; }
.op-progress > i { display: block; height: 100%; width: 38%; background: var(--accent);
animation: op-indeterminate 1.4s cubic-bezier(.65, 0, .35, 1) infinite; }
@keyframes op-indeterminate { 0% { margin-left: -38%; } 100% { margin-left: 100%; } }
@media (prefers-reduced-motion: reduce) {
/* 不动也要看得出「在进行」:整条淡色铺满,阶段名与已用时间照常走 */
.op-progress > i { width: 100%; margin-left: 0; opacity: .45; animation: none; }
}确定进度用 JS 改宽度(el.style.setProperty("width", …) 不受 CSP 限制,见 §3.1),不要在 HTML 里写 内联 style。进度区给 role="progressbar";只有确定进度(以及完成态的 100)才设 aria-valuenow, 不确定进度不设,阶段文字放在 aria-live="polite" 的区域里,失败主句用 role="alert"。
Host 的参照实现是 driver-v2/src/components/ActionProgress.vue(通知面板里的浏览器插件安装、 商店与已安装页的获取 / 更新、内置插件「查看并更新」共用)。
判据:点一下立刻松手,盯着按钮看——100ms 内它变没变?再把网络调慢:超过 1 秒时能不能说出 「现在在做哪一步、等了多久」?最后断网让它失败:原因、错误码、版本和「重试」都在按钮旁边吗?
3.7 下拉选择与清单联动
(2026-09-29 补,源于「插件 Agent 配置」设计稿第 5 轮微调;条款对界面同时适用。)
原生 select 要重新穿衣服再用。 浏览器默认的下拉箭头随系统不随主题,明暗切换、 平台差异都会露馅;直接把裸 <select> 放进界面,等于在整套设计语言里留一颗系统皮肤 (与 §3.1 按钮基线同一个道理)。做法:appearance: none 关掉默认外观,右侧为自绘 箭头留出固定 padding(箭头 14px + 两侧各 8px),箭头颜色走主题 Token;同一界面里的 下拉必须长一个样——同一个组件类,不一处原生一处自绘。自定义下拉按钮(如下拉选择 插件)同样遵守:箭头随主题、右侧留白、与全稿其他下拉同一套语言。
清单来自运行态,不写死。 凡是「已安装的应用」「可用的模型」「目录里的技能」这类 清单,UI 只消费运行时数据:已装插件 ∩ manifest 的能力声明、账户下发的模型目录、 扫描目录得到的技能文档。安装、卸载、上新要实时反映在清单里;客户端不得复制一份 静态清单(它会在数据变化后变成谎言)。开发恢复数据流时按「数据源 → 过滤 → 投影到 下拉」接线,不在界面层拼死数据。
上游变了,下游要跟着走。 一个选择控件的值决定下方列表的内容(选技能目录 → 列表读该目录、选应用 → 页面切到该应用)时,上游变化必须立刻替换下游内容,不留 上一份数据的残影,也不出现「选了却没反应」。换掉上游后,依赖旧内容的勾选、 展开态要按新内容重新开始,不给指向已不存在条目的幽灵状态。
人话在前,技术标识在后。 面向用户的清单行,第一行是用户能懂的功能名或中文说明 (主行、主题主文字色、可加粗);技术标识(工具 ID、模型代号、事件名、文件名)放 第二行小字(次要色、等宽字体可以有)。UI 是给用户用的(User Interface), 不是给开发者核对 API 用的——把等宽字体的技术名当主标题,就是本末倒置。
css
/* 统一的下拉组件:appearance 归零 + 自绘箭头 + 右侧留白(箭头定位在 wrap 上) */
.select-wrap { position: relative; display: inline-flex; align-items: center; }
.select-wrap .chev { position: absolute; right: 8px; display: flex;
color: var(--text-tertiary); pointer-events: none; }
.select-wrap select { appearance: none; height: 32px; padding: 0 30px 0 10px;
border: 1px solid var(--divider); border-radius: 10px;
background: var(--card-bg); color: var(--text-primary); }判据:明暗主题各开一次,下拉箭头和留白是否与主题一致、与同屏其他下拉一致; 装卸一个来源对象,清单是否即时增减;换一个上游选择,下方列表是否整体跟着换; 遮住第二行小字,只看主行——用户能不能不看文档就知道这一项是干什么的。
4. 刻度:间距、圆角、层级
4.1 间距用 4 的倍数
4 / 8 / 12 / 16 / 20 / 24 / 32 / 48。不出现 9px、13px 这种随手数字—— 一个人随手写没问题,十个插件各自随手写,并排就是碎的。
分组规则(承接开发指南 §13.3):回答不同问题的两块之间,留白必须明显大于组内间距。 组内 8px,组间就用 20px 或 24px,不要用 10px——差 2px 等于没分组。
4.2 圆角四档
| 档位 | 值 | 用在哪 |
|---|---|---|
| 小 | 6px | 徽标、标签、输入框内的小控件 |
| 中 | 10px | 按钮、输入框、列表项 |
| 大 | 14px | 卡片、面板、抽屉 |
| 全圆 | 999px | 胶囊标签、头像、状态点 |
同一个界面里圆角档位不要超过三种。嵌套时外层圆角必须大于内层,否则内层的角会顶出去。
既定例外:§3.2 图标按钮的 32px 见方用 8px 圆角,不占档位、也不计入「三种」上限——图标按钮是纯图形命中区,与文字按钮的视觉节奏分开。除这一处外,自造 8px、12px 等刻度外数值视为跑档。
4.3 「看不见」不等于「不占地方」
这条是通则,栽过一次:图标精灵图写成 0×0 塞进两列网格,把主内容挤没了(详见 §2.1)。
想让一个元素不影响布局时,先问一句它还在流里吗:
| 写法 | 还在布局流里吗 |
|---|---|
| 宽高设成 0 | 在。inline 元素照样撑行盒,flex / grid 里照样占一格 |
visibility: hidden | 在。位置照留,只是不画出来 |
opacity: 0 | 在。连点击都还在 |
display: none | 不在 |
position: absolute / fixed | 不在(脱离流,但仍可见、仍可点) |
前三种是「看不见但占地方」,后两种才真的让出位置。挑错了不会报错, 只会让旁边的东西悄悄错位——而这类问题单元测试基本抓不到。
4.4 层级(z-index)
插件在自己的 WebView 里,层级和 Host 不在同一个坐标系——写多大都盖不住 Host 的 通知面板或标题栏,也不会被它们影响。所以这里没有上限,只有一条给你自己用的分层建议, 免得同一个插件内部互相打架:
| 层 | z-index | 用途 |
|---|---|---|
| 内容 | 0–9 | 常规内容、粘性表头 |
| 浮层 | 10–29 | 下拉、气泡、提示 |
| 遮罩与抽屉 | 30–49 | 模态遮罩、侧边抽屉 |
| 全局提示 | 50+ | Toast、全屏加载 |
真正要守的是另一件事:Host 的标题栏盖在插件上方,那 44px 里插件的交互控件点不到—— 无论 z-index 写多大。见 §4.5。
4.5 单层顶部:44px Host Title Bar + 可滚动 main-body
窗口最上面 44px 是 Host Title Bar,整条都不归插件。它是唯一的页面级顶栏:左侧显示 返回与路径,中间负责拖拽,右侧承载 App 声明动作和通知。根页路径直接写 App 名(如 Voice); 子页写返回按钮与 Voice / 设置,不加 Apps /。面包屑祖先可点击,当前项只读。
插件不再创建第二层 main-title / page header,也不保留旧的蓝色 hover 渐变。需要交互的页面级 动作交给 Host titlebarActions,而不是在插件右上角再排一组按钮。
设置入口如果由 Host 登记为导航开关,进入设置后原按钮不能消失或保持普通态:它留在原位,使用 --accent-soft / --accent 呈现按下态并暴露 aria-pressed="true";此时再次点击的含义是 “返回插件首页”,读屏文案也必须同步变化。Host 再次投递同一可信设置 intent,插件按当前页面切回 根页;只有插件上报根导航后按钮才恢复普通态。面包屑等其他返回入口继续走插件 back-to-root Command。不要用插件 CSS 在正文里复制一颗“已按下设置”来冒充 Host 状态。
插件内容的第一层是 main-body:根框架先消费 Host 注入的 --reai-plugin-titlebar-safe-top(macOS 44px、其他平台 0px),随后填满剩余空间并承担页面纵向滚动。 标题、说明、Logo、搜索、标签和设置内容都属于 main-body;滚动内容不能穿过 44px 边界。 正文内部允许业务 sticky,但它相对 main-body 使用 top:0,不能把 sticky 叫作系统页头。
4.6 顶部交界的基础规则:同色连续,异色内缩
Title Bar 与 main-body 是相邻的全宽大 Surface。颜色变化必须对应真实的结构分层,不能只靠一条线 掩饰两个大色块零距离相撞。所有插件必须从以下两种模式中选择一种:
| 模式 | 适用情况 | 强制做法 |
|---|---|---|
| 同色连续(默认) | Title Bar 与正文属于同一页面背景 | 使用同一主题背景 Token,最终合成色一致;不加额外 padding、卡片套壳或分割线 |
| 异色内缩 | 正文顶部确实需要另一层背景色 | 把第一块异色 Surface 内缩到 main-body 内,桌面默认留白 16px、紧凑布局最低 12px,并使用 4px 间距体系 |
异色 Surface 应根据层级使用圆角、边框或阴影,让颜色变化发生在一个明确容器内部。禁止颜色 A 的 全宽 Title Bar 与颜色 B 的全宽正文零距离硬切;单独补 1px 分割线不算留白,也不能替代内缩。 半透明颜色只有在浅色、深色主题下的最终合成色都一致时,才算“同色”。
这条规则只定义视觉衔接,不改变平台所有权:Host 仍完整拥有 Title Bar 的 DOM、拖拽和交互;插件 只能设置自己 Surface 的主题 Token,不得修改 Host DOM/CSS、添加标题栏伪元素或把交互内容伸进 安全区。若 Host 使用透明 Title Bar,同色模式可让插件根背景在其下方连续延伸;若 Host 使用不透明 Title Bar,则由 Host 与插件共享同一背景 Token,或由插件采用异色内缩模式。
验收同时覆盖浅色与深色主题:同色模式不得出现色差或 1px 接缝;异色模式必须看见至少 12px 的 连续父背景,异色块不得全宽顶到 Title Bar 下沿。
完整骨架、安全区 Token 与当前 SDK 的能力边界见 插件开发指南 §3.1。其中 titlebar.action@1 已实现;通用路径 / 返回 贡献合同仍待平台开放,插件不得用 DOM 注入替代。
5. 面板与抽屉
抽屉是插件里最容易各写各的的东西,所以定死。
| 参数 | 规定 |
|---|---|
| 宽度 | min(360px, calc(100% - 24px)) |
| 内边距 | 24px;抽屉可视内容必须留在 main-body 内,不进入 Host 的 44px(见 §4.5) |
| 圆角 | 左侧 14px,右侧贴边为 0 |
| 遮罩 | rgba(20,18,40,.3) + backdrop-filter: blur(5px) |
| 层级 | 遮罩 30,面板 31 |
| 打开动效 | 120ms ease-out 滑入;prefers-reduced-motion 时直接显示 |
关闭按钮(本次问题的直接对象):
- 用图标按钮档(32px 见方 + 44px 点击区),不是文字
×; - 图标固定用
x(从 Host 图标表摘的那个),标准 16px / 线宽 2 档; - 位置:面板右上角,与标题基线对齐——不是靠上贴边。跟着标题走,它才像标题的一部分, 而不是漂在角落的孤儿;
- 必须有
aria-label。
必须支持的三种关闭方式:点关闭按钮、按 Esc、点遮罩空白处。少一种就会有用户卡在里面。
键盘上还有三件事,一件都不能省——声明了 aria-modal="true" 就是承诺了这些:
打开时焦点移进抽屉(通常给关闭按钮);
打开期间 Tab 在抽屉内循环,到底了回到第一个,不许跑到抽屉后面的页面上去 (焦点陷阱)。少了它,键盘用户会 Tab 着 Tab 着就「掉出去」,操作到一个自己看不见的控件;
关闭后焦点回到触发它的那颗按钮。不还焦点的话,焦点掉回文档开头,要从头 Tab 一遍。
⚠️ 这条实现起来最容易栽在一个地方:别去读
document.activeElement判断「焦点原本在不在抽屉里」。 鼠标按下时浏览器会先把焦点打到body——点遮罩必然如此,Safari / WKWebView 下点按钮也一样, 而插件正是跑在 WKWebView 里。等你的处理函数跑起来,焦点早就不在抽屉里了, 一判断就误判成「不用归位」,结果点遮罩关掉之后焦点掉回文档开头。 正确的做法是按关闭方式决定:点按钮、点遮罩一律归位;只有 Esc 需要看一眼焦点在哪 (用户正看着页面别处随手按 Esc 时,把焦点拽回去会让长页面猛地滚动)。
⚠️ 一个很容易踩的实现坑:如果用 hidden / display:none 控制显隐, 同一帧里既改显隐又改 transform,过渡不会播——元素从 display:none 出现的那一刻 没有可过渡的起始值,关闭时又被立刻藏起来,动画来不及跑。要么用 requestAnimationFrame 把两步分到两帧,要么改用 visibility + opacity/transform 这类不脱离渲染树的方案。写了动画却不播,比不写动画更糟——你会以为它生效了。
6. 状态:四种情况都不能白屏
列表和数据区必须显式处理四种状态,缺一种就会出现"一片空白,不知道是在加载还是坏了":
| 状态 | 要给什么 |
|---|---|
| 加载中 | 骨架屏或转圈 + 一句说明;超过 400ms 才显示,否则一闪而过反而更晃眼 |
| 空 | 一句"为什么是空的" + 一个"怎么让它不空"的操作。不要只写"暂无数据" |
| 失败 | 说人话的原因 + 重试按钮;错误码与原始原因按 §6.0 放在可展开处,不要只甩一个错误码 |
| 无权限 | 说清缺哪个权限 + 引导到「已安装 → 权限」。不要自己弹系统授权框 |
「超过 400ms 才显示」只管列表和数据区自己加载时的骨架屏或转圈。用户点了按钮之后的反馈不等, 按 §3.6 在 100ms 内出现。
6.0 等待与失败的最低信息量(Host 与插件通用,2026-09-27)
用户在等待或失败时看到「只有转圈、没有原因、没有版本」是不可接受的。所有加载、检查、 安装、同步类等待,以及所有失败与超时,不论 Host 自有界面还是插件 Surface,最低要求:
- 等待要说清在做什么:当前步骤的具体对象(例如「正在核对 Codex 运行组件」),有 可数进度就给「第几个 / 共几个」,并显示已用时间。文案来自真实事件或状态,不能用 写死的百分比或假进度,也不能只放一个转圈。
- 能看到版本:阻塞整页的等待与失败界面必须能看到 App(插件界面则是插件)的版本号, 放在页脚或诊断信息里均可。插件的版本取
app.manifest.json的version(构建时读入, 不另写一份常量);插件诊断里还要带 Host App 版本,即ctx.systemTasks.getVersionStatus()返回的app.currentVersion。它要求 Manifest 声明普通开放能力system.tasks@1;调用方 Surface 在 Host 里要有有效的展示记录(没有或已撤销就会被拒,例如SYSTEM_TASK_VISIBLE_SURFACE_REQUIRED;缺会话时先报其他错误码),同一界面 2 秒内限一次 ——所以在界面挂载时读一次缓存起来。读取失败就在诊断里写明读取失败和错误码,不要留空。 - 失败与超时给真实原因:用户可读的一句话 + 可展开的原始原因与错误码。不能只写 「检查时间较长」「出错了」。超时不等于失败,要说明后台是否仍在继续。
- 超时写明是哪一步:哪一步、等了多久(例如「等待云端识别结果超过 30 秒」),错误码 也要区分出这一步(参照实现用
LIFECYCLE_CHECK_TIMEOUT),不能只写「请求超时」。 - 不许把真实结果改写成更模糊的状态:失败、超时、被拒绝不能显示成「已取消」「已结束」 「暂无结果」,也不能静默收起。「已取消」只用于用户自己点了取消。拿不到错误码就如实 写「无错误码」并保留原始信息,不要编一个。
- 超时写明是哪一步:哪一步、等了多久(例如「等待云端识别结果超过 30 秒」),错误码 也要区分出这一步(参照实现用
- 说了「查看诊断」就必须真的有入口:诊断至少列出各个对象的当前状态、最近一次 错误的原因与代码、日志位置,并能一键复制全文。做不到就不要在文案里承诺。
- 复制的全文要自带版本与生成时间(插件界面是插件版本 + Host App 版本):只显示在 页脚上的版本不会跟着复制出去,用户贴给支持的那段文字里必须有。
- 复制的全文不得含敏感内容:token、Cookie、密钥、授权头、带签名的 URL 查询参数,以及 完整的用户输入、转写、对话正文一律不进;需要描述内容时只给长度、条数或 ID(如「转写 128 字」)。原始错误拼进诊断前按同样规则过滤,错误原文里常夹着请求 URL 和 token。
- 插件复制走
ctx.clipboard.writeText()(grant-gated 能力surface.clipboard@1);没有 这项能力或复制失败时,把同一段文本放进可选中的文本块让用户手动复制(参照实现复制失败 时就是这样兜底)。
- 可恢复的给恢复动作(重试 / 重新检查 / 修复),不可恢复的说明原因与出路。
- 中英文同时提供;原始错误可以不翻译,但必须有本地化的主句。
插件读两个版本的写法:
ts
// app.manifest.json 与 src/ 同级;tsconfig 需开 resolveJsonModule
import manifest from "../app.manifest.json";
const pluginVersion = manifest.version;
// 界面挂载时读一次;失败把错误码和原因写进诊断,不要吞掉
const hostVersion = await ctx.systemTasks.getVersionStatus()
.then((status) => status.app.currentVersion)
.catch((error: unknown) => `unavailable (${describeError(error)})`);
// Bridge 拒绝可能是 { code, userMessage }、Error 或一个字符串,三种都要留下原文
function describeError(error: unknown): string {
if (typeof error === "string") return error;
const e = (error ?? {}) as { code?: unknown; userMessage?: unknown; message?: unknown };
const code = typeof e.code === "string" ? e.code : "no code";
const reason = typeof e.userMessage === "string" ? e.userMessage
: typeof e.message === "string" ? e.message : String(error);
return `${code}: ${reason}`; // 拼进复制文本前还要按上文过滤敏感内容
}Host 的参照实现是内核门禁页(driver-v2/src/components/AppLifecycleRecovery.vue 与 src/first-install/KernelDiagnostics.vue),规则来源见 内核门禁规范(仓库内 docs/driver-v2-kernel-startup-gate.md#等待与失败的信息硬约束2026-09-27)。
6.1 设置项:说明、反馈与分隔
本节是插件设置页的布局合同,适用于普通设置行、分段选择及条件反馈。 实现注释应引用稳定地址: https://ai-board.reai.com/docs/plugin-design-system-v1#settings-content-layout。 可运行示例展示三档选择及异常反馈。
标题与辅助说明(title + description)
- 标题独占第一行,使用
--text-primary、14px / 20px、600 字重;浅色主题表现为深色标题,暗色主题跟随主文字色,不硬编码黑色。 - 说明位于第二行,使用
--text-secondary、12px / 20px,与标题间隔 4px。标题和说明属于同一个设置项,中间不画 divider。 - 设置项四周内边距 16px;控件与文字间至少 12px。独立设置项之间的 divider 左右内缩 16px,不能让文字贴线。
标题与动态反馈(title + feedback)
标题与控件组成首行;随选择变化的说明及相关策略归入同一个反馈区。
| 位置 | 必须满足 |
|---|---|
| 设置项外侧 | 水平内边距 16px;顶部、底部内边距 16px |
| 首行与反馈之间 | divider 内缩到文字内容区,左右均距卡片边缘 16px;线上、线下各留 12px |
| 反馈内容之间 | 当前选项说明与条件策略间隔 12px |
| 策略标题与正文 | 标题另起一行,用主文字色;正文用次级文字色,两者间隔 4px |
divider 用于区分信息层级,不能直接借通用行的通栏 border-bottom 实现。 必须由设置项或反馈容器明确拥有分隔,禁止靠 :last-child 猜测:插入动态说明、错误或开关后,边框不能因此改变语义。 内容较短时可以采用无分隔的 title + description 布局;任何方案都不能省略 padding。
条件策略与真实错误
- 当前选项说明随已生效选项变化。以 Voice 为例,原样档不显示云端润色失败策略;轻度和规整档显示对应说明及失败时的处理。
- “失败时怎么办”是条件策略,用中性文字分组,不使用无语义的虚线框或告警颜色冒充正在发生的错误。
- “设置未保存”“当前权限被关闭”是真实状态,独立显示、说清当前仍生效的值或受阻原因,以及已有的恢复入口;错误不得被截断或自动消失。
- 保存失败时保留或恢复之前已生效的值,不得把失败的提交显示成成功。策略文字也不能把权限拦截误说成“按原话注入”。
- 具体录音或润色任务的结果在对应任务/记录页面呈现,设置页不虚构正在处理、成功或回退状态。
适配与验收
长文案自然换行;窄窗让控件换行,不能压缩必要留白;不设会截断内容的固定高度。 动态反馈应采用合适的无障碍关联与状态播报,不夺走用户焦点。 逐项验收原样、轻度、规整、保存失败和权限受阻,覆盖长文案、窄窗与明暗主题。 量取真实渲染的 padding、divider 内缩及上下间距,并保留设计稿/插件同状态截图与实际插件版本、包摘要。 只验证文案存在、选择回调或构建通过,不能算视觉还原通过。
6.2 AI 回复与 Markdown 排版
当 AI 回复包含 Markdown 时,消息正文应呈现为对应的段落、标题、加粗、列表、引用、代码块和表格, 不能把 **加粗**、列表标记或代码围栏当作普通字符串展示。用户输入、工具状态标签与错误说明仍按各自语义处理, 不要给所有文本节点统一套 Markdown。
- 使用成熟解析器,并关闭原始 HTML;不可把模型输出直接交给
innerHTML。解析后的输出也必须遵守插件 CSP。 - 不执行回复中的脚本或事件属性,不自动加载远程图片;链接必须验证协议,并遵循 Host 导航能力,不能绕过 WebView 边界。
- Markdown 元素使用 Host 主题 Token,尤其是引用、代码、表格和来源地址;不要从文档网站复制一套固定浅色样式。
- 气泡限制宽度并允许长词换行;代码块保留空格和换行,代码及宽表格在内部横向滚动,不撑宽整个消息区。
- 测试中文混排、嵌套列表、尚未闭合的流式 Markdown、长代码、宽表格、HTML 与危险 URL;确认历史消息重开后排版一致,用户原文不被改写。
- 用浏览器或真实插件 WebView 验证明暗主题、主题切换和窄窗布局;解析器单元测试通过不能代替视觉验收。
源码修复与已安装插件是两个交付节点。默认 Driver 构建消费 Catalog 锁定的不可变插件包; 合并源码不会自动更新旧包。复测须记录插件版本和包摘要,并明确使用已批准包还是 plugin-dev 源码验收包。 不得为了让修复立即出现而把未审核候选伪装为已批准 Catalog 产物。
7. 交付前自检
提交插件前对着过一遍,每条都能答"是"才算合格:
- [ ] 设置项遵循 §6.1:title + description 两行、4px 间隔,title + feedback 的 divider 内缩且上下各 12px
- [ ] 条件策略保持中性;真实失败、权限受阻单独呈现,没有虚线套框、贴线文字或错误被截断
- [ ] 已比较选择变化与异常状态的实际布局,并记录视觉验收和插件产物证据,未把 DOM 文案测试当作视觉验收
颜色
- [ ] 样式表里搜不到写死的十六进制颜色(
--*别名的兜底值除外) - [ ] 切换明暗主题后,插件全部内容仍然清晰可读
- [ ] 用到的每个 Host Token 都能在
theme/tokens.rs里查到;插件别名已声明且最终指回 Host Token - [ ] 已按 §1.5 检查状态条文字、图标、背景和明暗主题切换,未用 fallback 掩盖变量拼写错误
- [ ] 自己的变量名没和 Token 撞名
- [ ] 样式都在样式表里,没有内联
style="…"(CSP 会拦)
图标
- [ ] 没有用文字字符当图标(
×↻→一个都没有) - [ ] 尺寸只出现 14 / 16 / 20 三档
- [ ] 标准档线宽是 2——和 Host 图标并排看不出粗细差别
- [ ] 每个纯图标按钮都有
aria-label
控件
- [ ] 样式表开头有全局 button 基线,
:focus-visible保留 - [ ] 遮住上下文,每颗按钮都说得出属于哪一档
- [ ] 每颗按钮都有 hover 反馈
- [ ] 带悬停 / 按下底色、描边或盒内焦点框的按钮,四个方向都留了白(§3.5:标准左右 ≥ 8px、上下 ≥ 4px,紧凑 ≥ 4px / 2px);保留
padding: 0的纯文字链接都注明了刻意 - [ ] 独立图标按钮的命中区实测不小于 44×44px;密集成组按钮至少 28px 高且命中区不重叠
- [ ] 一屏只有一颗主按钮
- [ ] 每个要等待的按钮点下去 100ms 内变样(进行时文案 + 禁用),超过 1 秒在按钮下方显示真实进度或「阶段 + 已用时间」,没有编造的百分比;进行中点不出第二次;完成与失败都有终态,失败带原因、错误码、版本、诊断和「重试」(§3.6)
- [ ] 等待类状态切换(未安装 / 正在安装 / 就绪)不改变所在行或卡片的高度,三态连录截图容器四边不动(§3.6 第 5 条)
- [ ] 下拉全部走统一组件:
appearance: none+ 自绘箭头 + 右侧留白,明暗主题与同屏其他下拉一致,没有裸原生 select(§3.7) - [ ] 清单类数据(已装应用 / 可用模型 / 目录内容)来自运行态并随装卸、上新实时增减;上游选择变化后下方列表整体替换,无残影、无「选了没反应」;清单行第一行是人话、技术标识在第二行小字(§3.7)
布局
- [ ] 间距都是 4 的倍数
- [ ] 圆角不超过三档,嵌套时外大于内
- [ ] 页面只有一条 Host Title Bar;插件没有第二层
main-title、窗口拖拽区、通知或蓝色顶栏渐变 - [ ] 根框架只消费一次
--reai-plugin-titlebar-safe-top;main-body填满剩余高度且是唯一纵向滚动容器 - [ ] 顶部交界已采用“同色连续”或“异色内缩”;不存在两个不同颜色的全宽大色块零距离硬切
- [ ] 抽屉 / 弹层锚定
main-body,没有用 viewportposition:fixed绕过 Host 安全区 - [ ] 根路径直接显示 App 名;子页有返回按钮,面包屑祖先可点、当前项只读,且没有
Apps /前缀 - [ ] 页面级操作通过 Host
titlebarActions,没有把插件按钮绝对定位进 Host 区域 - [ ] 已登记为导航开关的设置动作在设置页保持按下并有
aria-pressed;再次点击真实返回根页,读屏文案同步为“返回插件首页”
回复排版与产物
- [ ] AI 回复按 §6.2 呈现 Markdown,长代码和表格没有撑宽消息区
- [ ] 原始 HTML、危险 URL 与远程图片不会执行或自动加载;用户原文保持不变
- [ ] 已记录实际验收插件的版本、包摘要与来源,没有把源码合并当成旧包已更新
抽屉与状态
- [ ] 抽屉能用按钮 / Esc / 点遮罩三种方式关闭
- [ ] 打开时焦点进抽屉、Tab 在抽屉内循环、关闭后焦点归位
- [ ] 写了过渡动画的,确认它真的播了(别被
display:none同帧切换吃掉) - [ ] 加载 / 空 / 失败 / 无权限四种状态都有明确呈现
- [ ] 等待态说清在做什么(对象、第几个 / 共几个、已用时间),阻塞界面能看到版本号;失败与超时给出真实原因,写了「查看诊断」就真的能打开并复制(§6.0)
- [ ] 失败、超时、被拒绝没有被改写成「已取消」之类更模糊的状态,超时写明了是哪一步;复制出的诊断文本带插件与 Host App 版本和生成时间,且不含 token、密钥与用户原文(§6.0)
8. 平台侧的配套
本文只有第 2 章依赖尚未落地的平台能力,其余各章现在就完全可执行。
| 平台侧事项 | 状态 |
|---|---|
| 主题 Token 注入与主题热更新(§1.1) | ✅ 已实现;本文首次把它写成对插件的正式合同 |
| 标题栏动作按名字用 Host 图标(§2.5) | ✅ 已实现(titlebar.action@1,Host API 1.2.0) |
| Host 渲染根路径、二级页返回与可点击面包屑(§4.5) | ⏳ 统一设计已定;公开子路由 / 路径贡献合同尚未实现,插件不得自行注入 Title Bar |
| 往插件页面注入公共图标精灵图(§2.5) | ⏳ 未实现。页面内图标先按 §2.1 自带,尺寸线宽照 §2.2,将来只删不改 |
示例页部署到 ai-board.reai.com/design/plugin-v1/ | ✅ 已上线。独立 docroot、零构建、单独同步(与 /dfu/ 同模式),带 noindex 不对外导流 |
⚠️ 示例页是独立 HTML,不进开发者文档站的 Markdown 管线——文档站只同步 .md。 所以本文引用它一律用完整外链,不要改成仓库相对路径:相对链接会在文档站构建时被 改写规则吃掉,正文里留下半截话。