# 2026-06-08 · 自定义分类 + 聊天气泡统一 + 右键菜单瘦身

> 本文档是「方案」，不是「已完成」。按用户要求：每块都给**技术讲解**＋**通俗讲解**＋**用户会看到的变化**。
> 三件事按风险分两批：**批一**（小、安全，可立刻做）＝聊天气泡 + 右键菜单；**批二**（大、要拍板）＝自定义顶层分类。

---

## 批一·改动 A：聊天气泡统一（创作区 + 生成区）

### 现状（bug）
- 我们发出去的消息：**黑底白字**；AI 回复：**浅灰底深字**。两者底色不一致。
- 用户诉求：把「我们发过去那条」改回**和回复一样的原来那个底**（浅灰 `--nomi-ink-05`），不要黑气泡。

### 技术讲解
两个面板各自实现，但黑底来源相同——都是在 user 角色上额外盖了一层 `background: --nomi-ink`：

| 面板 | 文件:行 | 现在 | 改成 |
|---|---|---|---|
| 生成区 | `CanvasAssistantPanel.tsx:421` | `message.role === 'user' && 'self-end rounded-br-[4px] bg-nomi-ink text-nomi-paper'` | 去掉 `bg-nomi-ink text-nomi-paper`，只留 `self-end rounded-br-[4px]` |
| 创作区 | `workbench-ai.css:369-376` | user 规则里 `background: var(--nomi-ink)` + `color: var(--nomi-paper)` | 删掉这两行（只留 `margin-left:auto` 右对齐 + 右下角圆角）|

- 基样式 `bg-nomi-ink-05 text-nomi-ink`（`CanvasAssistantPanel.tsx:420` / `workbench-ai.css:357`）本来就是浅底，user 气泡去掉黑色覆盖后自动回落到它。
- 符合 R10：CSS 文件是**删属性**（只减不增），不新增样式。

### 通俗讲解
现在你发的消息像一块黑板贴在右边，AI 回的是浅灰色，俩颜色打架。改完之后，你发的和 AI 回的**用同一种浅灰底色**，靠「你的靠右、AI 的靠左 + 圆角缺口方向不同」来区分谁说的——干净、不刺眼。

### 用户会看到的变化
- 创作区、生成区两处聊天里，自己发的气泡从**黑底白字**变成**浅灰底深字**，和 AI 回复一个底色。
- 仍然一眼能分清谁说的（左右对齐 + 圆角方向）。其余排版不变。

---

## 批一·改动 B：右键菜单瘦身

### 现状
右键分类/子组/节点弹出的菜单框 `min-w-[168px]`、`px-3 py-1.5`、`rounded-lg shadow-xl`——对「重命名 / 删除」这种短词太宽，左右一大圈白边，显空、显丑。

### 技术讲解
改 `CategoryTree.tsx:267-304` `renderContextMenu`：
- 容器：`min-w-[168px]` → `min-w-[120px]`；`rounded-lg` → `rounded-md`；`shadow-xl` → `shadow-lg`；`py-1` 保留。
- 每项 `buttonClass`：`px-3 py-1.5` → `px-2.5 py-1`（更紧凑，仍够点）。
- 分隔线 `my-1` → `my-0.5`。
- 全部 token 化，不引入新任意值。

### 通俗讲解
现在那个右键小框，字没几个但框撑得老宽，周围空一圈。改完把框收窄、行间收紧，像一个「刚好装下文字」的紧致菜单，不再有大白边。

### 用户会看到的变化
右键出来的菜单变窄、变紧凑，贴合文字；功能、位置、逻辑都不变。

---

## 批二·改动 C：自定义顶层分类（**需要你拍板**）

> 这是上次那个「+」的延伸。上次我把「+」做成「在当前分类下建**子组**」，是因为当时数据模型只认死了 5 个内置分类。
> 你现在要的是真的能**新建一个顶层分类**（和「分镜/角色/场景/道具/声音」平级）。**这个能做，但它是一次伤筋动骨的改动**，下面讲清代价和岔路。

### 为什么它「不是顺手就能加」——技术现状
代码里「只有 5 个内置分类」这个假设被**焊死在 3 个层面**（Explore 实测，带 file:line）：

