knight-ui 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,6 +132,12 @@ createRoot(document.getElementById("root")!).render(<App />);
132
132
  import "knight-ui/tokens.css";
133
133
  ```
134
134
 
135
+ **这是唯一的样式文件,没有第二份要引。** 组件的样式是内联样式对象(写在 JS 里),只有少数做不成内联的东西才留在类规则里(玻璃的 `backdrop-filter`、遮罩、滚动条),而那些类规则也在同一个文件里。但**它必须被引入**——组件里每个颜色都写成 `var(--ku-*)`,没有这个文件,所有视觉值一起失效(内联样式里带 fallback 的那部分除外)。
136
+
137
+ 子路径由 `package.json#exports` 的 `./tokens.css` 提供(等价别名 `./tokens/tokens.css`,保留给此前按两段路径写的引用)。
138
+
139
+ > **源码里是两个文件,发布出去的是一个。** 三层方案把色板层拆成了独立文件 —— `src/component/tokens/palette.css`(裸三元组,由 `scripts/gen-palette.mjs` **完全生成**)与 `src/component/tokens/tokens.css`(语义层 + 少数类规则,手写)。拆开是为了让生成脚本永远碰不到手写内容(标记区的失败模式是脚本 bug **静默覆盖**手写的 token)。构建期由 `scripts/build-css.mjs` 把两者按序拼成 `dist/tokens/tokens.css`,所以**对外契约一个字节都没变**:仍然只有一个样式文件,`exports` / `files` 也未改。`palette.css` 不在 `exports` 里,消费者引不到它、也不需要引——这是刻意的,它单独加载没有任何意义(只有裸三元组,谁都不消费)。
140
+
135
141
  ## 主题定制
136
142
 
137
143
  Knight-UI 通过 CSS 自定义属性(CSS Variables)实现主题系统。在 `<html>` 上设置 `data-ku-theme` 属性切换暗亮模式:
@@ -141,18 +147,337 @@ Knight-UI 通过 CSS 自定义属性(CSS Variables)实现主题系统。在
141
147
  <html data-ku-theme="light"><!-- 亮色模式 --></html>
