Skip to content

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 表(原 Board 仓库内 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 图标表(原 Board 仓库内 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 走 ​

档位尺寸线宽用在哪
小14px2.4密集列表内的行内图标、徽标
标准(默认)16px2绝大多数场景:按钮、菜单、状态
大20px2空态插画、页面级主图标

标准档那个 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 16px0(图标居中)
圆角10px8px
字号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 调用或另一个进程的一次性操作——安装、更新、 下载、发送、保存、同步、登录、授权、生成。纯本地、瞬间完成的操作(开关、展开折叠、切标签) 本身的状态变化就是反馈,不需要进度条。

最小要求(四条都要满足):

  1. 100ms 内有可见变化。 点击后 100ms 内,触发它的按钮必须变样:文案改成进行时 (「正在安装…」)并禁用;需要等待的,按钮正下方同时出现进度区。实现上就是: 在第一个 await 之前改界面状态。先 await 再改,等待的那几秒就是空白。
  2. 超过 1 秒要说清进行到哪。 能拿到真实字节或条目计数(已下载 12.4 / 38 MB、第 3 / 5 个) 就显示确定进度;拿不到就显示当前阶段名 + 已用时间 + 不确定进度条(来回跑的短条)。 阶段名来自真实的调用边界或事件(「正在下载并校验安装包」「正在启用」),不许编百分比, 也不许按时间匀速涨一个假进度——走到 90% 卡住比没有进度更伤信任。已用时间超过 1 秒再显示, 免得瞬间完成的操作闪一下「已用 0 秒」。
  3. 进行中禁止重复触发。 按钮禁用只是第一道;处理函数入口再用一个进行中标志挡一次 (键盘回车、跨窗口转发来的迟到点击都绕得过禁用态)。同一件事的第二次请求并入进行中的那一次, 不要把进度重置回起点。
  4. 完成和失败都有明确终态。
    • 完成:显示一句完成态(「已安装,正在继续刚才的搜索」),停留 1.5–3 秒再收起;或者由 接下来的界面直接接管(卡片变成「已安装」、页面打开新内容)。不能「进度条一消失就什么都没了」。
    • 失败:按 §6.0 显示真实原因、错误码、版本号、可复制诊断,并给 「重试」。失败不自动消失;用户关掉了就在他回来时还在。「已取消」只留给用户自己点了取消。
  5. 进行中状态不得引起布局跳动(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,最低要求:

  1. 等待要说清在做什么:当前步骤的具体对象(例如「正在核对 Codex 运行组件」),有 可数进度就给「第几个 / 共几个」,并显示已用时间。文案来自真实事件或状态,不能用 写死的百分比或假进度,也不能只放一个转圈。
  2. 能看到版本:阻塞整页的等待与失败界面必须能看到 App(插件界面则是插件)的版本号, 放在页脚或诊断信息里均可。插件的版本取 app.manifest.json 的 version(构建时读入, 不另写一份常量);插件诊断里还要带 Host App 版本,即 ctx.systemTasks.getVersionStatus() 返回的 app.currentVersion。它要求 Manifest 声明普通开放能力 system.tasks@1;调用方 Surface 在 Host 里要有有效的展示记录(没有或已撤销就会被拒,例如 SYSTEM_TASK_VISIBLE_SURFACE_REQUIRED;缺会话时先报其他错误码),同一界面 2 秒内限一次 ——所以在界面挂载时读一次缓存起来。读取失败就在诊断里写明读取失败和错误码,不要留空。
  3. 失败与超时给真实原因:用户可读的一句话 + 可展开的原始原因与错误码。不能只写 「检查时间较长」「出错了」。超时不等于失败,要说明后台是否仍在继续。
    • 超时写明是哪一步:哪一步、等了多久(例如「等待云端识别结果超过 30 秒」),错误码 也要区分出这一步(参照实现用 LIFECYCLE_CHECK_TIMEOUT),不能只写「请求超时」。
    • 不许把真实结果改写成更模糊的状态:失败、超时、被拒绝不能显示成「已取消」「已结束」 「暂无结果」,也不能静默收起。「已取消」只用于用户自己点了取消。拿不到错误码就如实 写「无错误码」并保留原始信息,不要编一个。
  4. 说了「查看诊断」就必须真的有入口:诊断至少列出各个对象的当前状态、最近一次 错误的原因与代码、日志位置,并能一键复制全文。做不到就不要在文案里承诺。
    • 复制的全文要自带版本与生成时间(插件界面是插件版本 + Host App 版本):只显示在 页脚上的版本不会跟着复制出去,用户贴给支持的那段文字里必须有。
    • 复制的全文不得含敏感内容:token、Cookie、密钥、授权头、带签名的 URL 查询参数,以及 完整的用户输入、转写、对话正文一律不进;需要描述内容时只给长度、条数或 ID(如「转写 128 字」)。原始错误拼进诊断前按同样规则过滤,错误原文里常夹着请求 URL 和 token。
    • 插件复制走 ctx.clipboard.writeText()(grant-gated 能力 surface.clipboard@1);没有 这项能力或复制失败时,把同一段文本放进可选中的文本块让用户手动复制(参照实现复制失败 时就是这样兜底)。
  5. 可恢复的给恢复动作(重试 / 重新检查 / 修复),不可恢复的说明原因与出路。
  6. 中英文同时提供;原始错误可以不翻译,但必须有本地化的主句。

插件读两个版本的写法:

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),规则来源见 内核门禁规范(原 Board 仓库内 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 产物。


6.3 设置页底部:版本与关于插件 ​

所有插件必须提供设置页;业务设置后在页面底部统一展示可点击的版本号和「关于插件」,顺序固定,长页随正文滚动,不覆盖设置内容。版本来自当前安装包 app.manifest.json;点击定位该插件的精确版本更新日志。「关于插件」打开 open.reai.com 中同 appId 的应用详情页。

沿用 §6.1 的设置行、间距和 Host 主题 Token,支持窄窗换行、键盘焦点、中英文与读屏。完整 URL 合同、缺失版本处理和每次送审自动撰写介绍要求见设置页与版本文档规范。这是 2026-10-02 确认的新要求,存量插件需逐项改造和实际点击验收。

7. 交付前自检 ​

提交插件前对着过一遍,每条都能答"是"才算合格:

  • [ ] 设置页底部版本号和「关于插件」可点击;版本与当前安装包一致,日志定位精确版本,关于到同 appId 的具体应用;本次送审的插件更新日志已自动撰写并复核

  • [ ] 设置项遵循 §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,没有用 viewport position: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。 所以本文引用它一律用完整外链,不要改成仓库相对路径:相对链接会在文档站构建时被 改写规则吃掉,正文里留下半截话。

ReAI App 平台合同 v1.1

← 返回 ReAI Open