1. **类型层焊死**：`generationCanvasTypes.ts:12-14` 把分类 id 定义成闭合联合类型 `CategoryId = 'shots'|'cast'|'scene'|'prop'|'audio'`，而且节点 `node.categoryId`、子组 `group.categoryId` 都用的这个类型。自定义 id 连类型都过不了。
2. **运行时过滤焊死**：`generationCanvasStore.ts:156` 的 `isCategoryId()` 是「是不是这 5 个之一」的白名单。`addNode`/`restoreSnapshot`/`createGroup` 等每个入口都用它当过滤器——**自定义分类下的节点会被静默丢弃**。
3. **持久化焊死**：`projectCategories.ts:138-149` 的 `normalizeCategories()` 有一行 `if (!isBuiltinCategoryId(...)) continue`——存盘/读盘时**直接扔掉**任何非内置分类。而且 UI 现在压根没把存档里的 categories 接到侧栏（`WorkbenchShell.tsx:180` 没传 `categories` prop，`CategoryTree.tsx:57` 永远回落到 `BUILTIN_CATEGORIES`）。

还有 ~10 处「没分类就当 'shots'」「`categoryId === 'scene'` 才走定妆」这类按内置 id 写死的判断（`GenerationCanvas.tsx`、`buildFixationNode.ts`、`useNodeRelationships.ts` 等），自定义分类要逐个确认不踩坑。

### 要做的事（技术清单，分 5 步）
| 步 | 改什么 | 文件 |
|---|---|---|
| 1 | 把 `CategoryId` 从闭合联合放宽成 `string`；`isCategoryId` 改成「非空字符串」校验 | `generationCanvasTypes.ts:12-14`、`generationCanvasStore.ts:156` |
| 2 | `normalizeCategories` 去掉「扔掉非内置」那行，自定义分类得以存盘 | `projectCategories.ts:145` |
| 3 | 让存档里的 `categories` 真正流到 UI：加一个 categories 状态切片（放 `workbenchStore`），存读盘带上它，`WorkbenchShell` 把它传给侧栏 | `workbenchStore.ts`、`workbenchProjectSession.ts`、`WorkbenchShell.tsx:180` |
| 4 | 加分类 CRUD action：`addCategory / renameCategory / deleteCategory / reorderCategory`，每次 bump 存盘修订号 | `workbenchStore.ts` |
| 5 | 图标兜底：`getCategoryIcon` 对未知图标名回落到通用图标（否则渲染崩）；自定义分类节点默认用通用「分镜帧」样式 | `categoryIcons.ts:33`、`BaseGenerationNode.tsx:727` |

UI 侧：
- 侧栏「+」从「建子组」**改成**「新建顶层分类」，新分类出现在列表末尾并进入**行内改名**（复用上次给子组做的那套行内编辑，提到 `CategoryItem`）。
- 子组入口保留在**分类行右键「新建子组」**（已存在）。
- 自定义分类支持右键「重命名 / 删除」；删除时其下节点回家到「分镜」或一并删（见岔路 3）。

### 通俗讲解
你可以把现在的程序想成一个**只有 5 个抽屉、且抽屉是焊死在柜子上**的文件柜——抽屉的名字、数量、位置都是出厂写死的，连「往里塞一张不属于这 5 类的纸」系统都会自动撕掉。你要的「自己加一个新抽屉」，等于要把焊死的结构改成**可以自由增减的抽屉轨道**：要动柜子的骨架（类型）、动「往抽屉塞纸」的安检规则（运行时过滤）、还要让新抽屉**关机重开后还在**（存盘）。这三处都改完，才谈得上「加新分类」。所以这不是改个按钮文案的小事，是改地基。改完之后，加分类就跟你现在加子组一样顺手：点「+」→ 出来一个新分类 → 当场打字命名。

### 用户会看到的变化
- 侧栏「分类 / 文件」右边那个「+」，点了**不再是建子组，而是建一个全新的顶层分类**，出现在分镜/角色/.../声音 这一列的最下面，光标直接落在名字上让你打字。
- 新分类可以右键改名、删除；可以拖动排序（若做岔路 3-排序）。
- 想在某个分类下建子组，改成**在该分类那一行右键 →「新建子组」**。
- 新分类里的节点默认长得像「分镜帧」（图 + 输入框）那种通用卡片。