142
148
  ```
143
149
 
144
- 常用 Token 变量:
150
+ 组件内部**不写死颜色**:所有视觉值都走 `var(--ku-*)`(内联样式带 fallback 字面量,变量缺失时仍可渲染)。因此重定义 Token 即可整体换肤,无需覆盖组件 CSS。
151
+
152
+ > **例外:内容色板(`--ku-content-*`)不带 fallback** —— 它由 JS 按主题现值拼进内联样式(`rgb(var(--ku-content-blue))`),写不了兜底:过期的兜底会静默画出**旧主题**(表现成"主题切换失灵"),而缺失的兜底立刻可见。因此 `tokens.css` 必须被引入,见下。
153
+
154
+ > **亮色主题状态**:色阶、语义重绑、缺失/写反的覆盖都已就位 —— 包括 `--ku-color-glass-menu` 与 `--ku-color-mask` 两处此前会继承暗色值、导致亮色下弹层是一块暗板 / 遮罩压过头的**真 bug**;`Monaco` 的代码面则刻意**不跟随**本库主题(固定用 VS 自带的 `vs-dark`,见「代码面」一节)。语义与方向现在在两个主题下都成立。**表面分档与玻璃分档都已重推过一轮**(见下方「亮色的表面分档」与「亮色的玻璃分档」),阴影重量也在同一轮补了亮色值。**已知缺口三处**:① `Markdown` 的代码块仍注入仅暗色的 highlight.js 样式表(亮色下浅底浅字,需第二份样式表);② 玻璃在**浅色内容**上方的对比度——这是暗色侧的问题,本轮按范围只重标了亮色,见「亮色的玻璃分档」末尾的实测表;③ 亮色 `--ku-color-glass-tip` 已连着两轮下调(`.48 → .38 → .28`),压在近黑内容上只有 **`2.51:1`**,**低于 WCAG AA 正文下限**,是有意接受的倒退(同节有说明与收回办法:单独把 `tip` 抬回 `.46–.48`)。同轮还把五个 `--ku-blur-*` 的半径整体砍半(`22/30/40/48/36` → `10/14/18/22/16px`)——**半径是两个主题共用的,所以暗色的玻璃也一起变清了**,见「同一轮的第二步」。之后又单独把亮色 `tip` 再降一档并把**亮色气泡的颗粒减半**(新增 `--ku-glass-grain-tip`),见「气泡的第二轮」。
155
+
156
+ ### Token 总览
145
157
 
146
158
  | 变量名 | 说明 |
147
159
  |---|---|
148
- | `--ku-color-primary` | 主色 |
149
- | `--ku-color-text-0` | 主要文本色 |
150
- | `--ku-color-text-1` | 次要文本色 |
151
- | `--ku-color-bg-0` | 主背景色 |
152
- | `--ku-color-bg-1` | 次背景色 |
153
- | `--ku-color-bg-2` | 三级背景色 |
154
- | `--ku-color-border` | 边框色 |
160
+ | `--ku-color-primary` / `-hover` / `-active` | 主色及交互档位 |
161
+ | `--ku-color-primary-light-default` / `-hover` / `-active` | 主色浅底(选中/悬停着色面) |
162
+ | `--ku-color-success` / `--ku-color-warning` / `--ku-color-danger` (+ `-hover` / `-active`) | 语义色,每色三档 |
163
+ | `--ku-color-{success,warning,danger}-border` | 校验态描边(同色 60% 透明,避免满饱和描边过重)。**校验态只改这一条**:`Input` / `Input.TextArea` 的字段底一律 `fill-0`,不再铺同色浅底 |
164
+ | `--ku-color-{success,warning,danger}-light` | **只剩聚焦环一个角色**(不再兼"校验态浅底")。环与普通聚焦态同构:`0 0 0 2px`,不按有无校验态在 1px/2px 之间切换 |
165
+ | `--ku-color-text-0` … `--ku-color-text-3` | 文本色,由亮到暗四档(实色,非半透白) |
166
+ | `--ku-color-bg-0` … `--ku-color-bg-3` | 背景阶(0 最深) |
167
+ | `--ku-color-fill-0` … `--ku-color-fill-3` | 交互面填充阶(输入框底、悬停底、滚动条) |
168
+ | `--ku-color-border` | 描边色 |
169
+ | `--ku-color-link` | 链接色(与 `primary` 同档;需要 hover/active 直接用 primary 家族) |
170
+ | `--ku-color-fill-translucent` / `-hover` | 半透明填充(玻璃面上的条纹/悬停底;用不透的 `fill-*` 会把玻璃糊死) |
171
+ | `--ku-color-fill-translucent-strong` | 半透明填充的第二档(0.06)。给**必须读得出来**的静态带子用——目前只有代码三件套的工具条。0.03 那一档的定位是"叠在已有底上的极浅着色",暗色下够用,亮色下只有约 2/255,工具条会整条消失。值与 `-hover` 相同但**角色不同**,故独立命名(调 hover 的深浅不该顺手把工具条挪走)。 |
172
+ | `--ku-content-{hue}` | **色板层**的分类内容色(`blue` / `cyan` / `teal` / `green` / `amber` / `orange` / `red` / `pink` / `violet` / `grey`)。组件这样用:`rgb(var(--ku-content-blue))`、`rgba(var(--ku-content-blue), .15)`。见下方「内容色板」 |
173
+ | `--ku-color-mask` / `--ku-blur-mask` | 遮罩底色与模糊(铺满视口那一层,见 `.ku-mask`) |
174
+ | `--ku-color-glass-tip` / `--ku-blur-tip` | 小浮层气泡(见 `.ku-glass-tip`) |
175
+ | `--ku-color-glass-menu` / `--ku-blur-menu` | 选项面板(见 `.ku-glass-menu`) |
176
+ | `--ku-glass-grain` | 磨砂颗粒(SVG 噪点 data URI,由浮层 / 选项面板两档玻璃类消费) |
177
+ | `--ku-glass-grain-tip` | 小浮层档**自己的**颗粒。暗色 = `var(--ku-glass-grain)`(逐字相同),**亮色减半**(`0.10 → 0.05`)—— 颗粒在亮色下不做"磨砂"这份工(白膜上它只拉暗面板 + 盖掉背景的低频泛光),只有小面积气泡有"想看到背景泛光"这个诉求。见「气泡的第二轮」 |
178
+ | `--ku-glass-menu-film` | 选项面板的第二层背景("奶膜")。暗色是一条极淡的白渐变,**亮色是 `none`** —— 见 `.ku-glass-menu` |
179
+ | `--ku-shadow-sm` / `--ku-shadow-elevated` / `--ku-shadow-lg` | 阴影三档(浮层请用 `lg`) |
180
+ | `--ku-radius-xs` … `--ku-radius-xl` | 圆角阶(2 / 3 / 4 / 6 / 8 / 10px) |
181
+ | `--ku-font-size-xs` … `--ku-font-size-lg` | 字号阶(10 / 11 / 12 / 13 / 14px) |
182
+ | `--ku-spacing-xs` … `--ku-spacing-xl` | 间距阶(2 / 4 / 8 / 12 / 16 / 20px) |
155
183
  | `--ku-font-family` | 全局字体 |
184
+ | `--ku-z-mask` / `-overlay` / `-modal` / `-toast` | 层级 |
185
+
186
+ ### 亮色的表面分档
187
+
188
+ 暗色下 `bg-*` / `fill-*` 这些**面**的相邻明度差稳定在 `.038–.046`;亮色原本只有 `.012–.035`,而且中间夹着两对**逐位相同**的值(`fill-0` 与 `bg-0`、`bg-3` 与 `fill-1`)—— 观感就是"整个界面都是白的,看不清边界"。
189
+
190
+ 根因不是档位分配错了,而是亮色**可用的明度区间天生比暗色窄**:最亮只能到纯白 `1.000`,再往下压就要撞上 `text-3` 所在的第 6 档(`.703`)。亮色灰阶的浅端当时沿用了阶梯化之前手挑的值,那批值在"每块面靠阴影和描边分层"的年代够用,摊到只看明度的阶梯上就塌在一起了。
191
+
192
+ | | 改前 | 改后 |
193
+ |---|---|---|
194
+ | 亮色灰阶第 0–5 档 | `#ffffff` / `#f7f8fa` / `#f3f4f7` / `#ebedf0` / `#dfe2e7` / `#d6d6da`(跨度 `.123`) | `#ffffff` / `#f5f6f8` / `#ecedf0` / `#e0e2e5` / `#d3d6db` / `#c9c9cd`(跨度 `.163`) |
195
+ | 页面底 `bg-0` | `#f3f4f7`(L `.967`) | `#ecedf0`(L `.946`) |
196
+ | 面板 vs 页面 | ΔL `.012` | `.027` |
197
+ | 输入字段 vs 页面 | ΔL `.000` | `.054` |
198
+ | 亮色 `--ku-color-border` | `rgba(grey-9, 0.08)` → 合成后 ΔL `.052` | `rgba(grey-9, 0.14)` → ΔL `.088` |
199
+
200
+ - **只动第 0–5 档。** 第 0 档仍是纯白、第 6–9 档(四个文字色)一位没挪,所以亮色的身份——白浮层、白字段、原样的四档文字——完整保留;文字对比度反而随页面变深而**变好**。
201
+ - **`fill-0` 从第 2 档移到第 0 档。** 它原先与 `bg-0` 同档,于是亮色下输入框底与页面底逐位相同,字段在页面上完全隐形。亮色的字段底本来就该是纯白 —— 暗色那边 `fill-0` 同样与面板同档,两个主题一致:**字段靠描边而不是靠底色立住**。
202
+ - **描边是另一半原因。** 同一个 alpha 在两个主题里不是同一件事:暗色的 `--ku-color-border` 是实色第 4 档,合成后 ΔL `.160`,而亮色 `0.08` 的合成只有 `.052`,**不到暗色的三分之一**。`0.14` 仍轻于暗色(亮色惯例如此),也仍轻于 Ant 的 `#d9d9d9` 铺在白底上的 ΔE 11.5。
203
+ - **附带收益**:页面变深后,亮色的白膜玻璃从 ΔL `.010–.026` 提到 `.016–.045`,遮罩的可读性也同步变好。
204
+ - **仍有一对同档**:`bg-3` ≡ `fill-1`(凹槽与 hover 填充)。`bg-3` 在本库内已无消费方,且两者从不出现在同一场景里,故未拆。
205
+ - 改法是改生成器的 `GREY_LIGHT` 表(保持每档既有的色相/彩度,只换 L)后重新生成 `palette.css`,**不是**在语义层打补丁。想再深一档就把那张表的 L 目标整体下调 —— 边界的代价是亮色整体观感变灰。
206
+
207
+ **已移除的 Token(公开 API 变更,请检查你的覆盖样式)**
208
+
209
+ 本轮清掉了 12 个全仓零引用的名字。它们是 `tokens.css` 作为公开 API 发布的一部分,所以**在覆盖样式里写过它们的消费者需要改掉**(缺失的变量会静默回落到浏览器默认,不报错):
210
+
211
+ | 被移除 | 替代 |
212
+ |---|---|
213
+ | `--ku-color-info` / `-hover` / `-active` | 用 `--ku-color-primary` 家族(`info` 的 `#3b82f6` 本就是蓝阶,没有独立色相) |
214
+ | `--ku-color-primary-disabled` | 禁用态用 `--ku-color-disabled-text` / `-bg` / `-fill` |
215
+ | `--ku-color-link-hover` / `-active` | 用 `--ku-color-primary-hover` / `-active`(取值本就相同) |
216
+ | `--ku-color-bg-4` | 用 `--ku-color-fill-1`(`Button` 的 `secondary` 档同时需要 hover/active,故取连续三档 `fill-1/2/3`) |
217
+ | `--ku-color-shadow` | 用 `--ku-shadow-sm` / `-elevated` / `-lg`(三个复合阴影) |
218
+ | `--ku-blur-sm` | 用 `--ku-blur-base`(模糊阶现为 10 / 14 / 18px) |
219
+ | `--ku-font-size-4xl` | 字号止于 `--ku-font-size-3xl`(20px) |
220
+ | `--ku-transition-base` / `-slow` | 用 `--ku-transition-fast`,或直接写字面量 |
221
+
222
+ 对应的 `Tokens.*` 访问器(`Tokens.info` / `infoHover` / `infoActive` / `primaryDisabled` / `bgBorder`)一并移除。
223
+
224
+ ### 内容色板(分类色,`--ku-content-*`)
225
+
226
+ `Tag` 的预设色名、`Avatar` 的哈希取色、`VChart` 的默认数据序列,用的是同一组十个色相。它们此前各自硬编码了一份**互不一致**的 hex 数组(同一个"蓝"在标签上是 `#5b9cf5`、在头像上是 `#4c90f0`、在图表上又是 `#4c90f0` 配 `#3b82f6`),而且两个主题下渲染成同一批颜色。现在三处都指向 `--ku-content-<hue>`:**一个色相一个档位**,由 `scripts/gen-palette.mjs` 按"等明度"选出来,且随主题变。
227
+
228
+ | | 改造前(三份数组) | 改造后(暗色 / 亮色) |
229
+ |---|---|---|
230
+ | 明度跨度 | `.177` | `.053` / `.041` |
231
+ | 色相去重 | `#4c90f0` 与 `#3b82f6` 相差 ΔE 4.3(同一个蓝,Avatar 与 VChart 因此各少一种颜色) | 最接近的一对 ΔE 6.3 / 6.9,两两可辨 |
232
+
233
+ - **选档规则**:每个色相取「**该主题下** L 最接近该主题目标明度」的那一档——亮色 `.71`、暗色 `.74`。**两张表不能合并**:阶梯方向随主题翻转,同一个档位号在两个主题里明度不同(blue 暗色取第 7 档、亮色取第 4 档),一张表通用会让暗色的跨度从 `.177` 退到 `.353`。
234
+ - **两个目标值都是重推过的**,因为「跨度」与「保真度」是同一条曲线的两端,而原先的取值各自停在了偏端:
235
+ - 亮色 `.70 → .71` 是一次**纯赢**:整张表只有 cyan 动了(第 7 档 `#00a8bf` ΔE 12.7 → 第 6 档 `#00bdd6` ΔE 6.5),其余九个色相逐位不变,而跨度 `.051 → .041`、平均 ΔE `4.1 → 3.4`、最大 ΔE `12.7 → 7.0`,三个轴同时变好。旧版的 `.70` 严格劣于 `.71`。
236
+ - 暗色 `.78 → .74` 是**取舍**:`.78` 拿到最紧的跨度(`.043`)但把一个色相推得极远——`red` 的旧值 `#ef4444` 在 L `.637`,离 `.78` 差 `.14`,最近的档是 `#f9a29a`(**ΔE 19.0**,淡到认不出是那个红)。`.74` 的跨度 `.053`(仍比改造前紧 3.3 倍)而最大 ΔE 砍到 `11.5`。
237
+ - **一条结构性张力**:锚点不变式(第 5 档 = 品牌色,逐位)与「等明度」这条规则**互相拉扯**——十个品牌色的 L 本身从 `.637` 散到 `.797`,要让它们等明度就必须把一部分色相**推离锚点**。所以「跨度最小」永远等价于「位移最大」,不存在双赢。这也是 `.73` 那一档的东西:它跨度 `.065`/平均 ΔE `3.7`,平均之所以最好,是因为十个色相里有五个恰好停在锚点(ΔE 0),而那正是品牌的 L 本来就不齐。
238
+ - **残余的 `.04~.06` 主要是量化误差**,不是规则失效:阶梯只有 10 档,目标明度附近相邻档间距约 `.06`,取最近档天然可偏半格。生成器自检按 `.07` 设阈值。
239
+ - **消费方写 `rgb(var(--ku-content-blue))`,不要写 `rgb(var(--ku-blue-7))`**——档位号随主题变。`Tag` / `Avatar` 的内联样式直接用这个字符串(跟随主题、无需 JS 重渲染);`VChart` 走 `style={{ fill: … }}` 而**不是** `fill=` 属性,因为 SVG 表现属性里的 `var()` 不保证被替换。
240
+ - 预设色名到色相的映射:`Tag` 的 `purple` → `violet`、`cyan` → `cyan`、`gray` → `grey`;其余同名。传入**非预设色名**的原始 CSS 颜色(如 `<Tag color="#ec4899">`)仍按原样使用,其半透明版由 `color-mix(in srgb, … , transparent)` 现场合成。
241
+
242
+ ### 毛玻璃(半透明面板)
243
+
244
+ Token(`--ku-color-glass` / `-strong` / `-head`、`--ku-blur-base` … `-xl`)只提供**值**;模糊必须靠类施加(`backdrop-filter` 写不进内联样式,`@supports` 也不支持内联)。所以 `tokens.css` 必须被引入,且玻璃面要显式挂基类:
245
+
246
+ | 类名 | 用途 | 底色 / 模糊 |
247
+ |---|---|---|
248
+ | `ku-glass` | 容器级(侧栏、卡片、面板) | `--ku-color-glass` / `--ku-blur-lg` |
249
+ | `ku-glass-head` | 头部、顶栏 | `--ku-color-glass-head` / `--ku-blur-lg` |
250
+ | `ku-glass-overlay` | 浮层(弹窗、抽屉、筛选面板、Toast / Notification / Feedback,以及 Cascader / AutoComplete / TreeSelect / DatePicker / TimePicker / ColorPicker 的弹层) | `--ku-color-glass-strong` / `--ku-blur-xl` |
251
+ | `ku-glass-menu` | 选项面板(`Dropdown` 与 `Select` 的菜单;其余下拉类弹层尚未换档) | `--ku-color-glass-menu` / `--ku-blur-menu` |
252
+ | `ku-glass-tip` | 小浮层气泡(Popover / Tooltip / Popconfirm / AudioPlayer 与 VideoPlayer 的音量条与倍速菜单 / Chat 的表情面板;`FloatButton` 的按钮也走这一档) | `--ku-color-glass-tip` / `--ku-blur-tip` / `--ku-glass-grain-tip`(**唯一一个颗粒也单独成 token 的档**) |
253
+ | `ku-glass-subtle` | 表格级(面积大,糊太重会糊掉内容) | `--ku-color-glass` / `--ku-blur-base` |
254
+ | `ku-mask` | 遮罩(Modal / SideSheet / Image 预览 / UserGuide / Cropper 裁剪蒙版 / Spin 覆盖层) | `--ku-color-mask` / `--ku-blur-mask` |
255
+
256
+ ```tsx
257
+ <aside className="ku-glass ku-scrollbar">…</aside>
258
+ ```
259
+
260
+ **为什么"选项面板"要单独成档**:浮层档的其余成员(Modal / SideSheet / Toast / Notification / Feedback)都是**大面积、稀疏**的面——半透底在那里是好事,背后的页面透上来,一眼就知道"这是浮在页面之上的一层"。选项面板正相反:一百多像素宽、三十像素一行的**密集文字**,是要逐行读的面;同样 0.34 的底,背后只要有一行代码或一个图标,就会从菜单项的文字中间透上来,读的是叠印的两层字。**读的密度不同,底的厚度就必须不同**——这是它单独一档的全部理由,不是"浮层档的加强版",用错档只会得到过薄或过厚的面。
261
+
262
+ **磨砂感与不透明度是两件事,别用同一个值去调**:底 `0.70` + `22px` 模糊,透上来的只剩三成。但底一加厚,透上来那三成里的模糊就已经**看不见了**——如果只抬底色,菜单会从"一层糊字"直接翻成"一块深色平板",中间没有过渡(这正是把底从 `0.34` 抬到 `0.58` 时踩到的坑:透明没了,磨砂也跟着没了)。所以这一档由两个值分工:**底色负责"读得清",颗粒 + 奶膜负责"看着是磨砂"**,缺一个都只能得到平板或糊字。这也是为什么 `.ku-glass-menu` 的背景是**两层** `background-image`——上层是颗粒,下层是奶膜(`--ku-glass-menu-film`)。奶膜不能省:深色底上只铺颗粒,读起来是"深色板子上撒了噪点";先垫一层几乎均匀的白把面板抬回浅冷灰调,颗粒才有东西可依附。颗粒放上层是为了保住它自己的对比度(奶膜压在上面会把噪点的明暗差一起压平)。
263
+
264
+ > **奶膜走 token,因为它是全库唯一一处只有单个主题有效的层。** 暗色值是 `linear-gradient(180deg, rgba(var(--ku-white), 0.05), rgba(var(--ku-white), 0.015))`;**亮色是 `none`**——亮色面板本来就是纯白膜,再叠一层白是白叠白,合成结果一个通道都不变,是个恒等变换。写死在类规则里它就是一句看不出问题的死代码(在亮色下白付一次合成),抽成 token 才看得见那一份是空的。**颗粒层两个主题都保留**:颗粒不是"加白",它在浅底上靠深色噪点、在深底上靠浅色噪点,两边都看得见——这是"噪声是单色的、亮/暗两主题共用"那句话的具体含义。
265
+
266
+ 遮罩也做成了毛玻璃:背景留在视野里但失焦后退,浮层读得出是"浮在页面上"而不是"换了一页"。`ku-mask` 单列一档而不复用上面几档 —— 它是唯一"面积 = 屏幕"的玻璃面,底色与模糊要按全屏合成开销单独标定。`ku-glass-tip` 同理:气泡面积小、合成便宜,所以它的 `saturate` 是五档里最高的(`195%`),换来"背后的颜色透得上来"。
267
+
268
+ `Cropper` 的裁剪蒙版和 `Spin` 的覆盖层也挂在 `ku-mask` 上。它们是这里的例外——盖住的是一张图、一个容器,而不是整个页面,面积小得多,同档只是因为"半透 + 磨砂"的观感要求一致,顺带省掉几组几乎同值的 token。
269
+
270
+ 挖洞(用户引导的高亮区、裁剪框外沿)一律是**一块遮罩 + 一条 `mask` 图**:不要四块遮罩拼,也不要几条 `mask` 图层拼。两者是同一个病根——每块/每层都是一张独立纹理,边缘那一两个像素要重新采样(或被吸附到整数设备像素),于是蒙版正好在接缝上缺一角;半透明 + 模糊把这点差异放大成一道 1~2px、肉眼可见的缝,而同样的缝在纯色蒙版上根本看不出来。
271
+
272
+ 矩形洞用**一条 SVG 路径**(外框一个子路径、洞一个子路径,`fill-rule='evenodd'` 挖掉中间,见 `rectHoleMaskImage`),`viewBox` 取遮罩自身的像素尺寸、`mask-size: 100% 100%` + `preserveAspectRatio='none'`,于是按布局尺寸一次性栅格化,全程没有二次缩放。圆形洞是单个 `radial-gradient`——它本来就只有一条图层,不存在接边。代价是要依赖 `mask` 裁掉 `backdrop-filter` 的结果(与元素自身填充同等对待),赌错的后果是整片均匀糊掉,一眼可见,不是那种难查的杂症。
273
+
274
+ **磨砂颗粒(本库玻璃在默认底纹上唯一的磨砂来源)**:`ku-glass-overlay` 与 `ku-glass-menu` 叠的是 `--ku-glass-grain`,`ku-glass-tip` 叠的是它自己的 `--ku-glass-grain-tip`(暗色与前者逐字相同,**亮色减半**——见「气泡的第二轮」)。原因是背景若是平滑渐变,模糊它得到的还是同一片平滑渐变——**模糊本身不产生肌理**。这条对本库尤其致命:本库的**默认底纹 `--ku-bg-image` 就是一条平滑径向渐变**,于是任何一档玻璃在默认底纹上,`backdrop-filter` 的模糊几乎是个**视觉上的空操作**——透上来的只是同一片渐变稍微暗一点。用户看到的"透明"其实是渐变在漏,而不是"磨砂"。
275
+
276
+ 结论是:**颗粒不是锦上添花的肌理,它是这套玻璃在默认底纹上唯一的磨砂来源**,所以强度必须给到看得见。`0.06`(上几轮的值)在暗面板上落在可见阈值以下,玻璃于是读成"换了个纯色面板";通用值是 `0.10`,刚好能看出磨砂、又还没到"电视雪花"的档位。噪声是单色的;只贴浮层和小面,大面积容器不贴(会显脏)。
277
+
278
+ **但它跟主题是反向的**:暗色下它是承重的肌理;**亮色下它只是纯粹的成本**——白膜上一个通道都不会变亮,只有"把面板拉暗(α `.28` 时约 `13/255`)+ 铺一层抑制低频泛光的噪点"两个副作用。所以亮色的 `tip` 那一档把它减半。判定原则:**"这个面实不实"在暗色下问颗粒,在亮色下问 alpha 和阴影。**
279
+
280
+ > 换个底纹(照片、纹理、密集内容)时,模糊才会真正上场;在默认渐变底上别指望它。若要在默认底纹上继续加强磨砂感,调 `--ku-glass-grain` 的 `opacity`(data URI 里那个 `0.10`),不要再动模糊半径。
281
+
282
+ **哪些面刻意不玻璃化**:`Markdown` 的**面板**是玻璃,但它块内的代码块 `pre` 保持不透明的 `bg-1`——`pre` 是嵌在玻璃面**里面**的一块内嵌面,跟着玻璃化就是玻璃套玻璃(使用前提 3),语法高亮色板也校准在实色暗底上,所以它读成一块实心内嵌面。同理不玻璃化的还有:`Lottie` / `AudioPlayer` / `VideoPlayer` 的"暂无内容"占位框(`fill-0` + 虚线边,是"这里没有东西"的示意而非面板),以及 `VideoPlayer` 的播放舞台与中央播放键(`#000` 与 rgba 黑白蒙层,校准在视频画面上而不是页面上)。
283
+
284
+ **代码面(`CodeEditor` / `CodeHighlight` / `JsonViewer`)**:**代码面完全交给 Monaco 自带的 `vs-dark`,本库不注册任何主题、不覆盖任何槽位。** 三个组件的 `theme` prop 默认值就是字符串 `"vs-dark"`,仍可显式传 `"light"` / `"hc-black"`。
285
+
286
+ - **代码面不随本库的明暗主题变化。** 这是刻意的:一整套自定义主题(`ku-dark` / `ku-light`,`base` 分别是 `vs-dark` / `vs`)曾经存在过,切换主题时代码配色会**整套从暗翻到亮**——观感上像是编辑器被换掉了,而不是主题在变。所以那两个主题连同 `tokens/monacoTheme.ts` 与 `KU_MONACO_THEME` 导出已一并移除。**`theme` prop 的 `"ku-dark"` / `"ku-light"` 两个取值也随之消失**,改用 Monaco 自带的三个名字;这是本轮唯一一处 JS 侧的公开 API 变动。
287
+ - **代价:亮色主题下代码面是一块深色面板。** 它铺的是 VS 自己的不透明 `#1e1e1e`,不再是外框的 `.ku-glass`(玻璃被它盖住),所以在浅色页面里读成一块稳定的深色代码块——半透明玻璃的观感在这里没有了。这是"代码区在两个主题下长得一样"的直接交换条件。
288
+ - 改造前那次"把 49 个手抄十六进制值逐槽位换成本库色板"的做法已整体撤销,**现在源码里没有任何复制的颜色值**,随之消失的还有那批手抄值与 token 真值之间的漂移(旧值里 `fill-2` 抄的是 `#2f3440`、`bg-2` 抄的是阶梯化之前的 `#14161f`)。**"读已解析颜色"的那一族导出也已一并剪掉**(`readToken` / `readTokens` / `readTriplet` / `rgb` / `hex` / `valueToHex` / `currentTheme` / `contentStep` / `contentColor` / `contentHex`)—— 它们服务的两个消费者都不在了:内容色板靠 `rgb(var(--ku-content-blue))` 这样的字符串拼装就够,Monaco 又交回给自带主题。所以 `tokens/index.ts` 现在只剩 `contentVar` / `contentTintVar` 两个函数,**一个色值都不存**。真要再给某个槽位换回本库色板,得把那套解析(`getComputedStyle` + 展开嵌套 `var()` + 转 `#RRGGBBAA`,约 70 行)重新写一遍;同时 `palette.gen.ts`(色板的 JS 常量镜像,原本给 SSR 兜底)也从生成器里删掉了,所以连"不依赖 document 的兜底三元组"都没有现成来源。
289
+ - **已知缺口:`Markdown` 的代码块仍是仅暗色的 highlight.js 样式表**(见「哪些面刻意不玻璃化」)。它现在与代码面的取向一致了——两者都是暗色高亮压在各自的面板上——但亮色下周围是浅色面板、块内是暗色语法色,仍需第二份样式表才能对齐。
290
+
291
+ 工具条(语言切换 + 格式化/复制)是玻璃面**内部**的带子,按使用前提 3 不再挂玻璃,改用 `--ku-color-fill-translucent-strong` 压一档 + 一条下边框——与 `Chat` 输入区同一套两级半透。**这里必须是 `-strong`(0.06)而不是 `-translucent`(0.03)**:`--ku-grey-9` 随主题翻转,所以 0.03 在暗色下是一条看得见的白膜、在亮色下却只有约 2/255——叠在亮色 30% 的白玻璃上,整条带子消失。而它下方紧挨着的是 Monaco `vs-dark`(固定的不透明 `#1e1e1e`,见上),于是这条带子读起来像「页面从代码块上方漏了出来」,而不像控件的 chrome。加厚到 0.06 后亮色 Δ 约 13/255,与 `.ku-table-th` 那条表头带子重量相当(暗色也随之略明显,属预期:一个声明两个主题)。
292
+
293
+ 三件套的按钮统一是 `Button` 的 `type="primary" size="small"`;注意 `CodeHighlight` **没有工具条**——它全文只有一个绝对定位在右上角、悬浮在代码之上的复制键(且是实底主色,不是白的),背后就是正在滚动的代码,是三者里唯一"背后没有稳定底色"的控件——若它读不清,就是这里需要单独换实底档。
294
+
295
+ **语言切换用 `Select`**(`optionList` 直接吃 `LANGUAGES`,`size="small"`,宽 110px)。这带出一件容易漏的事:`Select` 的弹层**不是 Portal**(详见使用前提 2 下那条注),是触发框内 `position: absolute` / `top: 100%` 就地渲染的,默认 200px 高、自带滚动条。只要祖先链上有一层 `overflow: hidden`,它就会被切在编辑器底边上,而且切多少取决于使用者传的 `height`。所以 `CodeEditor` 的外框**不能**裁剪,圆角改由两个内层各自收:工具条收上两角、编辑器区收下两角,两者都比外框小 1px(那 1px 是外框自己的边框,写同值会在角上漏一道月牙)。两处细节:
296
+
297
+ - 工具条**只收圆角、不加 `overflow`**。圆角对背景本来就生效(背景自动裁到 `border-radius`),`overflow` 只用来裁后代——而语言下拉恰好就挂在工具条里,工具条自己一裁,弹层又换个地方被切在 28px 的条带底边上。
298
+ - 编辑器区那层反过来必须留 `overflow: hidden` + `minHeight: 0`:前者兜住 Monaco 会越出编辑区的浮层(建议列表 / 悬停),后者是因为外框已不再兜底——不写的话 flex 项的 `min-height: auto` 会让它顶着 Monaco 的固定高不肯收缩,多出来的部分就漏到页面上了。
299
+
300
+ 代价是这条工具条上两种控件质感不同:`Select` 的触发框是**实底** `fill-0`(展开时还要换主色描边),旁边几个 `Button` 是浅色半透。这是 `Select` 自己的样式,不是这里写歪了;要统一得改 `Select`。
301
+
302
+ `AudioPlayer` 的播放条本体也保持不透明——两条小气泡是它的绝对定位后代,条一旦挂玻璃就成了它们的 backdrop root,气泡就只糊得到条自己那层半透底。`VideoPlayer` 的功能栏则要玻璃感,于是功能栏挂 `.ku-glass-overlay`;为了让它那两个气泡仍能糊到**画面**而不是只糊到功能栏,玻璃底被拆成了控制行的**兄弟层**——同一条 backdrop root 规则,两个方向(详见 `VideoPlayer` / `AudioPlayer` 的「播放器:进度体」)。
303
+
304
+ `VideoPlayer` 的功能栏是"玻璃底 + 过渡色"的写法:**渐变必须写成 `backgroundImage`,不能写 `background` 简写**。简写会把 `background-color` 一并重置成 `transparent`,而那是内联值、优先级高于类,玻璃底色会被整个压死(与 `Notification` 里那个坑同源)。这条渐变的另一面是它顶掉了类自带的磨砂颗粒——内联 `background-image` 优先于类的 `background-image`,这里是渐变让颗粒让位,不是漏了颗粒。功能栏本体现在同时是进度体,进度填充压在玻璃/渐变**之上**(见 `VideoPlayer` / `AudioPlayer` 的「播放器:进度体」),所以未播放的那一段才是能看见玻璃质感的那一段。
305
+
306
+ 五条使用前提:
307
+
308
+ 1. **身后要有"可糊之物"**。纯色底上玻璃只会读成一块纯色面板,却照样付一次 `backdrop-filter` 合成开销——请配 `--ku-bg-image` 之类的底纹。
309
+ 2. **`backdrop-filter` 会成为 `position: fixed` 后代的包含块**(与 `transform` / `filter` 同理)。给外框挂玻璃 = 把框内的 fixed 元素拽进框里,再被 `overflow: hidden` 裁掉。`FloatButton`、`BackTop`、`Image` 预览、`UserGuide` 等用就地 `position: fixed` 的组件,其父容器**不要**挂玻璃类。(`Popover` / `Tooltip` / `Modal` / `SideSheet` / `Popconfirm` / `Feedback` 都经 `Portal.render` 渲染,不受此限。)
310
+
311
+ **带弹层的组件大多数反而是就地渲染的**,上面那份 Portal 名单是全部。`Select` / `AutoComplete` / `Cascader` / `ColorPicker` / `DatePicker` / `TimePicker` / `TreeSelect` / `TagInput`(整个 `input/` 家族,无一例 Portal),以及 `Dropdown` / `Calendar`,弹层都是在触发框内 `position: absolute` / `top: 100%` 渲染的(`Dropdown` 头上那句 `import { Portal }` 是从未使用的遗留导入,别被它骗了)。于是它们不但受本条约束,还受 `overflow` 约束:**任何祖先只要有一层 `overflow: hidden`,弹层就会被切在该祖先的底边上**。要在工具栏 / 卡片 / 可滚动容器里放这类控件,得先确保触发框到根的这条链上没人裁剪(`CodeEditor` 就是这么处理的,见「代码面」一节)。
312
+ 3. **玻璃不要嵌套**。外层一旦有 `backdrop-filter`,它就是内层的 backdrop root——内层只会糊到外层的半透明填充,永远糊不到页面,白白多付一次合成。需要叠层时让它们做**兄弟**(`UserGuide` 的遮罩与卡片就是这样),或把内层换成不透明的实色面。
313
+ 4. **不要拿多个玻璃面(或多条 `mask` 图层)拼出一整块**。每块/每层都是独立纹理,贴边处会被重新采样或吸附到整数设备像素,缝隙与重叠都是 1~2px 级的,模糊会把它放大成肉眼可见的接缝。要挖洞就用**一块**玻璃面 + **一条** `mask` 图(见上面的 `Cropper` / `UserGuide`),要让两块玻璃挨着就留出实色边框或干脆分开。
314
+ 5. `backdrop-filter` 不可用时已有 `@supports not (...)` 回退为不透明底色,不会出现"文字浮在背景上"。(遮罩是例外:它没有"更实一档"可退,退回不透明就是关灯,所以退成一层更深的半透明黑。)
315
+
316
+ ### 亮色的玻璃分档
317
+
318
+ 亮色五档的 alpha 现在是 **`.20 / .28 / .50 / .62 / .76`**(顺序是 `glass` / `tip` / `strong` / `head` / `menu`;`ku-glass-subtle` 与 `ku-glass` 同值)。沿革:`.30 / .36 / .42 / .54 / .78` → `.30 / .48 / .60 / .72 / .84`(按对比度下限重标)→ `.20 / .38 / .50 / .62 / .76`(按"面板、弹出的标签、提示气泡要更透"的反馈整体调透,每档约 `-.10`)→ 当前值(**只把 `tip` 再降一档**,理由见下方「气泡的第二轮」)。改动的理由从来不是"让档位表看起来均匀"——**亮色的玻璃档位靠 alpha 根本分不出来**,这一点要先说清楚,免得下次再来回试。
319
+
320
+ **为什么分不出来。** 亮色页面底是 `#ecedf0`(L `.946`),纯白是 `1.000`——白膜能拉开的总区间只有 **ΔL `.054`**。五档全塞进去,相邻档实测 ΔE 只有 `1.0 / 0.6 / 0.6 / 0.6` 这个量级(可见阈值是 2)。这不是取值没调好,是窗口宽度决定的:要让相邻档可辨,Δα 至少得 `.20`,五档就要跨 `0.80`,最厚一档将 ≥ `1.10` —— 而 α 超过 `.85` 的"白膜"已经是一块实心白板、不再是玻璃了。**也就是说,让五档互辨所需的 alpha 跨度落在 `[0, 1]` 之外——这个方向无解。** 暗色同样分不出来(相邻档实测 ΔE `0.8–2.8`,同量级,原因是膜和底同色系),所以这也不是亮色独有的毛病。
321
+
322
+ **反过来说:既然分层的活儿不由 alpha 承担,那 alpha 就只是一个"观感旋钮",不是"层次旋钮"。** 动它只改变"这个面多实 / 多透",被它压掉或拉开的那点档位差反正都在可见阈值以下——**所以这一轮整体调透,结构上几乎不损失什么。** 当前值的相邻档差是 `.18 / .12 / .12 / .14`,按同一条 ΔE ∝ Δα 的线性关系折算,约 `1.0 / 0.7 / 0.7 / 0.8`——仍然全部远低于 2。**这是估算值,不是实测**;实质结论是"再怎么压也还是低于阈值"。
323
+
324
+ 亮色下调层靠的是另外两件东西,都在位:**遮罩**(`strong` 叠在 `.20` 遮罩压过的页面之上,实测 ΔL `.109`)与**阴影**(所有无遮罩的弹层——`Dropdown` / `Select` 菜单、`Popover` / `Tooltip` 气泡、`Cascader` / `DatePicker` 等——都带 `--ku-shadow-lg` / `-sm`)。
325
+
326
+ ### ⚠️ 一个会让人以为"改了没生效"的点:`glass` 这一档降了 `.10`,在亮色页面上几乎看不出来
327
+
328
+ `glass` / `subtle`(侧栏、卡片、表格)身后是页面自己的底纹(`#ffffff → #e0e2e5`),而白膜叠在这种**同样接近白**的底上本来就挪不动几个数:
329
+
330
+ | | 叠在页面顶部(`#ffffff`) | 叠在页面底部(`#e0e2e5`) |
331
+ |---|---|---|
332
+ | α `.30`(改前) | Δ 约 `6/255` | Δ 约 `9/255` |
333
+ | α `.20`(当前) | Δ 约 `4/255` | Δ 约 `6/255` |
334
+
335
+ **这一档的实 / 透基本由页面自己的明度决定,alpha 拧到头也就这样。** 所以这一轮 `glass` 的实际效果是**边界更依赖那 1px 描边与 `--ku-shadow-sm` 去划**,而不是"面板后面看得更清楚"。**如果发现亮色下面板糊成一片、找不着边,那是描边 / 阴影不够,不要回头去加 alpha** —— 在这条底上加 alpha 救不了它(加 `.10` 也只有几个数)。其余四档身后是**内容**(卡片、代码块、图片),alpha 一动就是实打实的差别,那才是肉眼能看到变化的地方。
336
+
337
+ **那 alpha 该按什么定:按"这一档身后藏着什么、上面的字要多少底才读得清"。** 于是分成两类:
338
+
339
+ - **贴在页面上的一档(`glass` / `subtle`)**:身后最坏也只是页面自己的浅色底纹,要读的是页面级的文字,所以它不受对比度下限约束(**它不进到任意内容之上**——不是因为它在近黑内容上够读,同一张表里 α `.20` 只有 `1.89:1`)。它一路往"薄"走(`.55 → .42 → .30 → .20`),本轮再降一档。
340
+ - **身后可能是任意内容的四档,本该都守在对比度下限之上。** 弹层、气泡、菜单会浮在**任意**内容上,最坏是近黑(代码块、图片暗部)——而亮色的玻璃文字是深色的 `text-0`,深字要读得清只能靠膜把底垫亮。这个下限是 `.46`(AA 正文 `4.5`)。下表的合成值与对比度(文字 `#1c1f23` 压在 `#1e1e1e` 上,合成值 = `30 + 225α`):
341
+
342
+ | alpha | 合成后 | 对比度 | 对应档位 |
343
+ |---|---|---|---|
344
+ | `.20` | `#4b4b4b` | `1.89:1` ✗ | **当前的 `glass` / `subtle`**(不受此表约束,见上) |
345
+ | `.28` | `#5d5d5d` | `2.51:1` ✗ | **当前的 `tip`** —— 明知故犯,两轮连着降,见下 |
346
+ | `.30` | `#626262` | `2.69:1` ✗ | 改前的 `glass` |
347
+ | `.36` | `#6f6f6f` | `3.29:1` ✗ | 最老的 `tip` |
348
+ | `.38` | `#747474` | `3.51:1` ✗ | 上一轮的 `tip` |
349
+ | `.42` | `#7d7d7d` | `3.99:1` ✗ | 上上轮的 `glass` |
350
+ | `.46` | `#868686` | `4.51:1` ← WCAG AA 正文下限 | —(仅作参照,未取用) |
351
+ | `.48` | `#8a8a8a` | `4.79:1` ✓ | 上一轮的 `tip` |
352
+ | `.50` | `#8f8f8f` | `5.08:1` ✓ | **当前的 `strong`** |
353
+ | `.58` | `#a1a1a1` | `6.40:1` ✓ | 上一轮的 `tip` |
354
+ | `.60` | `#a5a5a5` | `6.71:1` ✓ | 改前的 `strong` |
355
+ | `.62` | `#aaaaaa` | `7.08:1` ✓ | **当前的 `head`** |
356
+ | `.72` | `#c0c0c0` | `9.09:1` ✓ | 改前的 `head` |
357
+ | `.76` | `#c9c9c9` | `9.99:1` ✓ | **当前的 `menu`** |
358
+ | `.84` | `#dbdbdb` | `11.95:1` ✓ | 改前的 `menu` |
359
+
360
+ **下调已经连着让掉两轮下限——这是有意为之,不是漏算。** `tip` 先降到 `.38`(`3.51:1`),又降到 `.28`(**`2.51:1`**),都低于 AA 正文的 `4.5`,而 `.48` 正是更早一轮为修好它才取的值。四档里真正踩在线上的**只有 `tip` 这一类**:`strong`(`.50`)是 `5.08:1`,仍在线上;`head`(`.62`)`7.08:1`、`menu`(`.76`)`9.99:1` 都很稳。所以受影响的是**气泡**——`Tooltip` / `Popover` 一旦压在深色代码块或图片暗部上,正文会偏吃力。要收回就是**只把 `tip` 抬回 `.46–.48`,其余四档不动**——它仍是五档里唯一真骑在线上的一个,也是唯一一个可以单独重标而不牵动别处的档位。
361
+
362
+ > 表里这几个数是**只按 alpha 算的**(合成值 = `30 + 225α`),没有计入颗粒层。颗粒的均值在中灰附近,压在近黑面板上会把面板**略微提亮**(α `.28` 时约 `+1.7/255`),对深底上的深字是一点点帮助,减半则收回这 `1.7` 里的一半。量级在四舍五入以内,所以上表照旧可用,但别把它当成精确到小数第二位的值。
363
+
364
+ **整体调透那一轮的下调不是等步长**,末尾一档少降一点:
365
+
366
+ | 档 | 改前 | 该轮 | 增量 | 为什么是这个量 |
367
+ |---|---|---|---|---|
368
+ | `glass` / `subtle` | `.30` | `.20` | `-.10` | 见上面那条警告:降了但也看不出降 |
369
+ | `tip` | `.48` | `.38` | `-.10` | 这一档是唯一骑在 AA 线上的,要收回就从它开始 |
370
+ | `strong` | `.60` | `.50` | `-.10` | |
371
+ | `head` | `.72` | `.62` | `-.10` | |
372
+ | `menu` | `.84` | `.76` | `-.08` | 最实的一档,少降一点以保住逐行可读 |
373
+
374
+ **之后又单独降了 `tip` 一档(`.38 → .28`)**,其余四档一个没动——理由见「气泡的第二轮」。所以「当前值」那一列里只有 `tip` 与上表不同。
375
+
376
+ 次序仍是设计里那条 `glass < tip < strong < head < menu`(菜单是要逐行读的最密一档所以最实,顶栏 `head` 次之,气泡 `tip` 仍比弹层 `strong` 再透一档)。
377
+
378
+ ### 同一轮的第二步:模糊半径整体砍半(磨砂太重)
379
+
380
+ 把 alpha 降下来之后,反馈仍是"**磨砂太重、背景的图和颜色透不过来**"——也就是说剩下的障碍不再是膜的厚度,而是**模糊半径**。原因很直接:`blur(40px)` 会把一张图糊成一片平均色,图里的细节和层次全没了,膜再薄也救不回来。**alpha 只管"膜盖了多厚",半径才管"背后的东西还能不能成形"。**
381
+
382
+ 所以半径跟着一起砍,砍到约一半(两个主题共用,暗色一起变清):
383
+
384
+ | token | 改前 | 当前 | 用在哪 |
385
+ |---|---|---|---|
386
+ | `--ku-blur-base` | `blur(22px) saturate(140%)` | `blur(10px) saturate(165%)` | `ku-glass-subtle`(表格) |
387
+ | `--ku-blur-lg` | `blur(30px) saturate(150%)` | `blur(14px) saturate(175%)` | `ku-glass` / `ku-glass-head`(侧栏、卡片、面板、顶栏) |
388
+ | `--ku-blur-xl` | `blur(40px) saturate(160%)` | `blur(18px) saturate(185%)` | `ku-glass-overlay`(弹窗、抽屉、各类弹层) |
389
+ | `--ku-blur-tip` | `blur(36px) saturate(170%)` | `blur(16px) saturate(195%)` | `ku-glass-tip`(气泡) |
390
+ | `--ku-blur-menu` | `blur(48px) saturate(175%)` | `blur(22px) saturate(195%)` | `ku-glass-menu`(下拉菜单) |
391
+ | `--ku-blur-mask` | `blur(10px) saturate(120%)` | **不动** | `ku-mask`(遮罩) |
392
+
393
+ **`saturate` 一并往上抬**,因为"颜色透不过来"还有另一半原因:膜再薄也还是盖了一层,白膜与深膜都会洗掉背后的彩度,不把 `saturate` 补上去,透上来的图会读成灰蒙蒙一片。
394
+
395
+ **方向翻了一处要说清楚:这条跷跷板规则此前是"每降一档底色,模糊必须跟着涨一档"。** 那条规则服务的目标是"看得见背后有东西,但**认不出是什么**"——认不出,靠的就是大半径。现在的目标是"**背景的图和颜色要透得过来**",大半径本身就与它冲突,于是两个旋钮改成同向。**这是目标变了,不是规则被违反了。**
396
+
397
+ **颗粒层(`--ku-glass-grain`,`0.10`)这一轮一个没动。** 10% 的单色噪点挡不住背后的图,它不是"图和颜色透不过来"的成因。但要说清楚:**它才是"磨砂感"这个词在本库里的实际载体** —— 模糊只负责"看不清",哑光的肌理全靠这颗颗粒(原因见「磨砂颗粒」一节:默认底纹是平滑渐变,模糊它对它是空操作)。所以**如果仍嫌"磨砂感太重",下一个旋钮是颗粒,而不是继续砍半径** —— 半径砍到 0 也去不掉那层哑光,而砍过头就不成其为玻璃了。**这条建议下一轮就被用上了,但落点在亮色的小浮层档(`0.05`,只动这一档),见「气泡的第二轮」。**
398
+
399
+ **注意这一档的半径次序变了**:`tip`(`16px`)现在比 `strong`(`18px`)还小。小面积的面本来就更容易看清背后,再叠大半径只是白糊一场 —— 它的 `saturate` `195%` 仍与 `menu` 并列五档最高。
400
+
401
+ **也刻意没动暗色。** 报的是亮色,暗色五档原样。
402
+
403
+ **奶膜在亮色下置空**(`--ku-glass-menu-film: none`),理由见上一节。
404
+
405
+ > ⚠️ **别用"暗色比亮色稳"来给这次改动作旁证——那句只在自己的背景上成立。** 把两个主题的**最坏背景**摆到一张表里,是同一个毛病,而且暗色更重:
406
+ >
407
+ > | 站在谁的角度 | 最坏背景 | tip | strong | head |
408
+ > |---|---|---|---|---|
409
+ > | 暗色(浅色文字压深膜) | 浅色内容 `#e8e8e8` | `1.73:1` | `2.24:1` | `1.98:1` |
410
+ > | 亮色(深色文字压白膜) | 深色内容 `#1e1e1e` | `4.79:1` → **`2.51:1`** ✗ | `6.71:1` → **`5.08:1`** | `9.09:1` → **`7.08:1`** |
411
+ >
412
+ > 暗色五档**在自己的页面底上**是 `16.3–17.5:1`(比亮色还高),所以单看自家底纹会以为"暗色没这个问题"。两个主题唯一的区别是**哪种背景更常见**:亮色下深色内容近在眼前(`Monaco` 按既定决策固定暗色,代码块就是一块近黑),暗色下浅色内容要遇到浅色图片才会出现。所以按范围只重标了亮色,暗色那三个 alpha 原样保留——**上面暗色那一行是一处已知的、尚未处理的缺口**,记在这里,免得下次又被"暗色看起来没问题"骗过去。
413
+ >
414
+ > **注意本轮的方向:亮色那一行是往下走的**(调透 → 膜变薄 → 深底上垫得不够),`tip` 因此从 `✓` 翻成 `✗`。这是本轮唯一一处质量倒退,记录在案;暗色那行不受影响。反过来,**以后再抬亮色 alpha 会让亮色这行变宽松**,但暗色那行永远不动它。
415
+
416
+ ### 气泡的第二轮:亮色 `tip` 再透一档 + 颗粒减半
417
+
418
+ 反馈是"**`Popover` 在亮色下磨砂透明看着更通透一点,可以看到背景的泛光**"。**只降 `tip` 一档是合理的** —— 它整个亮色梯子里**面积小到可以只看背景**的那一档(一块 300px 的气泡里没有几行字),其余四档身后都压着要读的内容,跟着降会连带伤可读性。
419
+
420
+ **但这一轮的结论是:光降 alpha 解决不了这条反馈,真正的旋钮是颗粒层。** 这一条值得单独记住,因为它是「alpha 在亮色近白底上只是观感旋钮」那条规律的第三个实例。
421
+
422
+ 把 `tip` 从 `.38` 降到 `.28`,面板自身在页面底纹上只挪了约 **`3/255`**(`243.2 → 240.0`)—— 又一次"改了但看不出"。真正的问题在于信噪比:
423
+
424
+ | | 泛光振幅(面板两端的差) | 颗粒噪点 | 信噪比 |
425
+ |---|---|---|---|
426
+ | 改前(α `.38`,颗粒 `0.10`) | 约 `17/255` | **±8/255** | 约 `2` |
427
+ | 改后(α `.28`,颗粒 `0.05`) | 约 `21/255` | **±4/255** | 约 `5` |
428
+
429
+ **背景的泛光是一条 `20/255` 量级的低频渐变,埋在 ±`8` 的噪点里读不出来。** 颗粒一减半,噪点让开、振幅上浮,渐变才成形。同期颗粒层自身的**平均拉暗**也从约 `13/255` 降到约 `6/255`,所以面板整体更亮、更"飘"。
430
+
431
+ **颗粒层在这里跟主题是反向的,这点要写清楚:** 它在暗色下承重(深面板靠它才不是一块纯色板),在亮色下**只是纯粹的成本** —— 白膜上一个通道都不会变亮,只有"拉暗 + 铺噪点"两个副作用。所以这一次**只动亮色、只动 `tip` 那一档**,新增一个 token 承载:
432
+
433
+ | token | `:root`(暗色) | `:root[data-ku-theme="light"]` | 消费者 |
434
+ |---|---|---|---|
435
+ | `--ku-glass-grain-tip` | `var(--ku-glass-grain)`(与通用值逐字相同) | 同一份 SVG,`opacity` `0.10 → 0.05` | `ku-glass-tip` |
436
+
437
+ **暗色一行不受影响**(`--ku-glass-grain-tip` 在 `:root` 里就是 `var(--ku-glass-grain)`),其余四档的颗粒也原样。亮色那份 data URI 与 `--ku-glass-grain` **只有最后一个 `opacity` 值不同**,改其中一份时记得两份一起改。
438
+
439
+ **收到的效果是"信噪比 ×2.5",不是"面板明显变透明"。** 如果后面还嫌不够,下一个旋钮仍是颗粒(`0.05 → 0`,即亮色气泡完全不铺颗粒)—— 那时亮色气泡就变成一层纯白膜,是"最透"的形态。继续动 alpha 是最没效率的一步。
440
+
441
+ **代价**:`tip` 到 `.28` 后压在近黑内容上只有 **`2.51:1`**,连续第二轮跌破 AA(见上一节的实测表)。气泡压在深色代码块或图片暗部上时正文会偏吃力。要收回就抬回 `.46–.48`。
442
+
443
+ ### 类规则与内联样式(谁能赢)
444
+
445
+ 组件的样式几乎全写在**内联 `style`** 里(颜色、描边、圆角、`cursor`、过渡……),而内联声明**优先于任何类选择器**——`:hover`、`:focus-visible` 也不例外(`border` 简写还会连带盖掉 `border-color`)。所以 `tokens.css` 里凡是与内联同名同属性的类规则都是"看着有、其实不生效"的死规则,现已全部清理。**留下的类规则都属于"内联没写、类才管得着"的那几类**:
446
+
447
+ | 类 | 作用 | 为什么类能赢 |
448
+ |---|---|---|
449
+ | `ku-glass*` / `ku-mask` | 玻璃底色 + 模糊 + `@supports` 回退 | `backdrop-filter` 与 `@supports` 都写不进内联 |
450
+ | `ku-table-*`(行 / 表头 / 排序 / 筛选) | 条纹、悬停、选中、表头吸顶 | 行与表头的内联不含底色 |
451
+ | `ku-calendar-cell*` | 悬停、今天、选中 | 单元格内联刻意只留布局属性 |
452
+ | `ku-select-option*` / `ku-dropdown-item*` / `ku-collapse-header` / `ku-table-filter-item` | 悬停底、选中底、展开过渡 | 内联没写底色(只有 `cursor` 被内联占了) |
453
+ | `ku-tag` / `ku-scrollbar` / `ku-float-button` | 过渡、滚动条伪元素、悬浮/展开态、玻璃档 | 内联未涉及这些属性;`ku-float-button` 还独占了三项:**底色**(内联已不再写 `background-color`)、**UA 重置**(`appearance: none` / `border: none` —— 少了下标就会露出 UA 的原生 `outset` 斜角环)、以及**阴影的两个状态**(`:hover` 的 `-lg` 与 `box-shadow` 过渡都写不了内联,组件里那句内联 `boxShadow` 因此是删掉的) |
454
+ | `ku-layout-sider-trigger` | 折叠钮的**几何**(绝对定位骑在侧栏右边缘、24px 圆钮)、**UA 重置**(`appearance: none` 等)与不透明底色 | 这些属性内联没写——钮要压出侧栏边缘,位置只能由类给;UA 重置同理,写不进内联的语义里 |
455
+ | `ku-userguide-card` | UserGuide 引导卡片的**不透明**底(`--ku-color-bg-3`) | 卡片不挂玻璃,底色改由类提供(内联原本就没写 `background-color`) |
456
+ | `ku-focus-ring` | 键盘焦点环(见下) | `Button` / `FloatButton` 原先内联 `outline: none` 关掉了 UA 环又不给替代,现已移除该内联——不删内联,挂类也出不来环 |
457
+ | `ku-modal-close` | 关闭按钮的悬停底 | 内联没写 `background-color`(`color` 被内联占了,所以悬停只改底、不改色) |
458
+ | `ku-modal-btn-primary` | 主按钮的悬停 / 按下 | 底色内联恒有值(还要随 `confirmLoading` 变),只有 `filter` 没被内联占住 |
459
+
460
+ 推论:**给这些组件挂自定义类去改悬停色是无效的**,得改组件内联里的那一份,或把该属性从内联挪进类。
461
+
462
+ > ⚠️ **类规则失效有两种坏法,第二种比第一种难发现得多。** 一种是"被内联盖掉"(上表的主题),至少还看得见规则本身;另一种是**选择器写错,规则一条都不生效,而 CSS 不报错**。后者已经真实发生过一次:`.ku-glass-head` 前面误粘了一个中文字符,选择器成了 `额 .ku-glass-head` —— 这是**语法完全合法**的后代选择器(CSS 标识符允许非 ASCII 字符),于是它静默地永不匹配,`Layout.Header` / `Layout.Footer` 的玻璃底与模糊**一处都没生效过**,而且两个主题都一样,看起来就像"顶栏本来就不该是玻璃的"。
463
+ >
464
+ > 排查手段:在 DevTools 里选中那个元素,看规则面板里那条类规则在不在 —— **不在 = 选择器没匹配上(去核对选择器字符串),在但被划掉 = 被内联或更高优先级盖掉(去核对内联)**。这两条病因完全不同,别混。
465
+
466
+ ### 焦点环(键盘可达性)
467
+
468
+ `ku-focus-ring` 挂在 `Button`、`Checkbox`、`Switch`、`Layout.Sider` 的折叠钮,以及 `FloatButton` 与 `FloatButton.Group` 的触发钮上,走 `:focus-visible`(鼠标点击不出现,键盘 Tab 才出现):
469
+
470
+ ```css
471
+ .ku-focus-ring:focus-visible { outline: 2px solid var(--ku-color-primary); outline-offset: 1px; }
472
+ ```
473
+
474
+ > **挂这个类的前提是那个元素的内联里没有 `outline`。** 内联优先于类,元素自己写着 `outline: none` 的话,这里画多粗都出不来。`Button` 与 `FloatButton` 都是**先删掉内联那一行、再挂上的**——`FloatButton` 是后来补的:它内联 `outline: "none"` 关掉了 UA 焦点环却没给替代,于是"能 Tab 到、却看不出焦点落在哪",而且光加类没用。
475
+
476
+ 其余控件不走这条:`Input` 由自身 `focused` 状态画 `box-shadow` 聚焦环;`Select` 的触发框目前**不可键盘聚焦**(没有 `tabIndex`),因而也没有焦点环——要用键盘操作它得先补键盘支持。
477
+
478
+ ### 圆角分两档
479
+
480
+ `--ku-radius-base`(4px)给**控件**:输入框、按钮、小控件,以及像 `Toast` 这种又扁又小的临时提示条。`--ku-radius-md`(6px)给**容器**:卡片、表格、下拉面板、导航项、分页项、Tab、标签、上传卡、视频外框等"一整块面板 / 一整行列表"级别的面,`Notification` / `Popconfirm` / `Tooltip` / `Dropdown` 这类浮层气泡也归这一档。`xs`/`sm` 留给更小的内嵌元素,`lg`/`xl` 给弹窗、抽屉与引导卡片。
156
481
 
