93 KiB
Maka 设计系统契约 · design-system.md
Archived on 2026-07-13. This wave document mixes historical implementation plans and snapshots with design rules; use the local frontend READMEs, source, and contract tests for current guidance.
编写者:@yuejing · 编辑伙伴:claude-code agents · 版本:v0.2-wave-10
这是 Maka 桌面端的设计系统契约,不是手册、不是规范文档、不是 marketing copy。它把 PR55-PR106 之间逐渐沉淀下来的隐式规则显式化,以便:
- 任何 PR 改动 UI 时可以被 reviewer 和 release-gate 审计;
- 新进的 AI agents(claude-code style)可以读完这一份就知道边界;
apps/desktop/tests/smoke.md+apps/desktop/src/main/__tests__/*.test.ts
scripts/check-console.mjs三条 release gate 有一个共同的口径。触发本文档更新的事件:见 §10 版本与变更策略。
0. 目的与范围(Purpose + scope)
0.1 这是什么
本文档对应 @kenji v2 audit 的 item 6 — "Visual system as contract"。它是 后续 next-wave PR 的前置条件:
- ReasoningPanel 两态切换、tool stream pane、Settings IA 扩充第一轮、Quick Chat MVP 等改动落地前,相关组件契约必须在本文件登记。
- 新 PR 引入或废弃 token 时,本文件与代码必须同 PR 修改。
- 本文件是 release-gate 友好的:审计 PR 时,文档与代码的偏离 ≡ 契约违规。
0.2 这不是什么
- 不是一份 marketing-style "Maka 设计哲学" — 那是另一篇文档的职责。
- 不是 reference implementation re-engineering doc 的复述,详细对照见
notes/maka-vs-reference implementation-gap-v2.md。 - 不是用户文档;用户视角的能力公告应该在 Settings 页面与 ComingSoon copy 内呈现,不在这里。
0.3 适用范围
| 表面 | 受本文档约束 |
|---|---|
apps/desktop/src/renderer/ 所有 React 组件 |
✅ |
packages/ui/src/ 共享组件 |
✅ |
apps/desktop/src/renderer/maka-tokens.css token 定义 |
✅(本文件 §1 是其规范) |
apps/desktop/src/renderer/styles.css 组件 recipes |
✅ |
| Settings IA + Coming Soon copy | ✅(§5 文案契约) |
| Markdown 渲染 / hljs 调色板 | ✅ |
| 后端 IPC / runtime / @maka/core 的 token 命名 | ❌(后端 contract 见 @kenji notes/) |
| 第三方依赖(lucide-react / react-markdown / rehype-highlight)内部样式 | ❌(仅约束其使用方式) |
0.4 核心原则
这些是规则。新 PR 若需打破其中任何一条,须先以同一 PR 修改本文档。
- token 不二选一:颜色/间距/字号/阴影/动效曲线/z-index 全部来自
maka-tokens.css,不在组件里硬编码 hex / px / 数字曲线。 - 5 态契约:任何交互组件须明确
default / hover · focus / active · pressed / disabled / loading · error(不适用时显式声明),见 §3。 - 4 态表面:任何信息表面须同时落地
focus / loading / error / empty四种 状态,见 §6。 - 政策即文案:能力收缩用"不会做什么"做契约,不是"暂不支持"占位,见 §5。
- runtime 字符串走 redactor:任何展示 user-typed / provider-returned 文本
的位置须经
packages/ui/src/redact.ts或generalizedErrorMessage,见 §5、§8。 - prefers-reduced-motion 默认服从:所有动画 / 过渡须能在 reduced-motion 下退化为 ~0.01ms,详见 §4。
1. 基础 token(Foundational tokens)
单一权威:
apps/desktop/src/renderer/maka-tokens.css。本节按"类别 → 名称 → 取值或公式 → 表面 → 何时不要用"格式登记。Tailwind v4@theme inline暴露 的别名只是镜像,不构成新 token。
1.0 6-色哲学(the 6-color philosophy)
background / foreground / accent (logo blue) / info (amber) / success (green) / destructive (red)
派生规则:
- 灰阶不是单独的 hue。
--foreground-N是把 foreground 按 N% 与 background 做color-mix(in oklch, ...);--border / --state-hover-bg / --state-selected-bg / --muted / --ring是 foreground 的 alpha 叠加(oklch(from var(--foreground) l c h / α))。 - 状态 tone(accent/info/success/destructive)配套的
*-text变体已经把饱和 度向 foreground 拉,专门给"在 token 背景上要可读的文字"用。 *-rgb三元组只服务于 box-shadow ring(rgba 需要分量值,不能直接吃 oklch)。 绝对不要拿*-rgb当一般颜色变量。--brand-deep是少数品牌强调位的历史 alias。不要再扩到 CTA 或 checked 控件;这些分别走--action和--control。
1.1 颜色(Color)
| Token | 用途 | 何时不要用 |
|---|---|---|
--background |
顶层背景、modal 体 | 不要用作"灰底卡片"——卡片用 --background-elevated |
--background-elevated |
卡片、Pill 内部填充 | 不要用作页面主背景 |
--foreground |
主文字(primary, 100% ink)、Active 行文字、modal title | 不要在 --background-elevated 上做"略低对比" — 用 --foreground-secondary |
--foreground-secondary |
副文字(80% ink):tool meta、titlebar、sidebar row、caption heading | 不要做大段正文或主文字 |
--muted-foreground |
muted 文字(50% ink):placeholder、disabled text、scrollbar hover、极弱标注 | 不要做 caption 或副文字 — 那是 --foreground-secondary |
--foreground-dimmed |
极少用 — 主要是 placeholder hover 时的过渡 | 一般用 --muted-foreground 替代 |
--accent |
链接、focus ring、active 状态、live/status dot、toast/sidebar accent 等 garnish | 不要做 primary CTA 或 checked 控件;用 --action / --control |
--action / --action-foreground |
primary CTA、submit/send/apply/done 等实心动作按钮 | 不要做 checkbox/radio/switch/progress 的 selected 状态 |
--control / --control-foreground |
checkbox/radio/switch/progress 等 checked/on/progress 状态 | 不要做 primary CTA |
--info |
琥珀,ask 模式、warning 类提示、info_text 文案 |
不要拿 info 表"启用中流式" — 那是 --accent |
--success |
绿色,"已验证"、"已完成"、connected | 不要拿 success 表"已配置但未测试" — 那是 info |
--destructive |
红,error、denied、fs_destructive、git_destructive | 不要因为 hover/active 看起来更"突出"就升级到 destructive |
--brand-deep / --brand-deep-hover |
少数品牌强调位、reverse lineage 提示 | 不要做 CTA 或 checked 控件,不要新增调用 |
--success-text / --info-text / --destructive-text |
文字色(向 foreground 拉过) | 不要用作背景或边框 |
--foreground-2 / 3 / 5 |
极淡填充:sidebar、code、tool 卡片(表面 wash 档,非文字) | 不要做正文 |
--foreground-8 / 10 |
分割、shadow accent、block bg(表面 wash 档,非文字) | 不要做正文 |
--border |
1px 默认分割线 / 卡片外框 | 不要做 hover ring(那是 --ring) |
--border-strong |
选中态/active 卡片框 | 不要做默认外框 |
--muted |
与 border 同 α,语义上"作为背景而非分割" | 不要混用 |
--ring |
focus-visible 的 2px box-shadow | 不要做静态边框 |
--state-hover-bg |
sidebar row / button ghost hover 填充 | 不要用作 selected/pressed(那是 --state-selected-bg) |
--state-selected-bg |
sidebar row selected / button :active (pressed) | 不要做 hover(那是 --state-hover-bg,视觉会“提前”) |
主操作是浅蓝片 + 深蓝字:--action oklch(0.85 0.08 250) 配 --action-foreground oklch(0.30 0.06 250)(8.64:1,light/fresh CTA,不再是深色实心按钮)。选中/进度用 --control oklch(0.65 0.135 250)——比 accent L0.70 低一档,让近白 glyph 过 WCAG 1.4.11 非文本 3:1(3.09:1;logo L0.70 只到 2.55:1,故 control 单独取 L0.65)。链接、focus ring、在线点、nav active、toast accent 走 --accent(logo 蓝 oklch(0.70 0.135 250),emphasis alias 族)。--success 是独立绿 token(connected/ok 语义),不挂 accent,迁蓝后仍保持绿色。
| --chat-user-bg / --user-message-bubble | user 气泡背景(slate,区别于 accent) | 不要用作 assistant 气泡(assistant 不要气泡) |
| --chat-user-foreground | user 气泡文字 | 仅在 user-bubble 内部 |
| --selection | text selection 高亮 | 不要做 hover |
1.2 阴影(Shadow)
| Token | 公式概览 | 用法 | 不要 |
|---|---|---|---|
--shadow-border-opacity |
light 0.08 / dark 0.15 | 内部用,调 ring 浓度 | 不在组件直接消费 |
--shadow-blur-opacity |
light 0.06 / dark 0.12 | 内部用 | 不在组件直接消费 |
--shadow-minimal |
1px ring + 2 层 6% blur | Composer 主框、Toast | 不做 modal |
--shadow-minimal-flat |
仅 1px ring,无 blur | sidebar item card、provider chip | 不做需要漂浮感的元素 |
--shadow-medium |
1px ring + 4 层 blur 渐远 | popover、dropdown、command palette | 不做行内元素 |
--shadow-modal |
1px ring + 5 层 blur,最远 24px | Settings / Permission / Confirm modal | 不做非 modal |
硬规则:影子的第一层永远是 1px ring(
rgba(var(--foreground-rgb), 0.06)), 第二层往后才是模糊 blur。新阴影 recipe 必须遵守这个顺序,否则会和现有阴影 在 dark mode 互相蚕食对比。
1.3 排版(Typography)
| Token | 值 | 用法 |
|---|---|---|
--font-sans |
"Geist Variable", system-ui, …, sans-serif |
默认正文 / UI |
--font-mono |
"Geist Mono Variable", "JetBrains Mono", …, monospace |
代码、tool 名称、model id、provider type、token 计数 |
--font-default |
var(--font-sans) |
body 兜底 |
--font-size-base |
15px | html 基准;正文、nav-item label、page hint |
--font-size-ui |
13px | chrome/control;sidebar row、form label、button、settings row、card title |
--font-size-caption |
11px | dense meta;timestamp、badge、kbd、overline、tool-card meta |
kbd |
mono + 0.85em + var(--foreground-5) 底 + 1px border |
composer hint / 设置帮助 |
Tailwind alias:--text-xs→caption(11px), --text-sm→ui(13px), --text-base→base(15px)。
三档 scale(PR-TYPOGRAPHY-CONVERGE-0, issue #430 PR2):
- base 15px —
htmlroot,不动。改它会全局 scale 所有 rem。 - ui 13px — chrome/control。sidebar row、form label、button、settings row、card title。
- caption 11px — dense meta。timestamp、badge、kbd、overline、tool-card meta。
- 原来的 9/10/10.5/11/11.5px 和 0.68–0.78rem(15px root 下 10.2–11.7px)收敛到 caption。
- 原来的 12/12.5/13/13.5/14px 和 0.8–0.95rem(15px root 下 12–14.25px)收敛到 ui。
- 半像素值(12.5/11.5/10.5/13.5/14.5)归到最近 tier。
Heading em scaling:.maka-bubble-assistant h1-h4 用 em off body 15(h1=1.47em, h2=1.27em, h3=1.07em, h4=0.93em),track root 自动。Surface-specific hero 大字(onboarding/settings title 16–32px)也用 em。
规则:
- 标识符(model id、provider type、tool name、file path、env key、API endpoint)
必须
<code>或var(--font-mono),绝不混到正文 sans。 - 文章标题、章节标题用 600 weight +
text-wrap: balance(见.maka-bubble-assistant h1..h4)。 tabular-nums用在数字会跳动的位置(duration / token count)。font-size不准裸写Npx/Nrem,必须引用var(--font-size-*)或用em。typography-converge-contract.test.ts锁住这条规则。
1.4 圆角(Radius)
| Token | 值 | 用法 |
|---|---|---|
--radius |
0rem | 默认;page、layout 容器 |
--radius-control |
6px | button / input / small chip / sidebar row / kbd / inline code |
--radius-surface |
8px | card / popover / code block / toast / notice / table |
--radius-modal |
12px | Settings / Confirm / Permission modal / composer / floating card |
--radius-pill |
999px | pill / badge / round dot / skeleton-line |
smooth-corners utility |
superellipse(iOS 风) | 选用,浏览器支持自动 fallback |
Tailwind alias:--radius-sm→control, --radius-md/--radius-lg→surface, --radius-xl→modal。
这是有意的反规范:Maka 整体 0px 直角 + 关键交互组件 6/8/12px + 大面板上限 12px。 新 PR 不要引入 "更柔和" 的 14/16/18/20px,那会破坏 sharp 视觉。
radius-converge-contract.test.ts锁住两条规则:(1)border-radius不准 > 12px (999px圆头例外);(2) 所有border-radius和rounded-[...]必须引用--radius-*token,不准裸写Npx字面量(0/50%/inherit例外)。
1.5 间距(Spacing)
| Token | 值 | 用法 |
|---|---|---|
--spacing = 4px |
基础步距(绝对值,非 0.25rem) | |
--space-0 = 0 |
无间距 | |
--space-0-5 = 2px |
极小间隙(chip 内图标) | |
--space-1 = 4px |
小间隙 | |
--space-1-5 = 6px |
dense UI 半步(chip gap、meta 行) | |
--space-2 = 8px |
turn 内间隙、卡片内边距 | |
--space-2-5 = 10px |
section 间距半步 | |
--space-3 = 12px |
卡片内边距、section gap | |
--space-4 = 16px |
chat turn gap、卡片大边距 | |
--space-5 = 20px |
页面 padding | |
--space-6 = 24px |
消息行水平 padding | |
--space-8 = 32px |
大留白 | |
--space-10 = 40px |
hero 区上下 padding | |
--space-12 = 48px |
大段落间距 | |
--space-16 = 64px |
响应式大留白上限 |
16 以下保留所有偶数(0/2/4/6/8/10/12),dense UI 需要半步(6 在 4-8 之间,10 在 8-12 之间)。16 以上是纯 4px 倍数(16/20/24/32/40/48/64),大间距不需要半步精度。1px 作为 hairline literal 保留,不进 scale。Tailwind 的 p-N/gap-N/m-N 通过 @theme inline 的 --spacing: 4px 和手写 CSS 共用同一把尺子。
spacing-converge-contract.test.ts 锁住两条规则:(1) padding/margin/gap 不准裸写 Npx,必须引用 var(--space-*) token、calc(var(--spacing) * N) 或 literal(0/auto/inherit/1px hairline);clamp()/max()/min() 里的响应式参数例外。(2) TSX 不准用 p-[Npx]/gap-[Npx]/m-[Npx] arbitrary utility(1px 除外),必须用 Tailwind scale utility(p-2、gap-1.5 等)。
Maka 不提供用户可配置的界面密度。新增布局应按具体 surface 的信息量定间距,不要新增全局 density switch。
1.6 动效(Motion)
| Token | 值 | 用法 |
|---|---|---|
--ease-out-strong |
cubic-bezier(0.23, 1, 0.32, 1) |
反馈(按钮 press、focus ring、status 变化) |
--ease-in-out-strong |
cubic-bezier(0.77, 0, 0.175, 1) |
屏内运动(panel 切换) |
--ease-drawer |
cubic-bezier(0.32, 0.72, 0, 1) |
保留给后续结构性抽屉 / sheet;默认弹层不做进出场 |
弹层、tooltip、toast、tool/result card 默认 instant appear/disappear。只保留功能性动画: spinner、status pulse、streaming caret、shimmer,以及必要的 hover/press/focus feedback。详见 §4。
1.7 z-index 阶梯
| Token | 值 | 谁在这一层 |
|---|---|---|
--z-base |
0 | 普通 inline 内容 |
--z-sticky |
20 | sidebar header、sticky toolbar |
--z-titlebar |
40 | macOS 标题栏 / app 主标题栏 |
--z-panel |
50 | 右侧 rail / artifact panel(未来) |
--z-dropdown |
100 | 下拉菜单 / popover |
--z-tooltip |
150 | Tooltip primitive(未来) |
--z-modal |
200 | Settings / Permission / Confirm |
--z-overlay |
300 | 全屏 cinematic overlay、film-grain noise 层 |
硬规则:任何新表面必须先在本表里登记 z 层,禁止裸
z-index: 9999。
1.8 布局尺寸(Layout)
| Token | 值 | 用法 |
|---|---|---|
--w-sidebar |
260px | 左侧 nav 列宽 |
--w-rail |
240px | 右侧(artifact panel 等) |
--h-titlebar |
36px | 标题栏 |
--h-toolbar |
40px | 二级工具栏 |
--h-composer-min |
44px | 输入框(textarea)最小高 |
1.9 图标(Iconography)
图标由 @maka/ui/icons 出口(通用 UI 图标底层是 lucide-react;bot/channel 品牌 SVG
由本地 vendored body 渲染),按语境分档,不是全局统一一个尺寸。
| 档 | 尺寸 | 设定方式 | 用途 |
|---|---|---|---|
| chrome | --icon-size(16px) |
token | 左侧 nav 图标 + buttonVariants 按钮内图标 |
| 密集 meta | 12–14px | call-site size={…} |
状态点、footer 操作、chip、与小字号同行的内联图标 |
| 强调 / hero | 20px+ | call-site size={…} |
onboarding、错误页、settings about、permission 等单个语义图标 |
规则:
- chrome 是唯一被 token 化的档。 nav 图标(
.maka-nav-icon)和packages/ui/src/ui.tsx的buttonVariants([&_svg]:size-[var(--icon-size,1rem)])都消费--icon-size,所以两者不会再各走各的(历史缺陷:nav 18px 而按钮 1rem)。改这一个 token 同时移动两者。 - 密集 / hero 是 call-site 刻意值,不收敛。 它们是有意的视觉层级,不是漂移;不要用全局
svg.lucide { width }之类的规则一锅端,那会把 11–14px 的小图标静默放大、把 hero 压小。 - chrome 图标统一 80% ink(
--foreground-secondary),颜色由组件 variants 声明,不写在 unlayered CSS 里。 按钮内图标继承按钮文字色即可;nav 行因 darwin glass 把行文字提到 100%(theme-glass.css的html[data-os="darwin"] .maka-nav-row),图标由navRowVariants的[&_.maka-nav-icon]utility 钉在 80%,active 行提亮到 100%。不要在 unlayered CSS(如.maka-nav-icon)里写color——它会静默压过@layer utilities里的全部状态意图(历史缺陷:nav 图标被钉在--muted-foreground(50% ink),active/tone 提亮失效,且与 titlebar 图标的 80% ink 不一致)。 packages/ui/src/primitives/*是另一套组件库,有自己的响应式图标尺寸(size-4.5/size-4),不归本节管。
2. 间距契约(Spacing contract)
规则:
-
新组件按 surface 明确写出稳定间距。聊天主 surface 当前使用 18px turn gap、24px horizontal message padding、14px composer vertical padding (8px top / 6px bottom)。
-
不要用全局密度切换改 typography。字号、行高保持稳定;具体页面需要更紧或更松时,在该页面局部调整。
-
不要新增
<html data-ui-density>或 Settings 里的密度配置。界面密度不是产品设置项。
3. 组件契约(Component contracts)
每个组件登记:tone / 使用的 token / ARIA 角色 / 键盘契约 / 5 态行为。
行号来自 packages/ui/src/components.tsx 与 packages/ui/src/toast.tsx。
3.1 Button (.maka-button)
- 位置:CSS recipe
apps/desktop/src/renderer/styles.css内(注:基础 token recipe 在maka-tokens.css末尾@layer components也定义了一份兜底)。 - 变体:
UiButton的variantprop(default | secondary | ghost | outline | destructive | quiet), 通过 cva class 控制,不再使用data-variant属性。 - token:背景
--background / --state-hover-bg / --state-selected-bg;边框--border; destructive 用oklch(from var(--destructive) … / 0.35)border +--destructive-text文字;default/primary 用--primary+--primary-foreground。 - ARIA:原生
<button>,禁用态disabled+aria-disabled="true"(推荐 二者并存以兼容旧 AT)。 - 键盘:
Enter/Space触发,与原生一致;不要拦截 Tab。
| 状态 | 视觉 | token |
|---|---|---|
| default | 边框 + foreground 文字 | --background / --border |
| hover | 灰底,边框不变 | --state-hover-bg |
| focus(visible) | 2px ring + 原 border | --ring(全局 *:focus-visible) |
| active (pressed) | 更深底,无 scale | --state-selected-bg |
| disabled | opacity 0.45 + cursor: not-allowed | — |
| loading / error | N/A — button 自己不持有 loading 态;调用方在父表面(例如 ConnectionDetail)持有 testing 状态并替换 label 为"测试中…"。永远不引入"按钮自旋转"。 |
— |
禁止:
<div onClick>假冒按钮。所有可点击 affordance 必须是<button type=…>或<a href=…>。
3.2 Toast / ToastProvider / ConfirmDialog (packages/ui/src/toast.tsx)
- 位置:
toast.tsx:77ToastProvider;toast.tsx:205ConfirmDialog。 - API:
toast / success / error / info / warning / confirm / dismiss,通过useToast()取,禁止在 ToastProvider 之外调用(hook 会显式抛错)。 - token:4 个 variant 颜色绑定 info / success / warning / error;icon 来自
@maka/ui/icons(底层lucide-react),size={16}固定。 - ARIA:
<ol role="region" aria-live="polite" aria-label="Notifications">- 每条 toast
<li>,dismiss 按钮aria-label="Dismiss"。ConfirmDialog 是role="alertdialog" aria-modal="true"+aria-labelledby+ 可选aria-describedby。
- 每条 toast
- 键盘:ConfirmDialog 绑全局
Enter→ 确认;Escape→ 取消(由useModalA11y(dialogRef, () => onResolve(false)))。toast 自身不可 focus,仅 dismiss 按钮可 focus。 - 5 态:
- default:信息陈列
- hover:toast 整体不改色;action / dismiss button 单独 hover
- focus:dismiss / action button focus-visible 走全局 ring
- active:dismiss / action button :active
- disabled / loading / error:N/A — toast 一旦显示就是终态,4s 后或手 动 dismiss 消失
强约束:
success / error / info / warning是封闭 enum;不要私造第 5 种 variant。Error toast 的默认 duration 是 6s(其他是 4s),刻意更长。
3.3 Modal(useModalA11y hook + .maka-modal-backdrop / .maka-modal)
- 位置:hook
components.tsx:75;CSS recipemaka-tokens.css:1225(.maka-modal-backdrop)。 - 职责:modal 的三件套 — 初始 focus / Tab cycle / Escape + return-focus。
- token:背板
oklch(from var(--background) l c h / 0.6)+backdrop-filter: blur(6px); modal 体--background+--shadow-modal+--radius-modal;尺寸min(520px, 100vw - 48px)×max-height: 100vh - 80px。 - ARIA:
role="dialog"或role="alertdialog";aria-modal="true";aria-labelledby指向 modal 标题;aria-describedby指向 subtitle/description(如有)。 - 键盘:自动 trap Tab/Shift+Tab;Escape 触发 onEscape(若提供);非 alertdialog 的 modal 默认允许 Escape 关闭。
- 状态:
- shown:打开后立即处于稳定态,opacity 1 / scale 1;不做 decorative enter
- focus-within:modal 内任意 focusable 元素被聚焦
- disabled:N/A — modal 不可整体 disable,应在 body 内单独 disable 控件
- loading / error:modal body 内单独负责,见 §6
3.4 PermissionDialog(components.tsx:1641)
这是 Maka 安全 UX 的核心,所有改动须先经 @kenji review。
- 特殊:destructive 路径有显式的 "我已确认,允许" 按钮标签 + 红色 emphasis note + destructive tone primary button。
- token:
data-tone="destructive"时 primary button 走 destructive variant;hint 区使用--infotinted 背景;命令预览走.maka-code(mono)。 - ARIA:
role="alertdialog"、aria-modal="true"、aria-labelledby="permissionTitle"; reason 区有data-reason与.maka-reason-texttone 联动。 - 键盘:Escape 显式禁用(
useModalA11y(dialogRef)不传 onEscape)。原 因:权限决策必须显式 allow/deny,Esc 默认 deny 会被用户误触。 - rememberForTurn:仅在 decision === 'allow' 时回传,且强约束为 per-turn(不 跨 session 持久化)。
- 5 态:
- default:dialog mounted,单选状态为 unchecked
- hover:button hover 走基础规则
- focus-within:Tab cycle 在 dialog 内
- active:button :active
- disabled / loading / error:N/A — 权限决策是同步事件
完整文案契约见 §5.3。
3.5 Composer(components.tsx:1340 forwardRef)
- 职责:唯一的用户输入入口;流式感知(streaming = true 时 swap 工具栏 + 隐 藏 Send + 显示 Stop)。
- token:背景
--background;focus-within border 与 box-shadow 见.maka-composer-inner:focus-within;max-height 240px hardcoded(设计取舍)。 - ARIA:
<form>+<textarea>;toolbar 是普通容器;streaming hint 区域 没有aria-live(已有 Stop button 的 focusable 状态变化)— 这点 §9 列为待 完善项。 - 键盘契约:
Enter→ 提交Shift+Enter/Alt+Enter→ 换行Escape+ streaming → 调onStop()- IME composing 期间所有快捷键 short-circuit(
event.nativeEvent.isComposing或event.key === 'Process')
- placeholder:
"给 Maka 发消息…"固定 — 不要根据 mode 改 placeholder(用户已经看 得到 mode 切换器)。 - 5 态:
- default:单行高,placeholder 灰
- hover:composer 不响应 hover(视觉过载);focus 才升级
- focus-within:accent 边框 + 12% accent ring
- active:textarea active 与 focus 视觉一致
- disabled:toolbar 写 "等待你确认权限…";Send 灰;Send 与 Stop 都不可点
- streaming:Toolbar swap → "Maka 正在思考… [Esc] 或点 Stop 中断" + pulsing accent 点;只剩 Stop 按钮;Send 隐藏;Esc 调 onStop
3.6 SessionListPanel + SessionRow(components.tsx:180, :487)
- 职责:会话侧边栏。Active state + streaming pulse + unread halo。
- token:active row
--state-selected-bg;hover row--state-hover-bg;streaming dot--accent+ box-shadow pulse;unread halo--accent。 - 优先级硬规则:streaming dot 优先于 unread halo(PR85 验证)。即如果
session 正在 streaming,不显示 unread 圆点。代码逻辑在
SessionRowline ~580 + 600:{streaming && <span … />}与{session.hasUnread && !streaming && <span … />}。 - ARIA:列表外层 nav 节点;row 是
<button type="button">;rename inputaria-label="重命名对话";删除按钮aria-label... - 键盘:Arrow Up/Down 切换 row(在 SessionListPanel 容器上注册);Enter 打开;Delete 触发删除 confirm(通过 toast.confirm)。
- 5 态:
- default:text + meta
- hover:
--state-hover-bg背景 - focus:focus-visible ring
- active(current session):
data-active="true"→--state-selected-bg背景 + name weight 600 - editing:rename input + select-all on focus
- streaming:
data-streaming="true"→ 顶部加 pulse dot + 副文本 "Maka 正 在思考…" 覆盖 lastMessagePreview - error:N/A(session 不会处于 error 态;CRUD 失败由 toast 表达)
3.7 ChatHeaderAlertBadge(components.tsx:1050)
- 职责:在聊天标题栏一行小药丸表达"凭据/会话级"异常状态。
- token:
data-tone驱动 —warning/destructive/info,分别取--info-text/--destructive-text/--info-text+ 同色 alpha 背景。 - ARIA:当 onClick 存在时渲染为
<button>;否则<span>。aria-label与可见 label 同字符串。 - 键盘:仅可点击时有 focus;按下 Enter / Space 跳转。
- 生命周期映射(与
connection-status.ts同源):needs_reauth→ warning tone, label "需要重新登录"error→ destructive tone, label "上次连接失败"- 连接已删除 → destructive tone, label "连接已删除"(PR106)
- 5 态:default / hover (button) / focus / active — 同 Button; disabled / loading / error 不适用
3.8 TurnView + footer info tooltip(chat-view.tsx)
- 职责:把"user → tools → assistant"渲染为一个 visual unit (
<section class="maka-turn">)。turn 的 meta(model · duration · cost)落在 footer 的 info action tooltip——#546 删除了顶部 TurnSummary chip strip,meta 收进 tooltip,tooltip 只在 hover 时出现。 - 关键不变量(
materialize.ts):durationMs仅在 assistant message 落地后赋值,否则保持 undefined → info tooltip 不含 duration 项。tokens.costUsd仅当 > 0 时进 tooltip;从不渲染$0.0000。
3.9 ToolActivity(components.tsx:1524)
- 职责:tool 调用列表 with native
<details>/<summary>折叠。 - 核心规则:
- 默认展开当且仅当 status ∈ {
pending,waiting_permission,running,errored}(isOpenByDefault, line 1483)。completed/interrupted默 认折叠。 - errored 行:顶部红色
<ToolErrorBanner>(最多 240 字符)+ copy 按钮, 显式优先于参数 / 结果展示。 - 输出经过
redactSecrets与TOOL_LINE_CAP = 500双重过滤。
- 默认展开当且仅当 status ∈ {
- token:状态点 dot 走
.maka-tool-status-dot[data-status="…"]; running 时附 box-shadow 脉动maka-tool-pulse;errored 时 border / 背景皆 转 destructive 浅色。 - ARIA:
<section aria-label="工具调用记录">+ 每条 details 自带语义; tool count 圆点aria-label="N 次调用";status label 走STATUS_LABEL表 (5 种中文文案)。 - 键盘:原生 details summary 支持 Enter / Space 切换。
- 5 态:
- default:闭合
- hover:summary 行 hover 走 details 自带
- focus:summary focus-visible 走全局 ring
- active:详情区展开(
<details open>) - disabled:N/A
- loading:
running状态 = loading;脉动 dot 表示 - error:
errored状态 = 红框 + 红 banner + 自动展开
3.10 ModelTable (UI-02, ProvidersPanel 子组件)
- 职责:替代旧的"选择默认模型"下拉。
role="radiogroup"工作区,每行可键 盘选中作为默认。 - 位置:
apps/desktop/src/renderer/settings/ProvidersPanel.tsx内;键盘 helper 拆出到model-table-keyboard.ts(PR94),有 14 个 node:test。 - token:选中行
--state-selected-bg;search box 走 composer focus ring 同款。 - ARIA:
- 表外层
role="radiogroup" aria-label="模型选择" - 每行
role="radio" aria-checked={isDefault} - 隐藏 default hint:
role="status"或普通 div + 紧邻可视位置(不要打aria-live="assertive",太吵)
- 表外层
- 键盘契约(PR92 + PR93 + PR94):
ArrowDown/ArrowRight:focus 下一行 且 select;末行 wrap 到首行ArrowUp/ArrowLeft:focus 上一行 且 select;首行 wrap 到末行Home:跳到首行End:跳到末行- 关键不变量:focus 与 selection 必须同步移动(不能"focus only",那是 UI-04 早期 ARIA radiogroup 回归 bug)
- 模型来源标签(PR91 + PR74 模型 fetch fix):
modelSource === 'fetched'+models.length > 0→ success tone "实时拉取的 N 个模型(X 拉取)"modelSource === 'fetched'+models.length === 0→ success tone "已成功调用 provider,但返回 0 个模型"modelSource === 'fallback'→ info tone "静态备用列表"
- 5 态:
- default:列表 + 默认选中行高亮
- hover:行 hover 走
--state-hover-bg - focus:行 focus-visible 走 ring;selection 同步移动
- active:单选状态
data-default="true" - disabled:禁用某行(如 provider 报告该 model 不可用)→ opacity 0.5 + 不可 focus;当前未触发
- loading:整个 ProvidersPanel 自身处理
loading态,使用.maka-skeleton-card占位 - error:fetchModels 失败 → toast.error + 留在 fallback 模式
3.11 MessageCopyButton(components.tsx:893)
- 两种变体:
- floating(无 label):默认。
.maka-message-copyabsolute top-right, opacity 0,hover/focus-within 时升起。用于 assistant bubble 末尾"复制消息"。 - labelled:
labelprop 存在时,data-labelled="true",inline 流, 始终可见。用于"复制思考过程"按钮。
- floating(无 label):默认。
- token:复制成功 1.4s 内,
data-copied="true"→ success tone border + 浅 success 背景 + check icon。 - ARIA:
aria-label在 default / copied 状态切换文案。 - 5 态:default / hover / focus / active / copied(5th 是非典型)。 disabled / loading / error 不适用 — 剪贴板 API 失败时静默回退。
3.12 Markdown 渲染 + 代码块
- 职责:assistant 消息体内 markdown 渲染(
Markdown组件,components.tsx:929),含 remark-gfm + remark-breaks + rehype-highlight。 - 不变量:
- 链接
target="_blank" rel="noreferrer noopener",由 main 进程拦截走系统 浏览器(见 PR96/97 external-link-guard)。 - inline
<code>用 mono + 5% foreground 背景。 - block code 用
.maka-code-blockwrapper:language pill header + copy 按钮<pre>。语言来自language-xxx类,未识别时显示'code'。
- hljs 调色板(
.hljs-*选择器)全部从 token 派生oklch(from var(--accent) …)系列,不允许引入第三方 highlight.js 主题 CSS。
- 链接
- task list 复选框(remark-gfm):禁用态
<input disabled>;选中态用--accent填充;cursor: not-allowed(markdown 是只读的)。
3.13 Tabs(packages/ui/src/primitives/tabs.tsx + maka-tab)
Tab spec 是“一个 primitive + 两个 variant + neutral state token”,不是每 surface 手写。
- primitive:
packages/ui/src/primitives/tabs.tsx(Base UI Tabs)。TabsTab吐maka-tabclass;TabsList接受variant="underline" | "pill"并设data-variant。ui.tsx的TabsRoot/TabsList/TabsTrigger/TabsPanelre-export 这一套,不走第二套手写。 - variant:
underline:active = neutral under-bar(Base UI indicator,--foreground),无背景填充。用于切 view + 联动 panel 的真 tabs(plan / skill)。pill:active =--state-selected-bg填充 + bold + neutral border,Base UI indicator 藏掉。用于 pill 风格的 view 切换(catalog 供应商分类)。
- token:tab 不引入专属颜色 token,直接复用
--state-hover-bg/--state-selected-bg(§1.1)。under-bar 用--foreground——structural chrome 保持中性,品牌色只做 garnish。 - 不要:手写
[data-state=active]::afterunder-bar、手写[data-active="true"]brand 背景、per-surface 藏 Base UI indicator。active/hover 一律走.maka-tab。 - segmented control(切参数,不是切 view):用
SettingsSegmented(Base UI ToggleGroup,packages/ui/src/primitives/settings-segmented.tsx),不是 Tabs。例如 daily-review range(今日/本周/本月切时间窗口,内容是同一个 report 按 range 重新 fetch,不是 3 个 view)。active 是.settingsSegmented button[data-pressed](neutral 白底 + 阴影)。 - contract:
tab-spec-499-contract.test.ts锁每个 surface 用maka-tab/SettingsSegmented+ 无手写 active/hover CSS;state-token-governance-499-contract.test.ts禁 selected/active 用 brand token(tab 不再是例外,无需 allowlist)。
4. 动效契约(Motion contract)
4.1 持续时间表
| 触发场景 | duration | easing |
|---|---|---|
| 按钮 hover / active 背景过渡 | 120ms | ease(系统) |
| 弹层 / tooltip / toast / tool card appear-disappear | 0ms | N/A |
| Composer focus 边框 / shadow | 160ms | ease |
| Tool 边框 status 变化 | 160ms | ease |
| Tool running 脉动 box-shadow | 1500ms (loop) | ease-in-out |
Cursor pulse (maka-cursor) |
1100ms (loop) | ease-in-out |
Skeleton shimmer (maka-shimmer) |
1500–1600ms (loop) | ease-in-out |
4.2 硬规则
-
只动 transform / opacity / color / background / border-color / box-shadow。 不要动
top / left / width / height— 触发 layout,永远不要。 -
使用命名 easing token,不要写 inline cubic-bezier。如果场景不匹配 现有 3 个曲线,先在 §1.6 加一条 named token,再使用。
-
默认不做 decorative enter/exit。
@starting-style、data-starting-style、data-ending-style不进 UI 源码;keyframes 只用于 功能性循环状态(cursor、pulse、shimmer、spinner、status)。 -
prefers-reduced-motion: reduce 触发
maka-tokens.css:1380全局兜底:*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }- 这是最大锤:组件不需要再单独写 reduced-motion 分支,除非默认
行为在 reduced-motion 下要彻底关闭(如 streaming cursor 改为常亮,
.maka-bubble-streaming::after已经处理)。 - 新动画必须能在 0.01ms duration 下还语义正确(即过渡完成后的态视觉上仍 有意义),否则不要做。
- 这是最大锤:组件不需要再单独写 reduced-motion 分支,除非默认
行为在 reduced-motion 下要彻底关闭(如 streaming cursor 改为常亮,
-
永远不要禁用 transition 完全(
transition: none)。emil-design-eng 原 则:reduced-motion ≠ no motion;只是要让 AT 仍能观察 state change。
5. 文案契约(Copy contract)
这一节是 V0.2 polish wave 9(PR53-72)+ wave 10(PR75-106)期间从 @kenji 多次 review 沉淀下来的模式。任何新增文案必须满足以下全部。
5.1 政策声明,不是 backlog
旧的"暂不支持"占位 → 新的 当前状态 / 会包含什么 / 不会做什么 / 下一步需要配置什么
4 段式(PR55 模板,SettingsModal.tsx:193 COMING_SOON_PAGES)。
当前状态 — 一句话现状("未启用 / 默认关闭 / 等待 V0.2")
会包含什么 — 列点;具体可验证的能力
不会做什么 — 列点;安全/隐私的硬边界(policy,永久声明而非占位)
下一步需要配置什么 — 列点;用户/项目要做的事
关键:"不会做什么" 不是"暂时不做",而是 V0.2 即便上线后也不会做。例如:
- 每日回顾:"不截屏、不监听键盘、不读取其他 App 的数据"
- 开放网关:"不向外网监听,默认 127.0.0.1 only;不记录 prompt 文本"
5.2 通用错误文案(generalizedErrorMessage)
源:@maka/core 的 generalizedErrorMessage()(xuan 01b533f)。6 种 errorClass:
| errorClass | 用户文案 | 触发 |
|---|---|---|
auth |
Authentication failed / 凭据校验失败 | 401 / 403 |
timeout |
Request timed out / 请求超时 | abort / read timeout |
network |
Network error / 网络错误 | DNS / connect refused |
provider_unavailable |
Provider unavailable / 上游不可用 | 5xx |
rate_limit |
Rate limited / 速率受限 | 429 |
unknown |
Unexpected error / 未知错误 | 兜底 |
硬规则:
- 任何展示给用户的 provider 错误信息必须经过
generalizedErrorMessage, 哪怕来源已经在 backend 走过一次。UI 层第二道防线是packages/ui/src/redact.ts(见 §5.6)。 - 禁止回显:用户输入的 prompt / API key / URL query 参数 不得出现在错误 toast 或日志里。
5.3 destructive 强调(PermissionDialog)
- destructive permission(
fs_destructive / git_destructive / privileged):- 顶部 reason text 用
--destructive-text,font-weight 500 - body 内最后一段加红色 emphasis note:
"这类操作不可恢复,确认前请再读一遍上面的参数。"
- 主按钮 label:
"我已确认,允许"(不是 "允许"),按钮 variant 切到destructive
- 顶部 reason text 用
- 非 destructive:按钮 label
"允许",variantprimary。
5.4 per-enum 文案映射
警告 / 提示文字只能从封闭 enum 映射,不在前端拼接用户输入。
connection-status.ts:87 STATUS_PRESENTATION— 6 种 ConnectionUiStatus →{label, detail, tone}。SettingsModal.tsx PERSONALIZATION_WARNING_COPY— settings warning enum (override-attempt / sensitive-pattern / control-chars)→ 单一 toast 文案。ToolActivity STATUS_LABEL— 6 种 tool status → 中文 label。
反例(禁止):
toast.error(`Failed to test ${userTypedName}: ${rawError}`);
5.5 "进行中" 而非 ticking ms
materialize.ts:158-160:durationMs 仅在 assistant 落地后赋值。UI 在
in-progress 状态显示 "进行中",不要:
- 拿
Date.now() - turn.startedAt跳动渲染(会触发每帧重绘) - 渲染 "0 ms" 占位
5.6 fetched vs fallback
ProvidersPanel.tsx 模型来源标签必须区分:
modelSource === 'fetched'→ "实时拉取"(success tone)modelSource === 'fallback'→ "静态备用列表"(info tone)
禁止:在 fallback 模式下显示 "实时拉取的 N 个模型" — 那是 PR91 关闭的 silent-fallback 回归。
5.7 单一语言(中文为主)
- 所有用户可见文本默认中文。Aria label 必须与可见文本同语言(PR70 完成 localization sweep)。
- 例外白名单:
- 技术 token name("OAuth"、"Bearer"、"API Key")保留英文
- model id / provider type / file path 保留原文(mono)
- "Maka" 品牌名
5.8 标识符走 mono
任何"机器字符串"在文案里出现时必须 <code> 包裹:model id、file path、env
key、provider type、HTTP method、HTTP header name。
不允许:
Provider type is openai-compatible允许:Provider type 是 <code>openai-compatible</code>
6. 表面状态矩阵(Surface state matrix)
每个信息表面必须实现以下 4 态。本表用于 release-gate 审计;下一波 PR 应优先填补 ❌ 与 🟡。
| 表面 | focus | loading | error | empty |
|---|---|---|---|---|
Chat 主区(.maka-chat) |
✅ 全局 focus ring | ✅ streaming caret + composer dot | ✅ tool error banner + chat header alert | ✅ EmptyChatHero / OnboardingHero |
| 侧边栏 SessionListPanel | ✅ row focus-visible | 🟡 没有显式 list loading(首次 load 渲染同步) | ✅ session 操作失败 → toast | ✅ "尚未发送" 分组 |
| 侧边栏 Skills | ✅ | 🟡 隐式 | ✅ skills:list 失败 → toast (PR56) | ✅ "暂无技能" copy (PR9) |
| Settings · 通用 | ✅ | ✅ shimmer skeleton (PR15) | ✅ settings:update warnings toast (PR59) | N/A(始终有内容) |
| Settings · 个性化 | ✅ | ✅ | ✅ warning toast per enum | N/A |
| Settings · 主题 | ✅ | ✅ | 🟡 | ✅ light/dark/auto 永远有当前选中 |
| Settings · 模型 (ProvidersPanel) | ✅ row + chip focus | ✅ providersLoading skeleton | ✅ test toast + fetchModels toast | ✅ enabledEmptyChip "还没有供应商" |
| Settings · 模型详情 (ConnectionDetail) | ✅ | ✅ testing 态 button label swap | ✅ test connection toast | N/A(必有 1 个 connection) |
| Settings · 使用统计 | ✅ | ✅ shimmer | 🟡 | ✅ "暂无数据" copy |
| Settings · 机器人对话 | ✅ | ✅ | ✅ test bot connection toast | ✅ 各 channel 默认 disabled |
| Settings · 网络 | ✅ | ✅ | ✅ test proxy toast (PR33) | N/A |
| Settings · 数据 | ✅ | ✅ | ✅ openPath 失败 toast (PR56) | N/A |
| Settings · 账号 (AccountSettingsPage) | ✅ row focus | ✅ hasSecret 加载 | ✅ test connection toast + 6-state badge | ✅ "尚未启用任何连接" |
| Settings · 关于 | ✅ | ✅ app:info 加载 (PR16) | 🟡 (info IPC 失败不显式提示) | N/A |
| Settings · 每日回顾 / 语音模型 / 开放网关 / 搜索服务 | ✅ | N/A | N/A | ✅ Coming Soon 4 段式 |
| Composer | ✅ focus-within | ✅ streaming 态 | ✅ send 失败保留输入 (xuan 7ce8f30) | ✅ placeholder 文案 |
| PermissionDialog | ✅ trap | N/A(同步事件) | N/A | N/A |
| Modal (Settings) | ✅ | ✅ 各 panel 自己处理 | ✅ 各 panel 自己处理 | N/A |
| Command Palette (⌘K) | ✅ | 🟡 不需要 loading | ❌ 命令执行失败暂未统一 surface | ✅ "无匹配命令" copy |
| Chat Header | ✅ pill 可点击 | N/A | ✅ ChatHeaderAlertBadge (PR65 + PR106) | N/A |
| Tool Activity | ✅ summary focus | ✅ pending/running 状态 dot | ✅ ToolErrorBanner (PR58) | ✅ tool 列表为空时整段不渲染 |
| Turn Summary chips | N/A(不可交互) | ✅ "进行中" 替代 ticking | N/A | ✅ chips 全 hidden 当无信号 |
未实装 surface(next-wave 必须按 §11 release-gate 入表):
下列 surface 是 @kenji v2 audit 8 系统 + @yuejing reference implementation 差距分析提到、但尚未实装的。每条必须按 §11 release-gate mapping 同时声明 fixture scenario / screenshots / failure state / smoke path / test gate。
| 表面 | 来源 | 必须实装的 4 态 | 责任 §9.x |
|---|---|---|---|
| Artifact pane (Files/Terminal/Preview) | kenji item 2 | focus = pane focus ring + 三栏 tab 焦点;loading = 文件读取 spinner;error = file not found / sandbox error inline;empty = "尚无 artifact,等模型生成后展示" | §9.1 |
| Quick Chat panel (global hotkey BrowserWindow) | yuejing reference implementation 差距分析 | focus = composer auto-focus on open;loading = AX 抓取进度;error = 权限缺失 / hotkey 冲突 inline;empty = 首次使用提示 | §9.7 (扩) |
| Health Center (统一健康面板) | kenji item 7 | focus = 各 status row keyboard 导航;loading = 巡检中 spinner;error = 巡检失败 list + generalized message;empty = 所有 sub-system 健康时显示「一切正常」 | §9.3, §9.11 |
| Workstation shell (session status / workspace / branch) | kenji item 1 | focus = sidebar row focus;loading = sub-thread 状态加载;error = workspace 路径无效 inline;empty = 新 session 无 workspace 时显示「未关联 workspace」 | §9.4, §9.8 |
| First-run to value flow | kenji item 8 | focus = 当前步骤 input focus;loading = fetch-models 进度;error = 各 step inline error;empty = N/A(流程本身就是 onboarding) | §9.12 |
| Turn control affordances (retry / regenerate / branch / cancel) | kenji item 4 | focus = 每个 action button focus ring;loading = action pending 状态;error = action 失败 toast;empty = N/A | §9.9 |
| Sources / Skills / Automations 审计面板 | kenji item 5 | focus = 各 entry focus;loading = list 加载;error = entry 验证失败;empty = "暂无来源 / 技能 / 自动化" | §9.10 |
| ModelCatalog 扩展行(capability/pricing/source/unsupported) | kenji item 3 | focus = row focus;loading = pricing 拉取;error = model unsupported 警告 inline;empty = capability 全空时显示 "provider 未暴露能力信息" | §9.2 (扩) |
已实装但待修缺口(next-wave PR 候选):
- SessionListPanel 首次加载 skeleton(🟡)
- Settings · 主题 error 态(异常时未显式提示)
- Settings · 使用统计 error 态
- About info IPC 失败的 fallback
- Command Palette 命令执行失败的统一 surface
7. Release-gate 钩子
7.1 三条 gate
| Gate | 文件 | 覆盖 |
|---|---|---|
| manual visual | apps/desktop/tests/smoke.md |
9 path(first-launch → command palette),8-10 分钟 |
| unit | apps/desktop/src/main/__tests__/*.test.ts |
87 desktop tests(redact / connection-status / materialize-turns / open-path-guard / personalization-prompt / settings-IPC / chat-readiness / connection-test-status / model-table-keyboard / external-link-guard / window-state) |
| log discipline | scripts/check-console.mjs |
console.* 调用必须在 ALLOW map 内;workspace pretest 触发 |
7.2 PR 必须满足
任何 PR 添加新可见 UI 必须满足下面至少一条:
apps/desktop/tests/smoke.md加一个 path 描述apps/desktop/src/main/__tests__/加一个 unit test 测试 derived helper- 如果纯改 CSS / token / 组件 recipe 无法 unit test: 必须更新本文档对应章节
7.3 pretest 工作流
npm --workspace @maka/desktop test
→ pretest:
├── npm --workspace @maka/core run build
├── npm --workspace @maka/ui run build
└── node ../../scripts/check-console.mjs
→ npm run build:main
→ node --test "dist/main/**/*.test.js"
新增 console 调用必须在
check-console.mjs:32 ALLOWmap 显式登记理由。未在 白名单的 console.* 会让 pretest 失败。
7.4 何时跑
- 合并任何 UI / runtime / credential / permission 改动到 main 前
- 改动以下任一时:
LlmConnection、sessions:changedpayload shape、ConnectionUiStatusderivation、TurnViewModel、nextRadioId、 PermissionDialog rendering - 打 release tag 前
8. 反模式(Anti-patterns)
这是 NOT-DO 清单。出现以下任意一条 → PR review block。
8.1 交互元素
❌ <div onClick={...}> 假冒按钮 / 链接
✅ <button type="button"> 或 <a href="…">
❌ 浏览器原生 title="" 作为唯一 affordance 描述
✅ 视觉文字 + aria-label;title= 可作为 OS-level tooltip 兜底,但
UX 延迟(~700ms)使其不能是主要 affordance;Tooltip primitive 暂未实装
(见 §9)
❌ Modal 没经过 useModalA11y
✅ 所有 modal(含 ConfirmDialog、PermissionDialog、SettingsModal)必须用
useModalA11y(ref, onEscape?)
❌ PermissionDialog 允许 Escape 关闭 ✅ 权限决策必须显式 — Escape 在 PermissionDialog 内屏蔽
8.2 视觉
❌ 硬编码 hex / oklch(color: #1a1a1a / background: oklch(0.5 0.1 200))
✅ 使用 maka-tokens.css 中 token;新 token 必须先登记到 §1
❌ 硬编码 z-index 数字(z-index: 9999)
✅ 使用 --z-* 阶梯,新阶梯先登记到 §1.7
❌ 硬编码 cubic-bezier ✅ 使用 §1.6 三条 named easing
❌ 动画 top / left / width / height
✅ 仅 transform / opacity / color / background / border-color / box-shadow
❌ 引入第三方 highlight.js CSS theme
✅ .hljs-* 选择器来自 maka-tokens.css,oklch(from var(--accent) …) 系列
8.3 安全 / 隐私
❌ 把原始 provider error / API key / 用户输入 / URL query secret 渲染到 UI
✅ 走 generalizedErrorMessage (core) + redactSecrets (UI 二层)
❌ console.log 未 dev-gate
✅ console.* 须 process.env.NODE_ENV === 'development' 或登记到
scripts/check-console.mjs ALLOW
❌ 后端持久化用户 typed text / provider 名 / personalization tone 到 telemetry ✅ telemetry 仅写 errorClass、redactedMessage、status code
8.4 国际化
❌ 在可见 UI 新增顶层英文字符串 ✅ 中文优先;英文仅限 §5.7 白名单(技术 token、品牌名)
❌ aria-label 用英文,visible text 用中文(或反之) ✅ 二者同语言(PR70 已扫一遍)
8.5 状态语义
❌ 流式中持续 tick duration: Date.now() - startedAt
✅ in-progress 时显示 "进行中",duration 仅在 assistant 落地后写
❌ 假成本 $0.0000
✅ costUsd > 0 才显示
❌ silent fallback:把 fallback model list 当 "实时拉取" 显示
✅ modelSource: 'fetched' | 'fallback' 显式状态,UI 严格按枚举显示
❌ 在一个 row 上同时 streaming dot + unread halo ✅ streaming 优先,unread 在 streaming 时不渲染(PR85)
9. 未尽契约(Open questions / future contracts)
@kenji、@xuan、@yuejing 已识别但尚未契约化的项。这一节是 next-wave PR 的 研究入口。
9.1 ArtifactRecord shape — @kenji audit item 2 · PR108 主线
PR108 选定(@kenji 2026-05-22):作为 Maka 从 chat app 跨入 workbench 的 第一块骨架,先于 Quick Chat / Workstation shell 落地。本小节是 PR108 backend + renderer + fixture + smoke 的契约入口。
9.1.1 核心 record
// @maka/core/src/artifacts.ts
export type ArtifactKind = 'file' | 'diff' | 'html' | 'image' | 'pdf';
export interface ArtifactRecord {
/** UUID generated by main; primary key. */
id: string;
/** Owning session — artifacts are session-scoped, not workspace-scoped. */
sessionId: string;
/** Turn that produced this artifact; required for "branch from turn" + "checkpoint" semantics (§9.9). */
turnId: string;
/** Wall-clock ms when the artifact was created. */
createdAt: number;
/** Display name (assistant-supplied, e.g. "report.html"); sanitized for FS safety. */
name: string;
/** Discriminator. UI picks renderer per kind. */
kind: ArtifactKind;
/**
* **Relative** path under the artifact root. NEVER absolute. Layout:
* `{sessionId}/{id}-{name}`
* Absolute path is reconstructed by main as
* `{workspaceRoot}/artifacts/{relativePath}`
* with a `realpath()` check that the resolved path is still inside
* `{workspaceRoot}/artifacts/` — mirror of PR56 open-path-guard.
*
* Per @kenji constraint: renderer never sees absolute paths. All file
* IO goes through `readText`/`readBinary` IPC helpers (9.1.2). This
* keeps every future preview (HTML / PDF / image / export) sharing the
* same path-traversal defense surface instead of opening new ones.
*
* Why file-backed:
* - HTML must run in sandboxed iframe (§9.1.5)
* - Large outputs (PDFs, multi-MB diffs) can't survive in JSONL
* - Snapshot/diff/rollback (§9.9) needs stable file identity
*/
relativePath: string;
/** Byte size of the file at storagePath; surfaced in UI list rows. */
sizeBytes: number;
/** Optional mime-type hint (e.g. 'text/html', 'application/pdf'); falls back to inference. */
mimeType?: string;
/** Optional generalized source description (e.g. "Bash tool output", "Edit diff"). */
source?: string;
/**
* State machine. Defaults to `'live'`; `'deleted'` is a tombstone (soft-delete)
* so undo + audit are possible. Hard delete only via Settings · 数据 explicit
* action.
*/
status: 'live' | 'deleted';
}
决策点已确定:
- per-session 工作目录:artifact 跟 session 走,不跟 workspace 走。理由: branch-from-turn (§9.9) 需要复制 artifacts 到新 session 而不共享。
- snapshot 版本号:monotonic int(
turnId隐式承担版本顺序,无需独立计数器)。 - 删除:软删(
status: 'deleted')。永久 purge 在 Settings · 数据。
9.1.2 IPC contract
// preload window.maka.artifacts
artifacts: {
list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise<ArtifactRecord[]>;
get(artifactId: string): Promise<ArtifactRecord | null>;
/** Read file content as text (for diff / file / html previews). */
readText(artifactId: string): Promise<
| { ok: true; text: string }
| { ok: false; reason: 'not_found' | 'too_large' | 'read_failed' | 'not_allowed' | 'deleted' }
>;
/**
* Read file content as Buffer→base64 for image / pdf previews.
* **MIME allow-list** (@kenji constraint): only `image/png`, `image/jpeg`,
* `image/gif`, `image/webp`, `image/svg+xml`, `application/pdf` are returned
* inline. Unknown / non-allowed MIME → `reason: 'unsupported_mime'` (renderer
* shows metadata + "在 Finder 中打开" affordance; no raw binary reaches the
* preview component). MIME is sniffed at read time from the file content,
* NOT trusted from `record.kind` or `record.mimeType`.
*/
readBinary(artifactId: string): Promise<
| { ok: true; base64: string; mimeType: string }
| { ok: false; reason: 'not_found' | 'too_large' | 'read_failed' | 'not_allowed' | 'deleted' | 'unsupported_mime' }
>;
/** Soft delete. */
delete(artifactId: string): Promise<void>;
/**
* Open a native Save dialog and copy the artifact file to the chosen
* destination. Cancel is not an error toast.
*/
saveAs(artifactId: string): Promise<
| { ok: true; saved: string }
| { ok: false; reason: 'canceled' | 'not_found' | 'not_allowed' | 'deleted' | 'write_failed' }
>;
/** Subscribe to `artifacts:changed { reason: 'created' | 'deleted' | 'purged', artifactId, sessionId }`. */
subscribeChanges(handler: (event: ArtifactChangedEvent) => void): () => void;
};
不允许通过 IPC 暴露绝对路径给 renderer —
ArtifactRecord.relativePath是 artifact-root-relative;main 侧realpath()校验解析后仍在{workspaceRoot}/artifacts/prefix(mirror PR56 open-path-guard)。任何 文件读取走readText/readBinaryhelper,禁止新增暴露绝对 path 的 IPC 方法。
路径安全 contract(@kenji 2026-05-22 constraints):
- record 字段:
relativePath(无前导斜杠 + 无..)- main 端拼接:
join(workspaceRoot, 'artifacts', relativePath)- realpath 双校验:
realpath(artifactRoot)+realpath(target),再验 target 在 root 内(symlink escape 防御 — mirror PR56 open-path-guard:__tests__/open-path-guard.test.ts现有覆盖 symlink 越界 /../ URL schemes)- 失败 → 返回
'not_allowed'reason,不返回 path 信息- 删除-tombstone 不等于 path purge(@kenji constraint #4):当 record
status === 'deleted',readText/readBinary立刻返回'deleted'reason,不读文件;真正的 path purge 走单独 Settings · 数据 流程
大小阈值:
readText上限 10MB,readBinary上限 50MB。超过返回'too_large',UI 提示"打开 Finder 查看"(复用 PR56 openPath('artifact'))。
9.1.3 渲染 — 右侧 pane shell
新增 ArtifactPane 组件,位于 chat shell 的右侧分栏。<aside class="maka-artifact-pane">:
- 默认隐藏;当 session 内至少 1 个 live artifact 时显示
- 用户可手动 collapse/expand(持久化到 localStorage
maka-artifact-pane-v1类似 PR54 模式) - 宽度:默认 360px,可拖拽 280-560 范围,复用
maka-resize-handle模式 - 内部三个区域:
- list (top, scrollable):
ArtifactRow[]按createdAt desc,每行 icon- name + size + relative time
- preview (bottom, 70% height):选中行的 preview,按 kind 分发
- toolbar (bottom strip):「打开 Finder」「复制路径」「删除」
- list (top, scrollable):
9.1.4 Per-kind preview MVP
| kind | preview 组件 | 限制 |
|---|---|---|
file |
<pre class="maka-artifact-file"> + 复用 rehype-highlight |
text 文件;非文本走 image / pdf 分支 |
diff |
复用 PR76 FileDiffPreview 但全屏版(line cap 取消,因为 pane 有 scroll) |
unified diff 文本 |
html |
file-backed sandboxed iframe(见 9.1.5) | 严格 |
image |
<img src="data:{mimeType};base64,{base64}"> |
PR60 redact 不适用(图像不文本) |
pdf |
<embed type="application/pdf"> + fallback link "在 Finder 中打开" |
macOS 内置 PDF rendering,Windows/Linux 走 fallback |
9.1.5 HTML preview sandbox 契约
HTML artifact 是最危险的 kind,必须满足以下边界(按 @kenji 2026-05-22 review 收紧 — DOM iframe 不能独立 partition / CSP,所以全部隔离靠 sandbox attribute + 内容传递方式):
-
file-backed:HTML 内容不嵌入 transcript JSONL;renderer 不接 raw HTML 字符串到 iframe
srcdoc,而是通过 main 的readText(artifactId)获取已 sandbox-prefix-validated 的文本,再交给 iframesrcdoc渲染。这等价于 "内容来源 = 受控的 artifact 文件,不是任意网络/file:// 资源"。 -
iframe sandbox attribute:
<iframe sandbox="allow-scripts" srcdoc={await readText(artifactId)} />- 不允许
allow-same-origin(防止访问父 frame DOM / cookies / localStorage) - 不允许
allow-top-navigation(防 iframe 替换主 renderer) - 不允许
allow-popups(防新窗口逃逸) - 不允许
allow-forms(防 form submit 外发数据) - 不允许
allow-modals(防 alert/confirm 影响主 surface)
- 不允许
-
CSP 关系澄清(@kenji constraint #1):DOM
<iframe>本身不分配独立的 ElectronwebContents partition,不允许承诺一个 DOM iframe 做不到的隔 离层。当前 contract 不通过 CSP override 来获得隔离;隔离完全依赖 sandbox attribute +srcdoc内容控制。未来如果要给 artifact preview 加更强 隔离(如外部 site 渲染),单独引入<webview>或 BrowserView + 独立 partition 才能正确表述这条;目前 contract 不预先承诺。 -
外链 v1 = 完全禁用(@kenji 2026-05-22 review #6 — corrects earlier draft):在没有
allow-popups的 sandbox 里,iframe 内的<a href>点击 不会自动冒泡到主 renderer 的setWindowOpenHandler;它们只是静默失 败或被 sandbox block。承诺 "PR96 接管 iframe 外链" 是文档错误。PR108b 的 MVP 选择安全优先:
- iframe 内所有 navigation 一律被 sandbox 阻断(这是 default 行为)
- preview 顶部显示一行状态条:"此预览中已禁用外部链接 · {N} 个链接"
(扫描 srcdoc 中
<a href>个数,info 色) - 用户想跳:在 toolbar 用「在 Finder 中打开」找到 HTML 源文件,浏览器 打开
未来扩展(不进 PR108b):
- HTML transform:main 读取 HTML 时把
<a href>改写为<button data-maka-link="..." />+ 注入 inline script 经postMessage向主 renderer 发外链请求;主 renderer 经 allowlist +shell.openExternal - 或者:让 iframe 内 JS 通过 postMessage 与主 renderer 交互,主 renderer
作为唯一的
shell.openExternal网关
-
CSP global 不动:渲染端的全局
Content-Security-Policy保留default-src 'self'; script-src 'self';artifact iframe 的安全完全靠 sandbox attribute 承担,禁止为了 artifact 把全局 CSP 放开。
9.1.6 Failure states
| 状态 | 触发 | UI 表现 |
|---|---|---|
| empty | session 内无 live artifact | pane 不渲染,整个右侧让位给 chat |
| single live artifact | 仅 1 个 | list 折叠成单行 + preview 直接显示 |
| read_failed | readText/readBinary 返回 'not_found' / 'read_failed' |
preview 区显示 destructive 色「无法读取 artifact 文件 · 路径可能已被外部删除」+「在 Finder 中打开」按钮 |
| too_large | 超出 10MB / 50MB 阈值 | preview 区显示 info 色「文件超出预览大小 · {sizeBytes} 字节」+「在 Finder 中打开」 |
| html_blocked | iframe 加载失败(CSP / 路径错) | preview 区显示「HTML 预览被沙箱拒绝 · 在 Finder 中打开查看原文」 |
| deleted | status: 'deleted' 但 includeDeleted=true |
行半透明 + "已删除" badge + 「恢复」操作(在 6 小时内可恢复);preview 显式提示「此 artifact 已删除,预览已停止」;readText/readBinary 即使被调用也立即返回 'deleted' reason,不会读到原文件内容(@kenji constraint #4) |
| unsupported_mime | readBinary sniff 出非 allow-list MIME |
preview 显示 kind + size + 「在 Finder 中打开」+「另存为」;renderer 不接收 raw bytes |
9.1.7 Gate
- node:test:
__tests__/artifact-path-guard.test.ts— 复用 open-path-guard 模式,钉 storagePath 必须在 artifacts/ 前缀__tests__/artifact-record.test.ts— record 创建 / soft-delete 转换 / size 限制返回正确 reason
- fixture scenario:
artifact-paneseed 一个 session 含 3 live artifacts:report.html、patch.diff、notes.md(不超过 10KB)artifact-errors在同 session 额外 seeddeleted.mdtombstone、unsupported.bin、missing.mdmetadata-only record,用于 failure-state screenshot/smoke。
- smoke path(smoke.md 新增 Path 11):
- 入口:用
MAKA_VISUAL_SMOKE_FIXTURE=artifact-pane启动 - 期望可见:右侧 pane 展开,三行 list,preview 选择 report.html 后渲染沙箱 iframe
- 失败信号:HTML 渲染失败但未显式提示、preview 内的
<a>改变 renderer location
- 入口:用
- smoke path(smoke.md Path 13):
- 入口:用
MAKA_VISUAL_SMOKE_FIXTURE=artifact-errors启动 - 期望可见:deleted / unsupported_mime / missing 三类 failure copy + native Save As dialog;取消 Save As 不报错。
- 入口:用
9.1.8 PR 拆分
@kenji 在 review 中建议 backend/core 先定义 record + fixture seed,不急 extractor。 按此 PR108 拆成三个 sub-PR(保持 atomicity):
| Sub-PR | 范围 | 责任 |
|---|---|---|
| PR108a | @maka/core 加 artifacts.ts + types;@maka/storage 加 artifact 存储;fixture seed 数据 |
@xuan |
| PR108b | renderer ArtifactPane shell + list + 3 preview kinds + sandbox iframe;smoke path 11 + fixture scenario artifact-pane |
@yuejing |
| PR108c | tool runtime 钩子;node:test gate | @xuan |
PR108c 范围澄清(@kenji review #5 — 2026-05-22):第一版不做 LLM
extractor。runtime hook 只在 Write / Edit / Bash 工具明确产出确定性
artifact 时记录:
Write→ 1 个kind: 'file'record(path 已知)Edit→ 1 个kind: 'diff'record(patch 已知)Bash→ 仅当命令显式 redirect 到文件(>/>>)时生成kind: 'file'record; stdout/stderr 不自动 promote 成 artifact(避免噪声)
未来 LLM extractor(从 assistant message 提取 ```html block 当 artifact)
是 PR109+ 的独立工作,不塞进 PR108c。
9.2 ModelCatalogEntry capability / pricing / source enum — @kenji audit item 3
当前 ModelInfo 仅有 id。需要扩展:
capability: 'chat' | 'tool_use' | 'vision' | 'audio' | 'embedding'pricing?: { inputPerMtok: number; outputPerMtok: number; currency: 'USD' | 'CNY' }source: 'fetched' | 'fallback' | 'user_custom'- UI 表现:ModelTable 列扩展 + tooltip
9.3 Health Center 表面分类 — @kenji audit item 7
"Health Center" 是把 connection / bot / proxy / skill 四种健康检测 surface 统一在一处。当前各自分散在 Settings 不同 panel。决策点:
- top-level nav 加 "健康中心" 还是塞进 "数据与账号"
- 自动巡检节奏(手动 / 启动时一次 / 定时)
- 失败时是否升级到全局 toast
9.4 Workstation shell session-status enum — @kenji audit item 1
当前 SessionSummary 没有 status,UI 通过 streamingBySession Map 外部判定。
未来若引入 sub-thread / long-running task:
'idle' | 'streaming' | 'paused' | 'errored' | 'archived'- sidebar row 视觉如何区分(目前 streaming 与 unread 已经在挤同一片视觉)
9.5 Tooltip primitive
title="" 是当前兜底;Tooltip primitive(基于 <dialog> 或 floating-ui 风格
positioning)未实装。引入时需要:
- 决定一个
--z-tooltip层叠(已登记,未消费) - 决定 hover 触发延迟(建议 500ms,与 macOS native 一致)
- 必须支持 keyboard focus 触发(不光是 hover)
- 必须支持
aria-describedby,与 button label 解耦
9.6 Streaming aria-live
Composer streaming 状态目前没有 aria-live。屏幕阅读器用户感知不到 "Maka
正在思考…"。需要决定:
aria-live="polite"还是"assertive"(polite 更妥)- 文案触发频率(每次状态切换一次,而非每个 token)
9.7 ChatHeader streaming aria-live + Quick Chat 入口
Quick Chat(参考 reference implementation macOS panel + global shortcut)尚未引入;引入时
--z-overlay 与 --z-modal 的相对关系、panel BrowserWindow 的设计需要单独
契约化。
Quick Chat 实装契约(扩):
- 入口:
globalShortcut.register('CommandOrControl+Shift+Space', …);冲突时 fail fast + 在 Settings · 通用 给可配置回退键 - 窗口形态:第二个
BrowserWindow,type: 'panel'(macOS),frame: false,transparent: true(看是否能在保持 sandbox + CSP 的前提下做毛玻璃),失焦自动隐藏(按quickChatHideOnBlursetting;默认 true) - 上下文抓取:v1 不接 macOS AX(隐私边界 + 权限引导成本太高);先做"上一次主窗口 active session"作为隐式上下文
- renderer 复用:用
#/quick-chathash route 让单个 React tree 渲两个窗口 - 退出:Escape / 失焦 / 同热键再次按下
- fixture scenario:新增
quick-chat-floating,seed 一个 active session 让 panel 能直接接入
9.8 Workstation shell — session 状态机(@kenji item 1, PR109a + PR109b)
Status enum (@maka/core SessionStatus,PR109a 落地):
| status | 触发条件 | sidebar 视觉 | 允许的下一态 |
|---|---|---|---|
active |
默认 / 可继续工作的会话(不区分是否被选中) | normal text;选中态用 row 高亮 | running / waiting_for_user / blocked / archived |
running |
runtime 流式响应或工具/网关请求执行中 | 脉动 accent Loader2 icon + "Maka 正在思考…" preview (PR85) |
active / review / aborted / blocked |
waiting_for_user |
permission_request、missing credential、explicit user action | warning 色 Hourglass icon |
running / active / aborted |
blocked |
缺配置 / 凭据 / 工具失败 / 未知阻塞,带 blockedReason |
destructive 色 ShieldAlert icon + 生成化 reason tooltip |
active(修复后) |
review |
长任务输出待用户审核 | info 色 Eye icon |
done / running |
done |
完成 + 已审核(用户继续聊则回 active) | success 色 CircleCheckBig icon |
active / archived |
aborted |
用户 cancel 或 turn-level cancel | muted 色 Ban icon;group 默认折叠(与 archived 同模式) |
(terminal) |
archived |
用户显式归档(与 isArchived 共生) |
muted 色 Archive icon;group 默认折叠 |
unarchive → active |
SessionBlockedReason (@maka/core, PR109a):
NO_REAL_CONNECTION | auth | permission_required | tool_failed | unknown.
UI 必须 通过 describeBlockedReason() 翻译成中文展示,不可 直接展示 enum:
| Reason | 用户文案 |
|---|---|
NO_REAL_CONNECTION |
缺少可用模型连接 |
auth |
需要重新登录 |
permission_required |
等待权限确认 |
tool_failed |
工具调用失败 |
unknown |
未知阻塞 |
Sidebar grouping(PR109b, locked order):
Pinned (synthetic, only when `pinFirst` + any flagged session)
↓
Running → Waiting → Blocked → Active → Review → Done → Archived (collapsed default) → Aborted (collapsed default)
pinned 抽出 flagged session 但不在某个 status 群里重复出现(去重)。
archived 和 aborted 是两个 dormant 分组,都默认 collapsed —— 不是
完全隐藏。@kenji 在 PR109b review 中明确:「aborted 是用户主动操作产生的
历史,必须能看见;不要因为分组语义把它静默吃掉」。Pinned 也只是 overlay
priority(用户「我要置顶」的意图),不是 lifecycle 分组;pinned 行内的
<SessionStatusIcon> 仍然显示真实 lifecycle(running 仍 spin,blocked 仍
destructive icon)。
实装入口(已落地):
packages/core/src/session.tsSessionStatus+SessionBlockedReason枚举 + guard 函数 (PR109a)SessionHeader / SessionSummary加status/statusUpdatedAt/ 可选blockedReason(PR109a)SessionManager在 send/permission/error/abort/stop 时显式写状态;通过sessions:changedreason'status-change'广播 (PR109a)apps/desktop/src/renderer/session-status-grouping.ts纯函数deriveSessionStatusGroups, 跟 storagesession-store.list()同 sort 顺序 (PR108k-yj + lastMessageAt desc + id 二次) (PR109b)apps/desktop/src/renderer/session-status-presentation.ts纯函数presentSessionStatus/describeBlockedReason/sessionStatusAriaLabel— 中文文案 + tone matrix (PR109b)packages/ui/src/components.tsx<SessionStatusIcon session={summary} />行级 icon +<SessionStatusBadge>头部 badge +SessionListGroups渲染多 group / collapsible (PR109b)
Failure states:每个 blocked 必带 blockedReason,UI 通过
describeBlockedReason() 生成化为用户文案;新增 reason 必须扩 core enum +
BLOCKED_REASON_LABEL 同 PR 修两处。
Gate:
apps/desktop/src/main/__tests__/session-status-grouping.test.ts锁 group order + collapsibility + sort + 中文 labels +aborted过滤(28 case)apps/desktop/src/main/__tests__/session-status-presentation.test.ts锁 enum coverage + tone vocabulary + blocked-reason copy 不暴露 enum + 中文 only- fixture scenario
workstation-statuses每个 status 各 seed 1 个 session + blocked 4 reason 各 1 个 sub-row - smoke Path 14 验视觉 + 行为:group 顺序 / blocked tooltip / archived collapse / header badge / running spin
- 视觉 screenshot baseline 后续在
workstation-statuses上跑 PR-IR-01 capture- PR-IR-02 sanity 全 8 variants
写集 / 不变量边界:
running来自 runtime stream/tool 生命周期;renderer 不能 optimistic 写done不 自动归档(archived 是显式生命周期)aborted在 sidebar 用 collapsed group 显示(PR109b @kenji review);header badge 也会出现「已中止」muted 状态blocked显式必带 reason;reason 缺失 fallbackunknown
9.9 Turn control 契约(@kenji item 4 — PR109c 进行中)
retry / regenerate / branch-from-turn / cancel / checkpoint-before-tools 是
turn-level contract。PR109c 落下第一版 runtime/storage contract;footer UI
在 PR109d 消费。
Turn-level state machine (PR109c, @xuan core contract — fields locked 2026-05-22 04:18):
running ──cancel──► aborted (terminal; partial output preserved)
running ──error──► failed (terminal; errorClass populated)
running ──final─► completed (terminal; default success)
| 状态 | 含义 | 持久化 |
|---|---|---|
running |
turn 已开始,后端仍在流式/工具/permission handoff 中 | turn_state |
completed |
正常完成或 regenerate/retry 后的新 sibling 完成 | turn_state |
aborted |
用户 cancel/stop;不是 error | turn_state.abortedAt + partialOutputRetained |
failed |
runtime/backend/tool 失败 | turn_state.errorClass |
Each turn is immutable once it leaves running — including its
status value, partial output, and message body. Retry / regenerate /
branch never rewrite an old turn; they always create a sibling turn
(retry/regenerate) or a new session (branch).
TurnRecord 字段 (@maka/core, PR109c):
interface TurnRecord {
turnId: string;
status: 'running' | 'completed' | 'aborted' | 'failed';
parentTurnId?: string; // reserved for v2 turn-tree
retriedFromTurnId?: string; // new turn was a retry of this id
regeneratedFromTurnId?: string; // new turn was a regenerate of this id
branchOfTurnId?: string; // set on first turn of a branched session
parentSessionId?: string; // sibling to branchOfTurnId
abortedAt?: number; // wall-clock ms when cancel landed
errorClass?: string; // generalizedErrorMessage class for `failed`
partialOutputRetained: boolean; // always true after PR109c per @kenji
}
Lineage 只写正向字段。旧 turn immutable:除自身 running → completed/aborted/failed 的状态变化外,未来 sibling 出现不会回写旧 turn。
UI 需要 "已重新生成 →" 时,从当前 turn list derive 反向 map
via deriveTurnLineageMap()(PR109d)。
操作清单(UI 触发 → core/runtime 处理):
| 操作 | UI 文案 | 触发位置 | 适用 turn.status | 行为 | 旧 turn 处理 |
|---|---|---|---|---|---|
regenerate |
「重新生成」 | assistant message footer | failed / aborted / completed |
sibling turn 复用同 user message + 写 regeneratedFromTurnId(legacy retry 的 retriedFromTurnId 在 badge 层 fallback 读) |
immutable |
branch-from-turn |
「分支」 | turn header context menu | 任意(含 aborted) |
新 session via sessions:branchFromTurn + branchOfTurnId + parentSessionId + 复制至该 turn boundary(aborted 起点复制到中断前最后可用 boundary) |
原 session 不变;artifacts v1 只复制引用,不复制 bytes |
cancel |
「取消」 | streaming 中的 Composer Stop / Esc | running |
turn → aborted;session → aborted;partial output 保留 |
n/a — turn 自身 status 转 |
checkpoint-before-tools |
n/a (自动) | 自动(destructive tool 前) | n/a | snapshot workspace;失败阻止 tool | n/a |
IPC / projection(PR109c locked):
sessions:listTurns(sessionId) -> TurnRecord[]sessions:regenerateTurn(sessionId, { sourceTurnId, turnId? })sessions:branchFromTurn(sessionId, { sourceTurnId, name? }) -> SessionSummarySessionChangedReason新增turn-status-change(独立于 session-levelstatus-change)@maka/uiTurnViewModelsurface 同名字段;deriveTurnLineageMap(turns)在 PR109d 加入materialize-turns.ts, 只 derive 反向 UI 链接,不持久化反向字段
UI 表现(#546 接口约定):
- Turn footer actions(icon-only:
🔁 重新生成 / 🌿 分支 / 📋 复制 / ℹ 详情): 根据 turn.status 动态决定 enabled setrunning:仅📋 复制(其他 action 长任务结束后再露)completed/failed/aborted:🔁 重新生成 / 🌿 分支 / 📋 复制, 外加ℹ 详情(hover tooltip:model · duration · cost)
- Aborted turn 视觉:assistant message body 前缀灰色斜体 "(已中断)";
turn header 显示 muted
Banicon - Failed turn 视觉:使用 PR58 的 AlertOctagon 红色 banner + copy-error 按钮,文案走 generalizedErrorMessage
- Lineage badges:
- 新 turn 顶部 small badge「重新生成自 turn ${shortId}」点击 scroll 到原 turn
- 旧 turn footer 通过 UI-side derive 显示「已重新生成 → turn ${shortId}」 点击 scroll 到新 turn(legacy retry 的 retriedFromTurnId 在 badge 层 fallback 读,统一显示为「重新生成」语义)
- branched session sidebar 顶部 banner「分自 ${parentSessionName}」+ 点击跳回 parent
- Branch 复制语义:aborted 起点的 branch 文案明示「从中断前分支」
- Cancel 行为:cancel button 仅调 IPC + 显示 loading;status 变化由
runtime 广播
turn-status-change,UI 不 optimistic 写(同 PR109b 约束)
Gate:
- core/storage/runtime tests (PR109c) 锁 turn 状态机:cancel 写 aborted 且 partial 保留;regenerate 创建新 sibling 不覆盖旧 turn; branch 复制至 turn boundary + aborted 起点 fallback 到中断前 boundary
- node:test 锁 UI lineage derive helper:
deriveTurnLineageMap(turns)返回正确的regeneratedTo反向映射(含 legacyretriedTofallback); 旧 turn 无 regenerated-to 时不影响 - node:test 锁 footer action × turn.status enabled matrix
- fixture scenario
turn-control-history(PR109d):seed 一个含 5 turn 的 session 覆盖每个状态(running × 1, completed × 2, aborted × 1, failed × 1)+ 1 regenerate sibling(含 1 legacy retried sibling 测 fallback),让截图覆盖所有 footer 状态 - smoke Path 15(PR109d):从 active session cancel → 验 aborted 出现 + 「(已中断)」prefix + regenerate button 可用;点 regenerate → 新 turn 出现 + badge 链接;点 regenerate completed → sibling 出现,原 assistant 仍可见
9.10 Sources / Skills / Automations 可见系统(@kenji item 5)
Maka 当前第一步实现为 core contract + existing module surface:skills 仍来自
文件系统扫描结果(window.maka.skills.list()),scheduled tasks 使用统一 catalog。
@maka/core/capability-audit 把这两类现有快照派生成一个可测试的
CapabilityAuditReport,并在 Skills / Automations 页面顶部显示同一份
审计摘要。后续接入真实 MCP/API source 时,应填充同一个 SourceRecord
输入,而不是新增平行 UI contract。
Contract:
| 实体 | 字段 | UI 表面 |
|---|---|---|
SourceRecord |
slug / name / type / enabled / authType / scopeSummary[] / status / lastTestAt / lastErrorReason |
Skills / Automations 顶部审计摘要;未来可扩 Settings · 来源 |
SkillAuditRecord |
id / name / description / declaredTools[] / enabled / sourceSlug / permissionMode |
Skills 顶部审计摘要 + 已安装技能列表 |
AutomationRecord |
id / name / enabled / trigger / permissionMode / lastRunAt / lastRunStatus |
Automations 顶部审计摘要 + 定时任务列表 / 执行记录 |
关键不变量:
- skill 不等于 permission widening:skill 声明
allowed-tools仅是 请求, 实际能用的工具仍受 PermissionEngine 检查;UI 表现要让用户看到 "声明" vs "实际授权" 的差异。SkillAuditRecord.permissionMode只能是explore | ask, 不允许因为declaredTools升级到execute。 - source 凭据不渲染原文:与 connection apiKey 一致,UI 只显示 masked 状态;落盘边界以 credential store 实现为准
- automation last-run 失败 → 自动禁用? 决策点:弱协议(仅显示 error),还是 连续 N 次失败自动 disable?后者需要新的 backend 状态机
当前 Gate:
packages/core/src/__tests__/capability-audit.test.ts:锁定 source / skill / automation enum、workspace skills source 派生、Skill 不放大权限、 ScheduledTask → Automation last-run 映射。apps/desktop/src/main/__tests__/capability-audit-ui-contract.test.ts: server-render 审计摘要条,确认 Skills / Automations 共享同一份CapabilityAuditReport,并锁定窄屏指标布局。- 现有 visual fixture 覆盖入口:
skills打开 Skills module;scheduled-tasks打开 Automations module。未来如果新增独立 Sources 面板或禁用切换,必须新增sources-skills-automationssmoke path:seed 3 个 source、5 个 skill、2 个 automation,验各自 panel 渲染 + 禁用切换 + last-run/sync 时间显示。
9.11 Health Center 表面契约(@kenji item 7 — 扩 9.3)
把 connection / bot / proxy / search / voice / open-gateway / storage 7 类健康检测 统一到一处。当前各自分散在 Settings 不同 panel + chat header alert。
入口:
- top-level nav 顶部固定一个 "健康中心" 图标 + badge(健康度颜色 dot)
- 任何 sub-system 出现 error 时 badge 变红,hover 显示具体分类
面板结构:
每个 sub-system row 显示:
- icon + 名称
- 当前 status(
healthy / warning / error / unknown) - 最近一次 generalized message(如 "Authentication failed · 3 分钟前")
- action:「立即检查」「打开对应 Settings」「复制诊断信息」
诊断 copy:
- 必须走 redactSecrets 二层;不暴露原始 provider URL / api key / user id
- 格式:Markdown 带时间戳 + sub-system 名 + generalized message + 当前 settings shape(脱敏)
Gate:
- smoke path
health-center-overview+health-center-all-errors(fixture scenariohealth-center-degraded,seed 7 个 sub-system 各种 status) - node:test 钉死 diagnostics copy 不包含 raw secret(复用 PR60 redact 测试矩阵)
9.12 First-run to value(@kenji item 8)
4 步内从空 workspace 到第一次 smoke prompt 成功:
Step 1: 选 provider (preset list)
↓ 错误:preset 不可用 → inline error,不允许跳过
Step 2: paste API key(仅本地,credential store)
↓ 错误:key 格式不合法 → inline;test 失败 → 阻止下一步(不允许 fallback-as-success)
Step 3: auto-fetch models → 选 default
↓ 错误:fetch 失败 → 显示原因 + retry / 选 fallback model(必须明确标 fallback)
Step 4: 发送 smoke prompt(一句 "hi")
↓ 错误:发送失败 → 不允许进入主界面,必须修复
不变量:
- 步骤间不能 silently fallback(每个失败必须 inline,并阻止下一步)
- 4 步全部完成才进主界面(OnboardingHero 替换为新的 stepper 流)
- 用户可以 Esc 跳过但需要二次确认 + 持久化
onboarding.skipped = true
UI 实装:替换当前 OnboardingHero 为 stepper 模式。每步独立组件,可单独
fixture seed。
Gate:
- fixture scenario
first-run-stepper:seed 一个空 workspace + 一个 mock provider preset,让 e2e 能从空到第一条消息走完 - smoke path
first-run-stepper:跑完 4 步 + 一个 step-3 失败分支
9b. @kenji v2 audit — 8 个系统 contract(informative,但下一波 PR 必读)
来自 @kenji 自己的 notes/maka-reference implementation-reference implementation-design-gap-v2.md +
notes/maka-redesign-v1-contract-smoke-gate.md。这些是 next-wave 的工作
入口,每条都要落到本文档对应小节后才动手写代码:
| # | 系统 | 一句话契约 | 在本文档对应 |
|---|---|---|---|
| 1 | Workstation shell | session 必须有状态机 active / running / waiting / blocked / review / done / archive;header + sidebar 显示 workspace / branch / status / PR,不再只靠聊天列表表达工作状态。 |
§3 SessionRow 扩、§9 新增 9.8 "session 状态机契约" |
| 2 | Artifact pane | generated file / html / image / pdf / diff 变成 ArtifactRecord;右侧 pane 预览;HTML 必须 file-backed + sandboxed <iframe srcdoc>,不能把大内容塞进 transcript。 |
§9 既有 ArtifactRecord 条目,需扩到 file-backed + sandbox 规则 |
| 3 | ModelCatalogEntry | backend 归一化 capabilities / context / pricing / source / stale / unsupported;UI 只展示事实,不猜;unsupported / image-only 不能设为 chat default。 |
§3 ModelTable 扩、§9 ModelCatalogEntry 条目 |
| 4 | Turn control | retry / regenerate / branch-from-turn / cancel / checkpoint-before-tools 是 turn-level contract;旧输出不可被覆盖;cancel 必须落 aborted 状态。 |
§3 TurnView 扩 + §9 新增 9.9 "Turn control 契约" |
| 5 | Sources / Skills / Automations | source 的 auth/scope、skill 的 allowed tools、automation 的 last-run 都必须可见、可验证、可禁用;skill 不能等于 permission widening。 | §3 ToolActivity 扩 + §9 新增 9.10 |
| 6 | Visual system contract | 即本文档。下一波每个新 surface 必须声明 fixture scenario + light/dark/narrow screenshot + failure state。 | §11 "Release-gate mapping" |
| 7 | Health Center | provider/credential/bot/proxy/search/voice/open-gateway/storage 的 status 和最近 generalized error 集中展示;支持 redacted diagnostics copy。 | §9 新增 9.11 |
| 8 | First-run to value | provider preset → 本地 paste key → auto-fetch models → 选 default → 发 smoke prompt 4 步内闭环;错误 inline,不许 fallback-as-success。 | §6 surface 表加入;§9 新增 9.12 |
10. 版本与变更策略(Versioning + change policy)
10.1 版本来源
- 本文档不单独 semver。隐式 versioning 由 git 提交承担。
- 顶部 frontmatter "版本:v0.2-wave-N" 跟随 wave 节奏更新;wave 切换由 yuejing 或 reviewer 在 PR 中显式 bump。
10.2 新增 token
- 新增必须同 PR 修改
maka-tokens.css+ 本文档 §1 对应小节。 - 新 token 必须满足:name 是
--<category>-<role>[-<modifier>]形式;不超过 3 段;下划线 / camelCase 禁用。
10.3 废弃 token
- 不允许直接删除。流程:
- PR-A:在 CSS 注释加
/* @deprecated since v0.2-wave-X — use --new instead */, 在本文档 §1 对应行加@deprecated标注,列入"废弃中" - PR-A+N(≥1 个 release):在确认所有 consumer 已迁移后,PR-B 同时删除 CSS 定义和本文档行
- PR-A:在 CSS 注释加
- 废弃期间,新代码禁止引用废弃 token,由 grep-based PR review 把关
10.4 5 态契约的破坏
- 任何对组件 §3 5 态行为的变更(如 PermissionDialog 允许 Escape)需 channel 显式签字:@kenji + @xuan + @yuejing 三方至少两方 approve
- 签字记录写入 PR description,本文档对应小节同步更新
10.5 反模式追加
- 任何在 review 中第二次发现的 anti-pattern → 列入 §8
- 列入后,下一次出现可直接 block PR 而不再 case-by-case 讨论
10.6 文档与代码的同步
- release-gate 不机械验证文档存在性(成本过高),改用 PR review 弱协议
- reviewer 校验清单:
- 引入新 CSS custom property → §1 是否更新?
- 引入新 React 组件 → §3 是否登记?
- 引入新 Settings page / Surface → §6 是否登记 4 态?
- 引入新 anti-pattern 案例 → §8 是否补充?
11. Release-gate mapping(new-surface 验收契约)
@kenji audit item 6 的具体落地规则。任何新引入或结构性重设计的 surface(不是单纯样式 polish)必须在 PR 描述里声明以下五件事,缺一不可:
11.1 Fixture scenario
引用 MAKA_VISUAL_SMOKE_FIXTURE=<scenario> 中的某一个 scenario(@xuan
当前在做 fixture mode 底座,覆盖 7 个 canonical state:first-run /
workspace-fetched / fallback-with-refresh-error / fetched-empty /
needs-reauth-or-error / turn-with-thinking / destructive-permission)。
如果新 surface 需要新 scenario,PR 必须先扩 fixture seed,再加 UI。 不允许"看起来对就行"。
11.2 Screenshot 矩阵(最少 3 张)
| 维度 | 取值 |
|---|---|
| Theme | light + dark(两张) |
| 视口宽度 | 990px(窄)+ 1240px(默认)— 任选一档;或两张都要 |
| 状态 | 至少一张 failure / empty / loading 中的非 happy-path |
放在 PR description 内联,便于 reviewer 扫读。文件命名建议
docs/screenshots/<surface>-<scenario>-<theme>-<width>.png,repo 内归档
可选。
11.3 Failure state 显式声明
每个新 surface PR 必须显式说明:
- What can fail here? 列出 ≥1 个失败分支(fetch 超时、auth 401、空响应、 并发 race、用户取消等)。
- How does the user see it? 引用 §6 表格中的 error/empty 写法(不能新造 pattern 而不入文档)。
- Generalized message text — 引用现有
generalizedErrorMessage6 enum 之一,或在 PR 中声明新 enum + 同 PR 更新 helper。
11.4 Smoke gate hook
如果新 surface 改变了已有的 9 条 smoke path,PR 必须同步 apps/desktop/ tests/smoke.md。如果新 surface 引入了全新的 path(如 Health Center 首屏、
Artifact pane 打开),必须新增一条 smoke path,覆盖以下要素:
- 入口(如何从默认状态到达这个 surface)
- 期望可见信号(≥2 个)
- 失败信号(≥1 个)
11.5 Test gate hook
renderer-only 视觉变更可以仅靠 smoke + screenshot 验收;带逻辑分支的(如 turn-control 的 cancel 状态机、ModelCatalog 的 unsupported gate、session 状态机的转换规则)必须有 node:test 单元测试钉死分支。
参考已有契约 gate:
__tests__/connection-status.test.ts— ConnectionUiStatus 6 态 + 优先级__tests__/materialize-turns.test.ts— TurnViewModel projection__tests__/model-table-keyboard.test.ts— ARIA radiogroup keyboard 行为__tests__/redact.test.ts— UI 二级 redactor__tests__/open-path-guard.test.ts— IPC allow-list + structured reason__tests__/external-link-guard.test.ts— URL scheme whitelist__tests__/window-state.test.ts— sanitize bounds
11.6 Reviewer checklist(粘到 PR description)
- [ ] Fixture scenario: <name>(或 N/A + 理由)
- [ ] Screenshots: light + dark + (failure/empty/loading)
- [ ] Failure state: <列举 + generalized message enum>
- [ ] smoke.md updated(或 N/A + 理由)
- [ ] node:test added/updated(或 N/A + 理由)
- [ ] §1 tokens / §3 component contract / §6 surface 表格已同步
- [ ] check-console.mjs 通过(pretest)
- [ ] typecheck + build + tests 全绿
11.7 例外
只动文档 / 注释 / commit message 的 PR 不需要走 11.1–11.5;reviewer checklist 仍要勾。
仅修 bug 不引入新 surface 的 PR 走最小修复 + 单测,不需要 11.1–11.4,但 仍需 11.5(即"该 bug 在 test 里被钉住")。
附录 A — 当前依赖快照(informative)
| 依赖 | 版本 | 范围 |
|---|---|---|
| react / react-dom | ^19.2.1 | UI |
| electron | ^39.2.7 | shell |
| lucide-react | ^0.577.0 | 通用 UI icon(packages/ui/src/icons.tsx 是唯一接口;call-site 用 @maka/ui/icons) |
| react-markdown + remark-gfm + remark-breaks | — | assistant markdown |
| rehype-highlight | — | code 高亮 |
| @fontsource-variable/geist + geist-mono | ^5.2.x | 字体 |
字体托管:Geist Variable + Geist Mono Variable 自包,本地 fallback 链 ⊇ system-ui。不要再引入额外字体。
附录 B — 关键文件索引
| 主题 | 路径 |
|---|---|
| Token 定义 | apps/desktop/src/renderer/maka-tokens.css |
| 组件 recipe | apps/desktop/src/renderer/styles.css |
| 共享 React 组件 | packages/ui/src/components.tsx |
| Toast + Confirm | packages/ui/src/toast.tsx |
| UI redactor | packages/ui/src/redact.ts |
| TurnViewModel + materialize | packages/ui/src/materialize.ts |
| Connection 6-state | apps/desktop/src/renderer/connection-status.ts |
| Settings 主入口 | apps/desktop/src/renderer/settings/SettingsModal.tsx |
| ProvidersPanel + ModelTable | apps/desktop/src/renderer/settings/ProvidersPanel.tsx |
| ModelTable 键盘 helper | apps/desktop/src/renderer/settings/model-table-keyboard.ts |
| smoke gate | apps/desktop/tests/smoke.md |
| desktop unit tests | apps/desktop/src/main/__tests__/*.test.ts |
| console 审计 | scripts/check-console.mjs |
— END —