### 需要你拍板的岔路（R3）

**岔路 1 · 新分类里的节点长什么样**
| 方案 | 你看到什么 | 代价 |
|---|---|---|
| A（推荐）通用帧样式 | 新分类节点统一是「图 + 提示词输入」那种通用卡 | 零额外 UI，最稳 |
| B 建分类时选「类型」 | 可选「像角色卡/场景卡/...」决定外观 | 多一步选择 + 多一套 UI，复杂 |

**岔路 2 · 新分类的图标**
| 方案 | 你看到什么 | 代价 |
|---|---|---|
| A（推荐）自动给通用图标 | 新分类统一一个图标（如方块/井号） | 简单 |
| B 给一排图标让你选 | 建分类时点选喜欢的图标 | 多一个图标选择器小面板 |

**岔路 3 · 内置 5 分类能不能动 + 删分类怎么处理**
| 方案 | 你看到什么 | 代价 |
|---|---|---|
| A（推荐）内置只读、自定义可删；删分类时其下节点自动回「分镜」 | 5 个原分类不能删/改名（防各处假设崩），自定义的能删能改，删了不丢节点 | 稳，符合现有 'shots' 兜底假设 |
| B 全部可删可改 | 连分镜都能删 | 风险高——大量代码默认「分镜一定在」，删了会连锁崩 |

**岔路 4 · 上次那个「+建子组」要被改掉，确认吗**
- 上次刚把「+」做成建子组。改成建分类后，「+」=建分类，子组走右键。需要你确认接受这个改动（否则「+」要同时承担两件事，会含糊）。

### 不动什么
- 内置 5 分类的 id、节点渲染内核、时间轴/导出逻辑不重写。
- 不引入并行版分类系统（P1）；自定义分类复用同一套节点/画布底座（P4 通用第一）。

### 回滚策略
- 批一（气泡 + 菜单）：纯样式，单 commit，可独立 revert。
- 批二（自定义分类）：独立分支/独立 commit 串；类型放宽与持久化改动各自一 commit，出问题逐个 revert。持久化加版本守卫，旧存档读得回。

### 验收门
- 批一：五门全过 + 真机截图对照（user 气泡浅底、菜单变紧）。
- 批二：五门全过 + `tests/ux/design-fidelity.e2e.mjs` 加断言 + R13 真机走查：建分类→改名→建节点→关机重开分类还在→删分类节点回家。**「关机重开还在」是这条的硬验收（接入即验证）**。

---

## 建议执行顺序
1. **先做批一**（气泡 + 菜单）——你现在就能看到效果，零风险。
2. **批二等你把上面 4 条岔路拍板**，我再写实现、按 R7 六角色过一遍、再动手。

---

## 拍板回填（2026-06-08）

批一已实现并推送（commit 7bd32f3）。批二 4 条岔路用户已拍板，全选推荐项：

| 岔路 | 决定 |
|---|---|
| 1 节点样式 | A · 通用「分镜帧」样式（`defaultNodeRenderKind: 'shot-frame'`）|
| 2 图标 | A · 自定义分类自动给一个通用图标，不让用户选 |
| 3 内置可动性 | A · 内置 5 类只读（不可删/改名）；自定义类可改名/删；删类时其下节点 `categoryId` 回落 `'shots'` 不丢 |
| 4 「+」职责 | A · 「+」改为新建顶层分类；子组入口改走「分类行右键 → 新建子组」|

实现按方案「要做的事 5 步」+ UI 侧推进；硬验收 = 建分类→改名→**关机重开分类还在**→删类节点回家。

> ⚠️ 实现踩坑（已修）：加载时 `projectCategoryMigration.mapLegacyCategoryId` 会把自定义 id 当非法丢弃，
> 导致「重开分类消失」。根因修复：legacy-removed id 仍删，其它非空 id（含自定义）原样保留。
> ⚠️ 协作事故：批二曾被并行会话的 `git reset` 清空一次，已按本会话记录完整重做并尽快提交锁定。