157
482
  ## 通用 Props(UIProps)
158
483
 
@@ -230,6 +555,45 @@ import { FloatButton } from "knight-ui";
230
555
 
231
556
  **FloatButton.Group** 额外支持 `direction: "vertical" | "horizontal"`。
232
557
 
558
+ **按钮自身挂玻璃(`.ku-glass-tip`),图标走主色,不描边。** 悬浮按钮是"浮在页面任意内容之上"的一枚小面,磨砂档(最透的那一档,与小浮层气泡同档)才读得出这一点——底色由类提供,内联不再写 `background-color`(内联优先级高于类,留着就把玻璃底盖死,与 `Card` / `CodeEditor` 同一个约定)。原来的 `#fff` 图标改成 `var(--ku-color-primary)`:底色已经半透,主色留在图标上才不丢品牌的锚点。
559
+
560
+ **边界靠阴影,不靠描边。** 一圈 `1px var(--ku-color-border)` 会把磨砂面**框成一个实心圆**——边缘越硬,"背后有东西"越读不出来,与磨砂的意图正好相反;柔影才是"浮起来"的语义,与磨砂同向。早前那一版用的是描边 + `boxSizing: border-box`,现已去掉,`box-sizing` 也随之不再需要(没有边框和内外边距时它是个空操作)。
561
+
562
+ > **亮色下这枚按钮偏淡,是结构性的,两个原因叠加。** 一是白膜叠在近白页面上本来就挪不动几个数(见「亮色的玻璃分档」里那条警告);二是它的演示区是**刻意留空的**,身后没有东西可糊。**「气泡的第二轮」把亮色 `tip` 档的颗粒减半,正好也缓解了这一点**(颗粒层在亮色下的平均拉暗从约 `13/255` 降到约 `6/255`)。**若还嫌淡,下一步是"给它单独一档"而不是继续动共用的 `tip`** —— `tip` 是 Tooltip / Popconfirm / 音视频条 / 表情面板共用的,为了一枚按钮继续往下降会连带把它们的可读性一起拉下去。
563
+
564
+ ### ⚠️ 去掉描边必须同时做 UA 重置,否则会露出一圈"半亮半暗的环"
565
+
566
+ **`<button>` 的 UA 默认样式带 `appearance: auto` 与一条 `2px outset` 的原生边框,而 `outset` 画出来就是"左上亮、右下暗"的一圈立体斜角。** 没有作者边框盖着它,按钮外面就会多出一圈半亮半暗的环——看起来就像"描边没删干净"。
567
+
568
+ 早前那一版的 `border: 1px solid var(--ku-color-border)` 是**顺手压住它**的;去掉描边时如果只删不补,UA 那圈斜角就露出来了。**这不是"描边"能修的**:把边框色改成 `transparent` 也一样,因为那是 UA 的绘制,不是一条可染色的边。必须 `appearance: none` 与 `border: none` **一起**下。
569
+
570
+ 所以 `.ku-float-button` 的类规则第一段就是 UA 重置,同 `.ku-layout-sider-trigger` 的做法:
571
+
572
+ ```css
573
+ .ku-float-button {
574
+ /* UA 重置 */
575
+ appearance: none; border: none; margin: 0; padding: 0;
576
+ font: inherit; line-height: 1; /* line-height 必须在 font 之后: font 简写会连它一起重置 */
577
+ /* token 化的外观 */
578
+ border-radius: 50%; display: flex; align-items: center; justify-content: center;
579
+ color: var(--ku-color-primary); cursor: pointer; user-select: none;
580
+ box-shadow: var(--ku-shadow-elevated);
581
+ transition: transform .15s ease, box-shadow .15s ease, background-color .15s ease;
582
+ }
583
+ ```
584
+
585
+ `padding` / `margin` / `font` 一并归零,因为它们都会吃进内联的 `width` / `height`,不归零那个 `size` 就名不副实;触发钮折叠态渲染的是文字 `"+"`,所以 `line-height: 1` 也要给。
586
+
587
+ **组件内联现在只剩随 props / state 变的两项**:位置与 `size`(外加触发钮展开态的 `rotate`)。圆、居中、主色、`border-radius`、`cursor` 全部由类给——那些是 token 化的外观,不该散在组件里。
588
+
589
+ ### 阴影必须留在类里,不能写进内联
590
+
591
+ 这一档有两件内联干不了的事:静置 `--ku-shadow-elevated` / 悬停 `--ku-shadow-lg` 的**两个状态**(内联写不了 `:hover`),以及 `box-shadow` 的**过渡**。所以组件里那句内联 `boxShadow` 是删掉的而非补上的——内联会连 `:hover` 一起盖掉,按钮就永远不抬起。同理,内联的 `outline: none` 也必须删掉,`.ku-focus-ring` 才画得出环。
592
+
593
+ **`Group` 的触发钮挂同一套类**(`ku-float-btn-trigger ku-float-button ku-glass-tip ku-focus-ring`):它和独立按钮本就是同一个东西,两套做法会像坏了,而且它此前**一条类规则都没有**(没有阴影、没有悬停)。唯一的差别是展开态的内联 `rotate(45deg)` 会盖掉 `:hover` 的 `scale` —— 它悬停时只变阴影、不放大,这是有意的。组内的**菜单项**保持原样(`bg-2` 不透明面 + 描边 + 阴影),它们是**菜单项**不是浮钮,本来就要挡住背后的内容。
594
+
595
+ > **与使用前提 2 不冲突。** 那条讲的是"**父容器**不要挂玻璃"——`backdrop-filter` 会让元素成为后代 `position: fixed` 元素的包含块。这里玻璃挂在**按钮自己**身上:元素自身的 `backdrop-filter` 不影响自身的定位,所以 `position: fixed` 的 `FloatButton` 与 `position: fixed` 的 `Group` 容器都照旧固定在视口上。要避免的是给**外面那层包裹 div** 挂玻璃。
596
+
233
597
  ### Icon 图标
234
598
 
235
599
  ```tsx
@@ -357,6 +721,12 @@ import { Layout, Text } from "knight-ui";
357
721
 
358
722
  **Layout.Sider**:`width`、`collapsedWidth`、`defaultCollapsed`、`trigger`
359
723
 
724
+ **折叠钮是一枚骑在侧栏右边缘、垂直居中的 24px 圆钮**(`trigger` 为真时出现在 `.ku-layout-sider-trigger` 上)。它压在 `--ku-color-fill-1` 的**不透明**底上、hover 走 `fill-2`,配 `1px var(--ku-color-border)` 与 `--ku-shadow-sm`;图标是 `IconChevronLeft` / `IconChevronRight`(展开态显示左箭头,折叠态显示右箭头)。三点都值得记:
725
+
726
+ - **底色必须不透明。** 圆钮**同时骑在两种底色上**——左半边是玻璃侧栏,右半边是内容区。半透底会让同一颗钮的左右两半各取各的背景,显出两种颜色。这与 `UserGuide` 卡片是同一个病根("浮层身后有两种底色"),只是面积小到不容易被指名。上一版是一条铺满侧栏宽度的底横条,当时的注释论证"横条必须半透否则会在玻璃上切出死色横缝"——那个论证只对横条成立(它完全位于玻璃内部,上下要连续),对骑边缘的圆钮不适用。
727
+ - **裁剪挪到了内层。** 钮要压出侧栏边缘(`right: -12px`),所以 `<aside>` 是 `position: relative` + `overflow: visible`,而 `overflow: hidden` 下移到内层那一列上——折叠过渡期间仍然夹得住内容。
728
+ - **它是真 `<button>`**,带 `aria-label` / `aria-expanded` 与 `ku-focus-ring`(走 `:focus-visible`,键盘 Tab 才出现)。上一版是个裸 `<div onClick>`,键盘够不到。
729
+
360
730
  ### Grid 栅格
361
731
 
362
732
  ```tsx
@@ -452,6 +822,10 @@ import { Input } from "knight-ui";
452
822
  | `validateStatus` | `"warning" \| "error" \| "success"` | — | 校验状态 |
453
823
  | `onChange` | `(value: string) => void` | — | 变更回调 |
454
824
 
825
+ **校验态只改描边,不铺底。** `validateStatus` 生效时字段底色**仍是 `fill-0`**,与普通字段同色——只有 1px 的 `--ku-color-{hue}-border`(同色 60% 透明)变红/黄/绿。这与 Ant Design 5 / Semi 一致。之前整个字段被 14% 的同色铺满,那是全库唯一一处铺**面积**的着色,也是"校验态看着厚重"的主因。聚焦时是一圈与普通聚焦态同构的 `0 0 0 2px` 柔光(不再按有无校验态在 1px/2px 之间切换——1px 的环紧贴 1px 的同色描边时,读起来是一条 2px 的粗边,那是"重"的另一个来源)。
826
+
827
+ > `Input.TextArea` 用的是同一套逻辑(同一个组件文件里写了两遍)。顺带修掉一个真 bug:`TextArea` 的聚焦环此前是 `boxShadow: <颜色>` —— 只给颜色不给偏移/模糊/扩散,`box-shadow` 会塌缩成零尺寸,**画不出任何东西**,也就是说 `TextArea` 一直没有聚焦指示。现已与 `Input` 统一。
828
+
455
829
  ### InputNumber 数字输入
456
830
 
457
831
  ```tsx
@@ -800,6 +1174,8 @@ const data = [
800
1174
  <Transfer dataSource={data} defaultValue={["1"]} titles={["待选", "已选"]} onChange={(keys) => console.log(keys)} />
801
1175
  ```
802
1176
 
1177
+ 两栏中间的左右箭头是 `IconChevronLeft` / `IconChevronRight`(`size="large"`,14px),颜色走 `currentColor` 继承按钮的 `#fff` / `text-2`,随禁用态一起变。这两个按钮原本是 `›` / `‹` 两个裸 unicode 字符,字形是 16px 字号下的一枚单字符(墨迹约 6px);换成 14px 的 SVG chevron 才是相同观感,直接沿用 `fontSize: xl` 会明显变大,所以只为字符服务的 `fontSize` / `lineHeight` / `fontFamily` 一并删掉了。
1178
+
803
1179
  | 属性 | 类型 | 默认值 | 说明 |
804
1180
  |---|---|---|---|
805
1181
  | `dataSource` | `TransferItem[]` | — | 数据源 |
@@ -1155,7 +1531,7 @@ import { Avatar } from "knight-ui";
1155
1531
  | `size` | `"extra-small" \| "small" \| "default" \| "medium" \| "large" \| number` | `"default"` | 尺寸 |
1156
1532
  | `shape` | `"circle" \| "square"` | `"circle"` | 形状 |
1157
1533
  | `src` | `string` | — | 图片地址 |
1158
- | `color` | `string` | — | 自定义颜色 |
1534
+ | `color` | `string` | — | 自定义颜色;不传则按内容哈希从**内容色板**取一个色相(跟随主题,8 个色相互不相同) |
1159
1535
 
1160
1536
  ### Badge 角标
1161
1537
 
@@ -1201,7 +1577,7 @@ import { Card, Text, Button, Avatar } from "knight-ui";
1201
1577
  | `header / footer` | `React.ReactNode` | — | 头部/底部 |
1202
1578
  | `actions` | `React.ReactNode[]` | — | 操作区 |
1203
1579
  | `bordered` | `boolean` | `true` | 边框 |
1204
- | `shadows` | `"hover" \| "always" \| "none"` | `"hover"` | 阴影 |
1580
+ | `shadows` | `"hover" \| "always" \| "none"` | `"hover"` | 阴影。`"always"` 取 `--ku-shadow-elevated`(不是 `-sm`)——无边框卡片只靠阴影与背景分层,`-sm` 的暗色量级在暗底上几乎看不见 |
1205
1581
 
1206
1582
  ### Tag 标签
1207
1583
 
@@ -1218,7 +1594,7 @@ import { Tag } from "knight-ui";
1218
1594
  | 属性 | 类型 | 默认值 | 说明 |
1219
1595
  |---|---|---|---|
1220
1596
  | `type` | `"ghost" \| "solid" \| "light"` | `"light"` | 样式 |
1221
- | `color` | `string` | — | 颜色 |
1597
+ | `color` | `string` | — | 预设色名(`blue` / `green` / `orange` / `red` / `purple` / `cyan` / `pink` / `gray`,走内容色板、跟随主题)或任意 CSS 颜色 |
1222
1598
  | `size` | `"small" \| "default" \| "large"` | `"default"` | 尺寸 |
1223
1599
  | `closable` | `boolean` | `false` | 可关闭 |
1224
1600
  | `avatar` | `React.ReactNode` | — | 头像 |
@@ -1386,6 +1762,75 @@ const data = [
1386
1762
  <Table columns={columns} dataSource={data} rowSelection selectedRowKeys={["张三"]} />
1387
1763
  ```
1388
1764
 
1765
+ #### 表头排序 / 筛选
1766
+
1767
+ 排序与筛选都配在**列定义**上,表头自动出箭头与漏斗:
1768
+
1769
+ ```tsx
1770
+ const columns: TableColumn[] = [
1771
+ { key: "name", title: "名称", dataIndex: "name" },
1772
+
1773
+ // sortable: true —— 不想写比较函数时的简写。数字按数值比, 其余按本地化字符串比
1774
+ { key: "age", title: "年龄", dataIndex: "age", sortable: true },
1775
+
1776
+ // sorter —— 自定义比较(入参是该列的取值, 不是整条记录)
1777
+ { key: "score", title: "分数", dataIndex: "score", sorter: (a, b) => a - b },
1778
+
1779
+ // sorter: true —— 只出按钮、不排本地数据; 排序发生在服务端, 拿到 onChange 后自己重新取数
1780
+ { key: "rank", title: "排名", dataIndex: "rank", sorter: true },
1781
+
1782
+ // defaultSortOrder —— 挂载即有序, 不必等用户点第一下
1783
+ { key: "city", title: "城市", dataIndex: "city", sortable: true, defaultSortOrder: "ascend" },
1784
+
1785
+ // 筛选: filters 给候选, 不给 onFilter 则退化成"该列值等于某个选中项"
1786
+ { key: "dept", title: "部门", dataIndex: "dept", filters: [{ text: "研发", value: "研发" }] },
1787
+
1788
+ // 给 onFilter 就自己判定命中(入参: 选中的值, 整条记录)
1789
+ {
1790
+ key: "tag", title: "标签", dataIndex: "tag", filterMultiple: false,
1791
+ filters: [{ text: "前端", value: "fe" }, { text: "后端", value: "be" }],
1792
+ onFilter: (value, record) => record.tag.startsWith(value),
1793
+ },
1794
+ ];
1795
+
1796
+ <Table
1797
+ columns={columns}
1798
+ dataSource={data}
1799
+ rowKey="name"
1800
+ onChange={(sorter, filters) => {
1801
+ // 服务端分页/排序时据此重新取数
1802
+ console.log(sorter); // { key: "age" | null, order: "ascend" | "descend" | null }
1803
+ console.log(filters); // { dept: ["研发"] }
1804
+ }}
1805
+ />
1806
+ ```
1807
+
1808
+ 行为约定:
1809
+
1810
+ - 排序三态循环:升 → 降 → 取消(表头箭头同步)。
1811
+ - **先筛后排**。反过来会把已被筛掉的记录也算进排序位置。
1812
+ - 列内多选取"或",跨列取"与"。
1813
+ - 单选(`filterMultiple: false`)点一条即生效并收起;多选要点面板里的"确定"才写回,中途关掉面板不生效。
1814
+ - 面板走 Portal + `position: fixed`(挂在 `th` 里会被表格容器的 `overflow` 裁掉),按视口夹取横向位置。
1815
+
1816
+ 列属性(`TableColumn`):
1817
+
1818
+ | 属性 | 类型 | 默认值 | 说明 |
1819
+ |---|---|---|---|
1820
+ | `key` | `string` | — | 列 key |
1821
+ | `title` | `React.ReactNode` | — | 表头 |
1822
+ | `dataIndex` | `string` | — | 取数字段,缺省用 `key` |
1823
+ | `width` | `number` | — | 列宽 |
1824
+ | `render` | `(value, record, index) => ReactNode` | — | 自定义单元格 |
1825
+ | `sorter` | `((a, b) => number) \| boolean` | — | 自定义比较(入参为该列取值);`true` 为服务端排序,不出本地排序 |
1826
+ | `sortable` | `boolean` | — | 用默认比较排序;与 `sorter` 二选一,同时给以 `sorter` 为准 |
1827
+ | `defaultSortOrder` | `"ascend" \| "descend"` | — | 初始排序方向 |
1828
+ | `filters` | `TableFilterOption[]` | — | 筛选候选;给了就出漏斗按钮 |
1829
+ | `onFilter` | `(value, record) => boolean` | — | 判定命中;不给则用"值相等" |
1830
+ | `filterMultiple` | `boolean` | `true` | `false` 为单选 |
1831
+
1832
+ 表格属性(`TableProps`):
1833
+
1389
1834
  | 属性 | 类型 | 默认值 | 说明 |
1390
1835
  |---|---|---|---|
1391
1836
  | `columns` | `TableColumn[]` | — | 列定义 |
@@ -1396,6 +1841,9 @@ const data = [
1396
1841
  | `bordered` | `boolean` | `false` | 边框 |
1397
1842
  | `striped` | `boolean` | `false` | 斑马纹 |
1398
1843
  | `rowSelection` | `boolean` | — | 行选择 |
1844
+ | `selectedRowKeys` | `(string \| number)[]` | — | 受控选中行 |
1845
+ | `onRowSelectionChange` | `(keys) => void` | — | 选中行变化 |
1846
+ | `onChange` | `(sorter, filters) => void` | — | 排序/筛选变化 |
1399
1847
 
1400
1848
  ### Modal 模态框
1401
1849
 
@@ -1498,6 +1946,8 @@ const menu = [
1498
1946
  | `position` | `Position` | `"bottom"` | 弹出位置 |
1499
1947
  | `onClickItem` | `(key: string) => void` | — | 菜单项点击 |
1500
1948
 
1949
+ **菜单是就地渲染的,不走 Portal**(触发框内 `position: absolute` / `top: 100%`,`minWidth: 140`,**高度不设限**)。所以触发框到根的祖先链上**任何**一层 `overflow: hidden` 都会把菜单切掉;菜单本身也会盖住下方内容,塞进矮条带时请确认下方有足够空间。需要"限高 + 滚动"的话,`Select` 有 `dropdownVisibleCount`(按每项 30px 折算),`Dropdown` 没有对应 prop。详见「毛玻璃」一节的**使用前提 2**。
1950
+
1501
1951
  ### Popover 气泡卡片
1502
1952
 
1503
1953
  ```tsx
@@ -1607,10 +2057,13 @@ import { Calendar } from "knight-ui";
1607
2057
 
1608
2058
  | 属性 | 类型 | 默认值 | 说明 |
1609
2059
  |---|---|---|---|
1610
- | `mode` | `"month" \| "year" \| "day"` | `"month"` | 视图模式 |
2060
+ | `value` | `Date` | — | 选中日期(受控) |
2061
+ | `isToday` | `(date) => boolean` | — | 自定义"今天"的判定 |
1611
2062
  | `disabledDate` | `(date) => boolean` | — | 禁用日期 |
1612
2063
  | `onChange` | `(date: Date) => void` | — | 变更回调 |
1613
2064
 
2065
+ **没有视图切换**:只有月网格一种视图,没有 `mode`(`day` / `month` / `year`)这个 prop。它曾经存在并写进过本文档,但组件内部从未读取,全仓(含测试)也没有任何一处传过 —— 属于"传了不生效、却让人以为有"的那类属性,已删除。要做年/月视图,第一步是加渲染分支,不是先补一个 prop。
2066
+
1614
2067
  ### Carousel 轮播
1615
2068
 
1616
2069
  ```tsx
@@ -1692,19 +2145,24 @@ import { Highlight } from "knight-ui";
1692
2145
  ```tsx
1693
2146
  import { Cropper } from "knight-ui";
1694
2147
 
1695
- <Cropper src="https://picsum.photos/600/400" cropShape="rect" aspectRatio={16 / 9}
2148
+ <Cropper src="https://picsum.photos/600/400" cropShape="rect"
1696
2149
  onCrop={(result) => console.log(result)} />
1697
2150
 
1698
- <Cropper src="/avatar.jpg" cropShape="round" aspectRatio={1} />
2151
+ <Cropper src="/avatar.jpg" cropShape="round" />
1699
2152
  ```
1700
2153
 
1701
2154
  | 属性 | 类型 | 默认值 | 说明 |
1702
2155
  |---|---|---|---|
1703
2156
  | `src` | `string` | — | 图片地址 |
1704
- | `cropShape` | `"rect" \| "round"` | `"rect"` | 裁剪形状 |
1705
- | `aspectRatio` | `number` | — | 裁剪比例 |
2157
+ | `cropShape` | `"rect" \| "round"` | `"rect"` | 裁剪形状,`round` 时按裁剪区中心与内切圆取圆 |
1706
2158
  | `onCrop` | `(result: CropResult) => void` | — | 裁剪回调 |
1707
2159
 
2160
+ **四个角 + 四条边共八个手柄:中心恒为白色、外圈是主色**(`2px solid var(--ku-color-primary)`),另有一层 `0 1px 3px rgba(0,0,0,.5)` 的投影兜底。裁剪框本身不描边——交界处的"连线"去掉后,裁剪区由四周那四块蒙版的明暗对比界定,视觉重量全部落到手柄上,框线就成了多余的第二重轮廓。
2161
+
2162
+ > **中心为什么写死白色、不跟主题翻转。** 手柄压在**被裁剪的那张图**上——任意底色,与主题无关。之前取的是 `--ku-color-text-0`,而那个 token 随主题翻转(暗 `#f6f7f9` / 亮 `#1c1f23`),于是亮色主题下中心从近白翻成近黑,在照片上立不住了。这里用的是**色板层**的 `rgb(var(--ku-white))` 而不是语义层,正因为它不该随主题变。主色外圈在两个主题下本来就同值,不动。
2163
+
2164
+ 裁剪框可以自由拖拽改变形状,**没有"锁死宽高比"的能力**(比如裁 1:1 头像)。不是漏了:内部用 0~1 的百分比坐标、x/y 还各自按图宽/图高归一化,这个空间是各向异性的,锁比例要先拿到图片的显示宽高比才能换算——真要做需要先改内部 resize 函数的签名。
2165
+
1708
2166
  ### UserGuide 用户引导
1709
2167
 
1710
2168
  ```tsx
@@ -1722,6 +2180,10 @@ const steps = [
1722
2180
  />
1723
2181
  ```
1724
2182
 
2183
+ **引导卡片是不透明面(`.ku-userguide-card` → `--ku-color-bg-3`),不是玻璃。** 这是全库唯一一处**刻意不挂玻璃**的浮层,原因和使用前提 3 的"玻璃不要嵌套"是同一族但更隐蔽:遮罩**挖了目标的洞**(`Mask.renderHole`),而卡片与遮罩是兄弟节点,于是同一个元素有两种底色——卡片压在洞外,身后是"遮罩压暗过的页面";压在洞内,身后是**没压暗的原始页面**(可以是一块近黑的 Monaco)。60% 的白膜盖不住这个差(实测把 alpha 抬到 .84,落在近黑与纯白上仍差 ΔE≈12),表现就是**点"下一步"时面板颜色偶尔跳变**。改用不透明面即 README 自己给的逃生口(使用前提 3 的后半句:"或把内层换成不透明的实色面")。`bg-3` 一条声明两个主题都对:卡片坐的是**遮罩压过之后的**页面,亮色 `#e0e2e5` 比压过的页面(`#c2`–`#d1`)亮 15–30/255,暗色 `#1a1f2b` 比压过的页面(`#0c`–`#15`)明显更亮——两侧都读得出"浮在遮罩之上"。`--ku-shadow-lg` 与 1px 描边保留不动,附带省掉一次全屏 `blur` 的合成开销。
2184
+
2185
+ > 这**不是** `.ku-glass-overlay` 本身的问题:`Modal` / `SideSheet` 也用它,但它们的遮罩**没有洞**,卡片身后恒为"遮罩压过的页面",玻璃是稳的。问题只在"玻璃身后同时存在挖洞与不挖洞两种区域"。
2186
+
1725
2187
  ### VChart 图表
1726
2188
 
1727
2189
  ```tsx
@@ -1743,7 +2205,7 @@ const data = [
1743
2205
  | `type` | `"line" \| "bar" \| "pie" \| "area" \| "scatter"` | — | 图表类型 |
1744
2206
  | `data` | `{ label: string; value: number }[]` | — | 数据 |
1745
2207
  | `width / height` | `number` | — | 宽高 |
1746
- | `colors` | `string[]` | — | 颜色数组 |
2208
+ | `colors` | `string[]` | — | 颜色数组(原样使用);不传则按内容色板的 6 个色相轮转,跟随主题 |
1747
2209
 
1748
2210
  ### OverflowList 溢出列表
1749
2211
 
@@ -1805,6 +2267,15 @@ const { success, error } = Toast.useToast();
1805
2267
  success("保存成功");
1806
2268
  ```
1807
2269
 
2270
+ **堆叠(纸摞)**:`stack: true` 时同一批 Toast 不并排铺开,而是叠成一张纸摞——最新的一条在最上层,后面几条各在下方露出 6px 的"纸边"、并逐层缩小 5%,最多露出 3 层。鼠标移上去展开成完整列表(最新的仍在最上面)。
2271
+
2272
+ ```tsx
2273
+ Toast.open({ content: "第一条消息", stack: true });
2274
+ Toast.open({ content: "第二条消息", stack: true });
2275
+ ```
2276
+
2277
+ 只要这一批里有任意一条带了 `stack: true`,整批都按纸摞渲染——同一个容器里没法同时既摞着又并排铺开。收起时超出 3 层的卡片只是透明,**不再接受点击**(它们会落在屏幕中间那片空白上)。
2278
+
1808
2279
  ### Notification 通知(命令式)
1809
2280
 
1810
2281
  ```tsx
@@ -1830,19 +2301,31 @@ Notification.destroyAll();
1830
2301
  ```tsx
1831
2302
  import { Banner } from "knight-ui";
1832
2303
 
2304
+ // 非受控:点击横幅主体即自行关闭
1833
2305
  <Banner
1834
- visible
1835
2306
  type="warning"
1836
2307
  title="系统维护通知"
1837
2308
  description="系统将于今晚 22:00-02:00 进行维护升级"
1838
2309
  onClose={() => console.log("closed")}
1839
2310
  />
1840
2311
 
1841
- <Banner visible type="success" title="操作成功" fullMode bordered
2312
+ // 受控:点击只上报,由父组件改 visible 来关
2313
+ <Banner visible={this.state.tip} type="success" title="操作成功" fullMode bordered
2314
+ onClose={() => this.setState({ tip: false })}
1842
2315
  actions={<Button type="primary" size="small">查看详情</Button>}
1843
2316
  />
1844
2317
  ```
1845
2318
 
2319
+ **关闭方式**:没有关闭按钮,**点击横幅主体任意位置**即触发。是否可关闭由 `closable` 控制(默认 `true`),与有没有传 `onClose` 无关;`onClose` 只负责在这时候收到通知:
2320
+
2321
+ | 传参 | 行为 |
2322
+ |---|---|
2323
+ | `closable`(默认)+ 不传 `visible`(非受控) | 触发 `onClose?.()` 并自行播放关闭动画后隐藏 |
2324
+ | `closable` + 传 `visible`(受控) | 只触发 `onClose?.()`,隐藏与否交给父组件(下一帧把 `visible` 置 `false`) |
2325
+ | `closable={false}` | 静态横幅:光标不是手型,点击无反应 |
2326
+
2327
+ `actions` 区域内的点击不会冒泡到横幅主体,所以按钮能正常点击、不会误关横幅。
2328
+
1846
2329
  | 属性 | 类型 | 默认值 | 说明 |
1847
2330
  |---|---|---|---|
1848
2331
  | `type` | `"info" \| "success" \| "warning" \| "danger"` | `"info"` | 类型 |
@@ -1850,7 +2333,9 @@ import { Banner } from "knight-ui";
1850
2333
  | `description` | `React.ReactNode` | — | 描述 |
1851
2334
  | `fullMode` | `boolean` | — | 通栏模式 |
1852
2335
  | `bordered` | `boolean` | — | 边框 |
1853
- | `actions` | `React.ReactNode` | — | 操作区 |
2336
+ | `actions` | `React.ReactNode` | — | 操作区,其内部点击不会关闭横幅 |
2337
+ | `closable` | `boolean` | `true` | 是否可关闭(点击横幅主体关闭);`false` 为纯静态横幅 |
2338
+ | `onClose` | `() => void` | — | 关闭时回调 |
1854
2339
 
1855
2340
  ### Progress 进度条
1856
2341
 
@@ -1980,7 +2465,6 @@ import { CodeEditor } from "knight-ui";
1980
2465
  defaultValue="console.log('hello');"
1981
2466
  language="javascript"
1982
2467
  height={300}
1983
- theme="vs-dark"
1984
2468
  onChange={(value) => console.log(value)}
1985
2469
  />
1986
2470
 
@@ -1990,7 +2474,7 @@ import { CodeEditor } from "knight-ui";
1990
2474
  | 属性 | 类型 | 默认值 | 说明 |
1991
2475
  |---|---|---|---|
1992
2476
  | `language` | `string` | — | 语言 |
1993
- | `theme` | `"vs-dark" \| "light" \| "hc-black"` | `"vs-dark"` | 主题 |
2477
+ | `theme` | `"vs-dark" \| "light" \| "hc-black"` | `"vs-dark"` | Monaco 主题;`vs-dark` 为 VS 自带暗色,**不随本库明暗主题变化**(见「代码面」一节) |
1994
2478
  | `readOnly` | `boolean` | `false` | 只读 |
1995
2479
  | `height / minHeight` | `number` | — | 高度 |
1996
2480
  | `showCopyBtn / showFormatBtn` | `boolean` | `true` | 复制/格式化按钮 |
@@ -2014,7 +2498,7 @@ const code = `function hello() {
2014
2498
  |---|---|---|---|
2015
2499
  | `code` | `string` | — | 代码文本 |
2016
2500
  | `language` | `string` | — | 语言 |
2017
- | `theme` | `"vs-dark" \| "light" \| "hc-black"` | `"vs-dark"` | 主题 |
2501
+ | `theme` | `"vs-dark" \| "light" \| "hc-black"` | `"vs-dark"` | Monaco 主题;`vs-dark` 为 VS 自带暗色,**不随本库明暗主题变化**(见「代码面」一节) |
2018
2502
  | `showLineNumbers` | `boolean` | — | 行号 |
2019
2503
  | `copyable` | `boolean` | `true` | 可复制 |
2020
2504
 
@@ -2031,9 +2515,9 @@ import { JsonViewer } from "knight-ui";
2031
2515
  | 属性 | 类型 | 默认值 | 说明 |
2032
2516
  |---|---|---|---|
2033
2517
  | `value` | `string \| object` | — | JSON 内容 |
2034
- | `readOnly` | `boolean` | `false` | 只读 |
2035
- | `height` | `number` | — | 高度 |
2036
- | `theme` | `"vs-dark" \| "light"` | `"vs-dark"` | 主题 |
2518
+ | `readOnly` | `boolean` | `true` | 只读 |
2519
+ | `height` | `number` | `200` | 高度 |
2520
+ | `theme` | `"vs-dark" \| "light"` | `"vs-dark"` | Monaco 主题;**不随本库明暗主题变化** |
2037
2521
  | `onChange` | `(value: string) => void` | — | 变更回调 |
2038
2522
 
2039
2523
  ### Chat 聊天
@@ -2042,23 +2526,59 @@ import { JsonViewer } from "knight-ui";
2042
2526
  import { Chat } from "knight-ui";
2043
2527
 
2044
2528
  const messages = [
2045
- { id: "1", content: "你好!", role: "user", timestamp: Date.now() },
2046
- { id: "2", content: "你好,有什么可以帮助你的?", role: "assistant", timestamp: Date.now() },
2529
+ { id: "1", role: "user", name: "我", time: "10:24", content: "你好!" },
2530
+ { id: "2", role: "assistant", name: "AI助手", time: "10:25", content: "你好,有什么可以帮助你的?" },
2047
2531
  ];
2048
2532
 
2049
- <Chat
2050
- dataSource={messages}
2533
+ // 默认:头像 / 名称 / 气泡 / 表情按钮 / 自动长高的输入框
2534
+ <Chat dataSource={messages} showInput
2051
2535
  onSend={(content) => console.log("send:", content)}
2052
- placeholder="输入消息..."
2053
- />
2536
+ placeholder="输入消息..." />
2537
+
2538
+ // 微信风:只有气泡,不带头像与名称
2539
+ <Chat dataSource={messages} showAvatar={false} showName={false} showInput onSend={send} />
2540
+
2541
+ // 长文 / Markdown 正文:不要气泡底板,并显示时间
2542
+ <Chat dataSource={messages} showBubble={false} showAvatar={false} showTime showInput onSend={send} />
2054
2543
  ```
2055
2544
 
2545
+ `ChatMessage`:`{ id?, role: "user" | "assistant" | "system", content, avatar?, name?, time?, status? }`。
2546
+ `avatar` 给图片地址(不给则用 `name` 首字,再退到角色的 `A` / `U`);`status` 为 `"sending"` 时尾部加省略号,为 `"error"` 时加一个可点的重试图标(配合 `onRetry`)。
2547
+
2056
2548
  | 属性 | 类型 | 默认值 | 说明 |
2057
2549
  |---|---|---|---|
2058
- | `dataSource` | `ChatMessage[]` | — | 消息列表 |
2059
- | `loading` | `boolean` | `false` | 加载中 |
2060
- | `showInput` | `boolean` | `true` | 显示输入框 |
2550
+ | `dataSource` | `ChatMessage[]` | `[]` | 消息列表 |
2551
+ | `renderMessage` | `(msg, index) => ReactNode` | — | 完全自定义单条消息的渲染(给了它就不再走内置头像/名称/气泡) |
2552
+ | `loading` | `boolean` | `false` | 加载中(显示三点气泡) |
2553
+ | `emptyContent` | `ReactNode` | — | 无消息时的占位 |
2554
+ | `showInput` | `boolean` | `false` | 显示输入框 |
2061
2555
  | `onSend` | `(content: string) => void` | — | 发送回调 |
2556
+ | `onRetry` | `(msg, index) => void` | — | `status="error"` 时点重试图标的回调 |
2557
+ | `placeholder` | `string` | `"输入消息..."` | 输入框占位 |
2558
+ | `sendText` | `string` | `"发送"` | 发送按钮文案 |
2559
+ | `showAvatar` | `boolean` | `true` | 显示头像 |
2560
+ | `showName` | `boolean` | `true` | 显示名称(`msg.name`) |
2561
+ | `showBubble` | `boolean` | `true` | 显示气泡底板;`false` 时内容直接铺在面板上并放宽到整行,适合长文 / Markdown 正文 |
2562
+ | `showTime` | `boolean` | `false` | 显示时间(`msg.time`) |
2563
+ | `rows` | `number` | `1` | 输入框初始行数(最小高度) |
2564
+ | `maxRows` | `number` | `5` | 输入框最多撑到几行,超出后框内滚动 |
2565
+ | `showEmoji` | `boolean` | `true` | 输入框左下角的表情按钮与表情面板(仅 `showInput` 时有效) |
2566
+
2567
+ **输入框**:多行 `textarea`,在 `rows` ~ `maxRows` 行之间随内容自动长高,越过 `maxRows` 后框内竖直滚动(`overflow-y: auto`)。`Enter` 发送、`Shift+Enter` 换行、`Esc` 关掉表情面板。发送按钮在输入框**右下角**,只有文字、没有图标——「发送」两个字已经把动作说全了,再加一个箭头/纸飞机是同一句话说两遍;没有内容时压成浅底并禁用。表情按钮与发送键**等高 26px**,图标 16px——一左一右站在同一条基线上,差几像素就会显歪;表情按钮悬停**只提亮图标本身**(`color` 切到 `text-0`;面板开着时回落成主色),不铺底块——与播放器那排按钮同一条规矩,底块会把右下角这块留白切成两个方块。表情面板走 Portal 渲染到 `body`——面板自身带 `overflow: hidden`,就地渲染会被裁掉;这也让它能挂 `.ku-glass-tip` 而不与面板的玻璃底嵌套。点中一个表情后面板**自动收起**(微信/QQ 的同一手感),要连插再点一次按钮即可。
2568
+
2569
+ 输入框的 `textarea` 必须显式写 `backgroundColor: transparent`:它的 UA 默认底是白的(`field`),不撤掉就是一块白板压在玻璃面上。
2570
+
2571
+ 越过 `maxRows` 之后的滚动条跟库里其余滚动区一致:挂 `.ku-scrollbar` 类(4px 细条、滑块 `--ku-color-fill-2`)+ 内联 `scrollbarWidth: "thin"` / `scrollbarColor`(Firefox 侧)。UA 默认那条灰白粗槽压在玻璃输入框上非常扎眼。
2572
+
2573
+ > **输入框的字比气泡大一号**:textarea 走 `--ku-font-size-lg`(14px),气泡仍是 `--ku-font-size-base`(12px)。气泡是"读"的、输入框是"写"的,写字的地方字大一点更顺手;只动输入框,气泡不变。
2574
+ >
2575
+ > 行高常量 `INPUT_LINE_HEIGHT`(现为 24,随字号从 22 一起来)**必须与 `taStyle.lineHeight` 保持一致**——`maxRows` 是按它折算成像素上限的(见 `Chat.tsx` 里 `resizeInput()` 与那个常量的注释)。两处改一处会让"长到几行就该出滚动条"失准。
2576
+
2577
+ 表情/发送那一行是 `boxStyle` **普通流**里的孩子,不是绝对定位——绝对定位要靠一个写死的 `padding-bottom` 给它让位,而那个数字得跟着按钮高度一起改,忘一次就变成文字压在按钮上。
2578
+
2579
+ **名字 / 时间靠哪一边,取决于气泡在不在,不只看角色**:有气泡时 `contentColStyle` 是贴着自己那条气泡收边的(上限 75%,实际宽度 = 内容宽),靠右对齐才是「贴在这条消息的右下角」,这就是按角色分左右。关掉气泡(`showBubble={false}`,长文 / Markdown 正文)后那一列是**整行**宽的(`maxWidth: 100%` + `flex: 1 1 auto`),再靠右对齐就不是贴着消息尾部,而是被甩到整行的最右边——上一条助手消息的时间在左、这条用户消息的时间在最右,两条时间左右横跳,看着像错位。所以无气泡时一律左对齐,跟正文的起头对齐。
2580
+
2581
+ **输入区的两层半透**:底栏(`--ku-color-fill-translucent`,0.03)与输入框(`--ku-color-fill-translucent-hover`,0.06)用的是 `translucent` 那一对 token 的浓度差,不是 hover 语义——输入框的底色不该跟着鼠标变。两者同族但差一档,读起来是「一条底栏里沉着一口井」,而不是同一层加一圈边框;整块 composer 保持半透,玻璃面板从底下透上来。
2062
2582
 
2063
2583
  ### AudioPlayer 音频播放器
2064
2584
 
@@ -2077,6 +2597,8 @@ import { AudioPlayer } from "knight-ui";
2077
2597
  | `theme` | `"dark" \| "light"` | `"dark"` | 主题 |
2078
2598
  | `volume` | `number` | — | 音量 0-1 |
2079
2599
 
2600
+ **播放条本体就是进度条**(见下面「播放器:进度体」)。`AudioPlayerState` 里带 `buffered`,自定义 `controls` 拿到的 state 里也有。
2601
+
2080
2602
  ### VideoPlayer 视频播放器
2081
2603
 
2082
2604
  ```tsx
@@ -2094,6 +2616,50 @@ import { VideoPlayer } from "knight-ui";
2094
2616
  | `loop` | `boolean` | `false` | 循环 |
2095
2617
  | `muted` | `boolean` | — | 静音 |
2096
2618
 
2619
+ **全屏**:`toggleFullscreen()` 对**容器**(`.ku-video-player`)请求全屏。容器与 `video` 的 `width/height` 都是内联样式,内联优先级高于 UA 给 `:fullscreen` 的那套 `width/height: 100%`,所以两者都要按 `isFullscreen` 显式让位(容器 `width/height: 100%` + 去掉圆角,`video` 高度 `100%`)——否则全屏的只是一个"固定宽高、圆角黑框"的容器,画面仍旧只有 `height` 那么高。
2620
+
2621
+ **播放器:进度体**
2622
+
2623
+ `VideoPlayer` 的功能栏与 `AudioPlayer` 的播放条**本体就是进度条**——进度不再是旁边单独的一条 4px 细线,而是铺满整条控件的三层底色:
2624
+
2625
+ | 层 | 颜色 | 说明 |
2626
+ |---|---|---|
2627
+ | 未加载 | 不画 | 让控件自己的底(视频侧是玻璃 + 过渡色,音频侧是 `bg-2`)透出来当轨道,"已加载"才有参照物 |
2628
+ | 已加载 | 视频 `rgba(255,255,255,0.22)` / 音频 `rgba(255,255,255,0.12)` | 与已播放段分明两种颜色;两档不同是因为底不同——视频侧压在玻璃 + 深色渐变上,音频侧压在 `bg-2` 上 |
2629
+ | 已播放 | `--ku-color-primary` + `opacity: 0.72` | 不压实色,让底下的画面/玻璃透一点上来 |
2630
+
2631
+ 已播放段用的是 `opacity` 而不是"带 alpha 的主色":单色填充上两者等价,但 `opacity` 不用为「主色的半透版」再造一个 token,也不会跟 `--ku-color-primary` 的改动脱节。
2632
+
2633
+ **控件行**:按钮 26×26、图标统一 16px(`size="extraLarge"`),功能栏/播放条高 34 / 40。倍速那一格是**文字**不是图标,按图标那一档(12px)排会明显比左右的图标小一圈,所以单独提到 `--ku-font-size-md`(13px)、`minWidth: 30`,才跟 16px 的图标视觉等重。悬停**只提亮图标本身**(`color` 切到主色 + `opacity` 0.85→1),不铺底块——底块是"方形按钮"的读法,而这排按钮只隔 4px,几块底色连起来就是一条色带,比方框本身更抢眼。图标没给 `color` 的一律走 `currentColor`,所以改 `button` 的 `color` 就够了(几个手写的 `<svg>` 也一律 `fill="currentColor"`)。
2634
+
2635
+ `VideoPlayer` 的时间读数在 4px 的行间距之外再让开 4px(`marginLeft` 走 `--ku-spacing-sm`):紧贴快进键时 `⏩ 00:00 / 00:56` 会读成一句话、被当成按钮的一部分,让开之后它才是独立的一段读数。
2636
+
2637
+ 两个播放器的**音量 / 倍速气泡是同一套**:都挂 `.ku-glass-tip`,同样的 `1px var(--ku-color-border)` 边框、`--ku-radius-md`、`--ku-shadow-sm`、`padding: 4px 0`,菜单项悬停色统一走 `--ku-color-fill-translucent-hover`(不再是独一份的 `rgba(255,255,255,0.1)`),位置都是 `bottom: 100%` + `left: 50%` + `translateX(-50%)` 居中弹在自己那个按钮上方。倍速面板的尺寸也逐字对齐(`minWidth: 64` + 菜单项 `padding: 5px 14px`,触发按钮 `minWidth: 30` + `padding: 0 4px`)——两处原来一个 56 一个 64、一个 `0 2px` 一个 `0 4px`,摆在一起是同一份 `SPEED_OPTIONS` 读成了两套东西。音量 / 倍速两个按钮也都排在控件行的**右侧**(`spacer` 之后),与 `AudioPlayer` 的次序一致。
2638
+
2639
+ **音量条可以拖、可以滚轮**(两个播放器同一套):
2640
+
2641
+ - **拖动**——与进度条同一套 `pointerdown` / `pointermove` / `pointerup` + `setPointerCapture`,纵坐标折成 0-1(条子底端是 0,所以是 `1 - (y - top) / height`)。
2642
+ - **滚轮**——每格固定 ±5%,**不按 `deltaY` 的原始值**:不同鼠标一格 `deltaY` 从 1 到 120 都有,直接用会一格从 0 蹦到爆表。滚轮上滚到 0 以上会自动解除静音(写回时 `muted = (音量 === 0)`)。
2643
+ - **滚轮必须挂原生监听**(`addEventListener("wheel", …, { passive: false })`),不能用 `onWheel`:React 19 在根节点上把 `wheel` 注册成 passive,`onWheel` 里调 `preventDefault()` 不生效(只打一条警告),结果是音量变了、页面也跟着滚了。气泡是条件渲染的,所以用一个回调 `ref` 在挂载/卸载时接上/摘掉。
2644
+ - 视觉上还是 4px 宽的一条竖线,但外面套了一层 **20px 宽的透明热区**承担交互:4px 的横向目标拿鼠标去戳太细,套上之后左右各 8px 内都算落在音量条上。
2645
+
2646
+ 面板里的文字走 `--ku-color-text-1`、音量条轨道走 `--ku-color-fill-2`、填充走主色,两边也一样。`VideoPlayer` 侧**刻意不额外压一层深色渐变**:全局底色是给**页面**上的玻璃标定的,气泡浮到画面上确实会更难读一点,但两个播放器的同一块面板必须是同一种质感——一边压黑一边留白,摆在一起就是两个组件。真要压暗也该是这套 token 一起调,而不是视频侧自己加料。
2647
+
2648
+ **点击空隙即跳转,不用 `stopPropagation` 兜**:控件行整行 `pointer-events: none`,只有按钮 / 音量 / 倍速各自打回 `auto`。于是点在按钮上就是按钮,点在按钮之间的空隙上自然落到下面的进度层——比给每个按钮挂 `stopPropagation` 省事,也不会漏掉以后新加的按钮。播放/时间文字要抬到填充之上(`z-index`),否则会被自己前面那段主色糊掉。
2649
+
2650
+ **进度可以按住拖动**:进度层挂的是 `pointerdown` / `pointermove` / `pointerup` / `pointercancel` 而不是 `click`。只挂 `click` 时"按下就跳、想微调只能一次次点",按住拖动毫无反应——这就是"进度无法通过滑动调整"的根因。三个要点:
2651
+
2652
+ 1. `pointerdown` 里 `setPointerCapture(e.pointerId)`。捕获之后手滑出进度条、乃至滑出播放器之外,`move` / `up` 也照旧送到这个元素上,松手才断;不捕获的话拖出条外事件就没了,得把指针挪回来按住才能接着拖,手感是断的。
2653
+ 2. `e.preventDefault()` 挡掉拖拽引发的文本选中(拖到时间文字上会把它选中)。
2654
+ 3. 进度层加 `touch-action: none`——不写这个,手机上拖进度会变成滚屏。
2655
+
2656
+ 拖动中的标志位放**实例字段**而不是 `state`:它只影响事件处理、不参与渲染,进 `state` 每动一下都白渲一次。
2657
+
2658
+ **进度层的层级关系,两个播放器方向相反**:
2659
+
2660
+ - `AudioPlayer` 的播放条不透明、没有 `backdrop-filter`,所以进度层是控件的**子节点**,额外带 `overflow: hidden` + 同款圆角把填充裁在圆角里。**这个 `overflow` 不能挪到外层**——音量/倍速气泡是 `bottom: 100%` 往上弹的,外层一裁就把气泡的头削掉。
2661
+ - `VideoPlayer` 的功能栏挂了 `.ku-glass-overlay`,于是**玻璃底必须拆成控制行的兄弟层**,不能继续当它的父节点。因为 `backdrop-filter` 会自成一个 backdrop root,它所有后代的 backdrop 就只剩"这一层画了什么";音量/倍速气泡往上弹出功能栏之外,那里功能栏什么都没画,气泡的毛玻璃会退化成一层半透色块直接压在**没有虚化的**画面上,比原来的不透明气泡还难看。拆成兄弟后控制行不在 backdrop root 里,气泡的 backdrop 重新是画面本身,毛玻璃才是真的。层序由 `z-index` 定:玻璃层在下(整片 `pointer-events: none`),控制行在上(`z-index: 1`)。
2662
+
2097
2663
  ### DragMove 拖拽移动
2098
2664
 
2099
2665
  ```tsx
@@ -2104,7 +2670,7 @@ import { DragMove } from "knight-ui";
2104
2670
  bounds={{ left: 0, top: 0, right: 500, bottom: 500 }}
2105
2671
  onDragEnd={(pos) => console.log(pos)}
2106
2672
  >
2107
- <div style={{ width: 100, height: 100, background: "#8b5cf6", cursor: "move" }}>
2673
+ <div className="ku-glass-tip" style={{ padding: "8px 16px", cursor: "move" }}>
2108
2674
  拖拽我
2109
2675
  </div>
2110
2676
  </DragMove>
@@ -2182,10 +2748,19 @@ import { AIChatInput } from "knight-ui";
2182
2748
  | `value / defaultValue` | `string` | — | 输入值 |
2183
2749
  | `placeholder` | `string` | — | 占位符 |
2184
2750
  | `loading` | `boolean` | `false` | 加载中 |
2185
- | `minRows / maxRows` | `number` | — | 行数范围 |
2751
+ | `minRows` | `number` | `2` | 最小(初始)行数,不足时留白;被 `style` 撑高也不塌 |
2752
+ | `maxRows` | `number` | `4` | 最大行数,超出后框内竖直滚动 |
2186
2753
  | `attachments` | `Attachment[]` | — | 附件列表 |
2187
2754
  | `onSend` | `(value, attachments) => void` | — | 发送回调 |
2188
2755
 
2756
+ > **面**:面板挂 `.ku-glass`(容器档)。它是一个**面板**——自带内边距,里面装着 textarea + 附件条 + 发送键——不是 `Input` 那种单字段控件;单字段控件继续走不透明的 `fill-0`(见 `input/Input.tsx`),两者的分野在这里。内部 textarea 本来就是透明的,附件标签走不透明的 `fill-1`(在玻璃面上是**更亮**的一档,读起来是一枚筹码)。
2757
+ >
2758
+ > 内边距 `10px 12px`,**横向略大于纵向**:纵向那 10px 会跟 textarea 下面 20px 的垫高叠在一起(发送键就落在右下角那个垫高层里),再大就把键从右下角推离边框;横向是文字起笔的位置,12px 才不显得贴在框上。附件条的横向缩进交给外框提供——它原来自己带 12px,于是比 textarea 多缩进一层、两个左边缘对不齐,现在只留底边距把附件和正文隔开。
2759
+ >
2760
+ > `minRows` 默认 `2`、`maxRows` 默认 `4`:即初始两行,最多长到四行,再超就在框内滚动。要更宽松的书写区就自己传 `maxRows`(`minRows` 与它拉开几行才有余地)。
2761
+ >
2762
+ > **发送键的纸飞机是垂直向上的**:`<IconSend size="small" rotate={-45} />`。Semi 的 `Send` 路径是一枚指向**右上**的纸飞机(机头顶点相对 24×24 中心约在水平上方 49°),逆时针转 45° 把它抬到约 94°,即基本正上方;`Icon` 的 `rotate` 是在包装 `<span>` 上输出 `transform: rotate(Ndeg)`、绕自身中心旋转(调用方传的 `transform` 会覆盖它)。要绝对精确的话值是 `-49`,取 `-45` 是干净值且误差 4°,肉眼不可辨。
2763
+
2189
2764
  ### AIChatDialogue AI 对话展示
2190
2765
 
2191
2766
  ```tsx
@@ -2212,11 +2787,13 @@ const messages = [
2212
2787
  | `userAvatar / assistantAvatar` | `string` | — | 头像 |
2213
2788
  | `userNickname / assistantNickname` | `string` | — | 昵称 |
2214
2789
  | `maxHeight` | `number` | — | 最大高度 |
2790
+ | `showAssistantBorder` | `boolean` | `false` | 是否给助手回复内容加边框(含内边距与圆角) |
2215
2791
  | `showThinking` | `boolean` | `true` | 是否显示"思考过程"块 |
2216
2792
  | `thinkingTitle` | `React.ReactNode` | `"思考过程"` | 思考块标题 |
2217
2793
  | `thinkingDefaultCollapsed` | `boolean` | `false` | 思考块默认是否收起 |
2218
2794
  | `thinkingBodyMaxHeight` | `number` | `320` | 思考正文最大高度(px),超出内部滚动 |
2219
- | `onCopy` | `(msg) => void` | — | 复制回调 |
2795
+ | `onCopy` | `(msg) => void` | — | 复制回调(复制按钮仅在传入时出现) |
2796
+ | `copyTip` | `React.ReactNode \| false` | `"已复制"` | 复制后的 Toast 文案;传 `false` 关闭提示 |
2220
2797
  | `onRetry` | `(msg, index) => void` | — | 重试回调 |
2221
2798
 
2222
2799
  > **AIMessage 思考内容支持(assistant 专用)**:`thinkingContent?: React.ReactNode` 思考/推理内容(Markdown 字符串或节点),展示为消息上方可折叠的灰色思考块;`thinkingTokens?: number` 真实 token 数(缺省按 ~3.5 字符/token 估算);`thinkingDurationMs?: number` 思考耗时(ms),完整消息可传,流式思考期间由组件自动计时无需传入。
@@ -2230,6 +2807,11 @@ const messages = [
2230
2807
  > />
2231
2808
  > ```
2232
2809
 
2810
+ > **复制反馈**:点"复制"会在调用 `onCopy` 之后弹 `Toast.success("已复制")`(文案用 `copyTip` 改,传 `copyTip={false}` 关掉,反馈交回调用方)。
2811
+ > **组件自己不碰剪贴板**——`content` 是 `React.ReactNode`,可能是节点而不是文本,"该拷什么"只有调用方说得清,所以写入始终发生在 `onCopy` 里(见上方示例)。也正因如此,这个提示代表的是"复制动作已发出",**不是组件核实过的结果**:`onCopy` 里若是异步写入且被浏览器拒绝,组件无从得知。要拿到真结果,就得把组件改成自己写剪贴板,并把 `content` 收窄成 `string`。
2812
+
2813
+ > **面**:面板挂 `.ku-glass`(容器档)。**面板内的 Markdown 必须按平**——`Markdown` 的根节点自己就是一块玻璃面(`.ku-glass`),两层玻璃相叠既违反本库"不嵌套玻璃"的规矩(内层的 backdrop 只能糊到外层已经画出来的那层半透底,糊出来还是同一片),又让每一条助手消息白付一次整块面积的 `backdrop-filter`。组件用**内联** `backdropFilter: none` 就地关掉它(内联优先级高于类),与本来就有的 `backgroundColor: transparent` 一起把 Markdown 还原成"只有内容"。正文与思考正文两处都走同一个 `FLAT_MARKDOWN_STYLE`。
2814
+
2233
2815
  ### Sidebar AI 侧栏
2234
2816
 
2235
2817
  ```tsx
@@ -2256,6 +2838,10 @@ const items = [
2256
2838
  | `width / collapsedWidth` | `number` | — | 宽度/折叠宽度 |
2257
2839
  | `header / footer` | `React.ReactNode` | — | 头部/底部 |
2258
2840
 
2841
+ > **面**:侧栏挂 `.ku-glass`(容器档),与应用外壳的 `Layout.Sider` 是同一块面——区别只在外壳那档是由**调用方**传 `className="ku-glass"` 玻璃化的(`Layout.Sider` 组件自己不写底),这里内建,让它在任何地方都不掉档。
2842
+ >
2843
+ > **条目的悬停底必须换档**:原来是 `fill-0`(`#14171e`),而它原本不透明的底是 `bg-2`(`#14161f`)——**两色几乎同值,悬停等于没反馈**(与 `.ku-select-option` 当初是同一个 bug,那里的修法也是换档)。玻璃化后更糟:`fill-0` 比玻璃面**更暗**,悬停会把条目压成一个洞。故改走 `fill-translucent-hover`(半透白),与 `.ku-dropdown-item` 同档。选中项保持不透明的 `fill-1`:它在玻璃面上是**更亮**的一档,读起来仍是一枚挑起的筹码,与那层很淡的悬停底也拉得开。
2844
+
2259
2845
  ---
2260
2846
 
2261
2847
  ## Other 其他