From 6a469b3e07022e56b29b1fd8a7c58df36b2111fe Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 2 Jul 2026 14:20:17 +0800 Subject: [PATCH] refactor(web): replace hand-written icons with Remix Icon (#1293) * refactor(web): replace hand-written icons with Remix Icon Generate a tree-shaken Remix Icon subset at build time via @iconify/utils + @iconify-json/ri, keeping the /iconSvg() API. Add a chat-new icon for the new-chat buttons and reveal the workspace 'new chat in group' button on hover. Unify the message copy and undo buttons (matching hover style and tooltip, drop the undo hover label, align sizes). Switch the mobile switcher kebab to the horizontal dots icon and tweak sidebar search colors. Regenerate the design-system icon catalog. * fix(web): address PR review feedback Restore accessible names (aria-label) on the message copy and undo buttons. Keep the workspace add button reachable for keyboard users by revealing it on header focus-within. Update the nix pnpmDeps hash for the newly added icon dependencies. * fix(web): address follow-up review feedback Keep the workspace add/more buttons focusable without hover by revealing them via opacity instead of display:none, so keyboard and non-hover users can reach the control. Drop explicit .ts extensions in icon imports to satisfy oxlint, and read the design-system icon catalog directly from the generated icon data. --- .changeset/refresh-web-icons.md | 5 + apps/kimi-web/design/design-system.html | 135 +++++----- apps/kimi-web/package.json | 6 +- apps/kimi-web/public/design-system.html | 135 +++++----- apps/kimi-web/scripts/gen-icon-catalog.mjs | 17 +- apps/kimi-web/scripts/gen-icon-data.mjs | 155 +++++++++++ apps/kimi-web/src/components/Sidebar.vue | 6 +- .../src/components/WorkspaceGroup.vue | 22 +- .../kimi-web/src/components/chat/ChatPane.vue | 79 +++--- .../components/mobile/MobileSwitcherSheet.vue | 4 +- apps/kimi-web/src/lib/icon-data.ts | 196 ++++++++++++++ apps/kimi-web/src/lib/icons.test.ts | 22 +- apps/kimi-web/src/lib/icons.ts | 244 +++--------------- apps/kimi-web/src/style.css | 7 +- flake.nix | 2 +- pnpm-lock.yaml | 28 +- 16 files changed, 639 insertions(+), 424 deletions(-) create mode 100644 .changeset/refresh-web-icons.md create mode 100644 apps/kimi-web/scripts/gen-icon-data.mjs create mode 100644 apps/kimi-web/src/lib/icon-data.ts diff --git a/.changeset/refresh-web-icons.md b/.changeset/refresh-web-icons.md new file mode 100644 index 000000000..6d6d022af --- /dev/null +++ b/.changeset/refresh-web-icons.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Refresh the web UI icon set and unify the message copy and undo button hover states and tooltips. diff --git a/apps/kimi-web/design/design-system.html b/apps/kimi-web/design/design-system.html index 261b678d4..9ca0de3b8 100644 --- a/apps/kimi-web/design/design-system.html +++ b/apps/kimi-web/design/design-system.html @@ -1039,79 +1039,82 @@

图标 Icon

-

图标一律从集中注册表 lib/icons.ts 取用:模板中用 <Icon name size /> 组件(components/ui/Icon.vue),v-html 场景(如工具 glyph)用 iconSvg(name, size)禁止手写 <svg>——scripts/check-style.mjsicon-from-registry 规则会对游离 SVG 告警。默认线稿风格:描边 1.5px、stroke="currentColor"stroke-linecap/linejoin: round、无 fill,颜色跟随文字;尺寸走下方三档 token。少数「实心即语义」的图标(play / stop / star)为 fill 例外。

+

图标一律从集中注册表 lib/icons.ts 取用:模板中用 <Icon name size /> 组件(components/ui/Icon.vue),v-html 场景(如工具 glyph)用 iconSvg(name, size)禁止手写 <svg>——scripts/check-style.mjsicon-from-registry 规则会对游离 SVG 告警。图标来自 Remix Icon(Apache-2.0),统一为 fill 风格(fill="currentColor"、24×24 源网格),颜色跟随文字;尺寸走下方三档 token。注册表由 scripts/gen-icon-data.mjs@iconify-json/ri 按需生成,离线且 tree-shaken。

尺寸阶梯 Size scale

-
sm · 14
-
md · 16
-
lg · 20
+
sm · 14
+
md · 16
+
lg · 20

图标库 Icon library

-

当前注册的图标(按用途分组)。新增图标时,把图形以 name → body 加入 lib/icons.ts,按需补 viewBox(默认 16×16)/fill,再用 node --experimental-strip-types scripts/gen-icon-catalog.mjs 重新生成本目录,保证注册表与文档不漂移。

+

当前注册的图标(按用途分组)。图标数据由 scripts/gen-icon-data.mjs 中的 NAME_TO_REMIX 映射决定,运行 pnpm gen:icons 生成 lib/icon-data.ts;本目录由 node --experimental-strip-types scripts/gen-icon-catalog.mjs 重新生成,保证注册表与文档不漂移。

-
Actions
-
plus
-
close
-
check
-
search
-
copy
-
link
-
external-link
-
download
-
undo
-
send
-
image
-
settings
-
sliders
-
log-in
-
Navigation & layout
-
chevron-down
-
chevron-right
-
arrow-up
-
arrow-down
-
arrow-right
-
minus
-
panel-collapse
-
panel-expand
-
expand
-
collapse
-
list
-
Files & tools
-
folder
-
folder-closed
-
folder-plus
-
folder-solid
-
file
-
file-text
-
file-plus
-
file-off
-
image-off
-
code
-
terminal
-
pencil
-
glob
-
globe
-
check-list
-
bolt
-
git-pull-request
-
Communication
-
message
-
mail
-
user
-
Status & media
-
info
-
help-circle
-
alert-triangle
-
clock
-
sparkles
-
play
-
stop
-
star
-
star-outline
-
dots-vertical
-
dots-horizontal
+
Actions
+
plus
+
chat-new
+
close
+
check
+
search
+
copy
+
link
+
external-link
+
download
+
undo
+
send
+
image
+
settings
+
sliders
+
log-in
+
Navigation & layout
+
chevron-down
+
chevron-right
+
arrow-up
+
arrow-down
+
arrow-right
+
minus
+
panel-collapse
+
panel-expand
+
expand
+
collapse
+
list
+
Files & tools
+
folder
+
folder-closed
+
folder-plus
+
folder-solid
+
file
+
file-text
+
file-plus
+
file-off
+
image-off
+
code
+
terminal
+
pencil
+
glob
+
globe
+
check-list
+
bolt
+
git-pull-request
+
Communication
+
message
+
mail
+
user
+
Status & media
+
info
+
help-circle
+
alert-triangle
+
clock
+
sparkles
+
play
+
stop
+
star
+
star-outline
+
dots-horizontal
+
Other
+
sort
+
grip

禁止用 emoji 充当功能图标(唯一例外:月相 🌑…🌘,仅用于「等待 Agent 响应」聊天态)。Kimi 品牌标记(32×22 的眼睛 logo)属于品牌资产,不在此线稿图标体系内。

diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index 9d60122f7..1b2eb5f2b 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -10,11 +10,15 @@ "build": "vite build", "typecheck": "vue-tsc --noEmit", "test": "vitest run", - "check:style": "node scripts/check-style.mjs" + "check:style": "node scripts/check-style.mjs", + "gen:icons": "node scripts/gen-icon-data.mjs" }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@iconify-json/ri": "^1.2.10", + "@iconify/utils": "^3.1.3", + "@iconify/vue": "^5.0.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "katex": "^0.17.0", diff --git a/apps/kimi-web/public/design-system.html b/apps/kimi-web/public/design-system.html index 8425e98cd..96da55a35 100644 --- a/apps/kimi-web/public/design-system.html +++ b/apps/kimi-web/public/design-system.html @@ -1038,79 +1038,82 @@

图标 Icon

-

图标一律从集中注册表 lib/icons.ts 取用:模板中用 <Icon name size /> 组件(components/ui/Icon.vue),v-html 场景(如工具 glyph)用 iconSvg(name, size)禁止手写 <svg>——scripts/check-style.mjsicon-from-registry 规则会对游离 SVG 告警。默认线稿风格:描边 1.5px、stroke="currentColor"stroke-linecap/linejoin: round、无 fill,颜色跟随文字;尺寸走下方三档 token。少数「实心即语义」的图标(play / stop / star)为 fill 例外。

+

图标一律从集中注册表 lib/icons.ts 取用:模板中用 <Icon name size /> 组件(components/ui/Icon.vue),v-html 场景(如工具 glyph)用 iconSvg(name, size)禁止手写 <svg>——scripts/check-style.mjsicon-from-registry 规则会对游离 SVG 告警。图标来自 Remix Icon(Apache-2.0),统一为 fill 风格(fill="currentColor"、24×24 源网格),颜色跟随文字;尺寸走下方三档 token。注册表由 scripts/gen-icon-data.mjs@iconify-json/ri 按需生成,离线且 tree-shaken。

尺寸阶梯 Size scale

-
sm · 14
-
md · 16
-
lg · 20
+
sm · 14
+
md · 16
+
lg · 20

图标库 Icon library

-

当前注册的图标(按用途分组)。新增图标时,把图形以 name → body 加入 lib/icons.ts,按需补 viewBox(默认 16×16)/fill,再用 node --experimental-strip-types scripts/gen-icon-catalog.mjs 重新生成本目录,保证注册表与文档不漂移。

+

当前注册的图标(按用途分组)。图标数据由 scripts/gen-icon-data.mjs 中的 NAME_TO_REMIX 映射决定,运行 pnpm gen:icons 生成 lib/icon-data.ts;本目录由 node --experimental-strip-types scripts/gen-icon-catalog.mjs 重新生成,保证注册表与文档不漂移。

-
Actions
-
plus
-
close
-
check
-
search
-
copy
-
link
-
external-link
-
download
-
undo
-
send
-
image
-
settings
-
sliders
-
log-in
-
Navigation & layout
-
chevron-down
-
chevron-right
-
arrow-up
-
arrow-down
-
arrow-right
-
minus
-
panel-collapse
-
panel-expand
-
expand
-
collapse
-
list
-
Files & tools
-
folder
-
folder-closed
-
folder-plus
-
folder-solid
-
file
-
file-text
-
file-plus
-
file-off
-
image-off
-
code
-
terminal
-
pencil
-
glob
-
globe
-
check-list
-
bolt
-
git-pull-request
-
Communication
-
message
-
mail
-
user
-
Status & media
-
info
-
help-circle
-
alert-triangle
-
clock
-
sparkles
-
play
-
stop
-
star
-
star-outline
-
dots-vertical
-
dots-horizontal
+
Actions
+
plus
+
chat-new
+
close
+
check
+
search
+
copy
+
link
+
external-link
+
download
+
undo
+
send
+
image
+
settings
+
sliders
+
log-in
+
Navigation & layout
+
chevron-down
+
chevron-right
+
arrow-up
+
arrow-down
+
arrow-right
+
minus
+
panel-collapse
+
panel-expand
+
expand
+
collapse
+
list
+
Files & tools
+
folder
+
folder-closed
+
folder-plus
+
folder-solid
+
file
+
file-text
+
file-plus
+
file-off
+
image-off
+
code
+
terminal
+
pencil
+
glob
+
globe
+
check-list
+
bolt
+
git-pull-request
+
Communication
+
message
+
mail
+
user
+
Status & media
+
info
+
help-circle
+
alert-triangle
+
clock
+
sparkles
+
play
+
stop
+
star
+
star-outline
+
dots-horizontal
+
Other
+
sort
+
grip

禁止用 emoji 充当功能图标(唯一例外:月相 🌑…🌘,仅用于「等待 Agent 响应」聊天态)。Kimi 品牌标记(32×22 的眼睛 logo)属于品牌资产,不在此线稿图标体系内。

diff --git a/apps/kimi-web/scripts/gen-icon-catalog.mjs b/apps/kimi-web/scripts/gen-icon-catalog.mjs index b4f6a33a1..79063bf06 100644 --- a/apps/kimi-web/scripts/gen-icon-catalog.mjs +++ b/apps/kimi-web/scripts/gen-icon-catalog.mjs @@ -1,24 +1,21 @@ // scripts/gen-icon-catalog.mjs — generate the design-system §02 icon catalog // HTML from the canonical registry (lib/icons.ts) so the two can never drift. // Run: node --experimental-strip-types scripts/gen-icon-catalog.mjs -import { ICONS } from '../src/lib/icons.ts'; +import { ICON_DATA } from '../src/lib/icon-data.ts'; // Display order + grouping. Names not listed here are appended under "Other". const GROUPS = [ - ['Actions', ['plus', 'close', 'check', 'search', 'copy', 'link', 'external-link', 'download', 'undo', 'send', 'image', 'settings', 'sliders', 'log-in']], + ['Actions', ['plus', 'chat-new', 'close', 'check', 'search', 'copy', 'link', 'external-link', 'download', 'undo', 'send', 'image', 'settings', 'sliders', 'log-in']], ['Navigation & layout', ['chevron-down', 'chevron-right', 'arrow-up', 'arrow-down', 'arrow-right', 'minus', 'panel-collapse', 'panel-expand', 'expand', 'collapse', 'list']], ['Files & tools', ['folder', 'folder-closed', 'folder-plus', 'folder-solid', 'file', 'file-text', 'file-plus', 'file-off', 'image-off', 'code', 'terminal', 'pencil', 'glob', 'globe', 'check-list', 'bolt', 'git-pull-request']], ['Communication', ['message', 'mail', 'user']], - ['Status & media', ['info', 'help-circle', 'alert-triangle', 'clock', 'sparkles', 'play', 'stop', 'star', 'star-outline', 'dots-vertical', 'dots-horizontal']], + ['Status & media', ['info', 'help-circle', 'alert-triangle', 'clock', 'sparkles', 'play', 'stop', 'star', 'star-outline', 'dots-horizontal']], ]; function render(name) { - const def = ICONS[name]; - const vb = def.viewBox ?? '0 0 16 16'; - const fillAttrs = def.fill - ? 'fill="currentColor"' - : 'fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"'; - return `${def.body}`; + const d = ICON_DATA[name]; + const vb = `0 0 ${d.width ?? 24} ${d.height ?? 24}`; + return `${d.body}`; } const seen = new Set(); @@ -31,7 +28,7 @@ for (const [label, names] of GROUPS) { lines.push(`
${render(name)}${name}
`); } } -const rest = Object.keys(ICONS).filter((n) => !seen.has(n)); +const rest = Object.keys(ICON_DATA).filter((n) => !seen.has(n)); if (rest.length) { lines.push('
Other
'); for (const name of rest) { diff --git a/apps/kimi-web/scripts/gen-icon-data.mjs b/apps/kimi-web/scripts/gen-icon-data.mjs new file mode 100644 index 000000000..49ed7dc45 --- /dev/null +++ b/apps/kimi-web/scripts/gen-icon-data.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +// scripts/gen-icon-data.mjs — generate src/lib/icon-data.ts, the tree-shaken +// Remix Icon (ri) subset that backs apps/kimi-web's icon registry. +// +// Single source of truth for "which existing icon name maps to which Remix +// icon" is the NAME_TO_REMIX map below. The SVG bytes are pulled straight from +// @iconify-json/ri at generation time, so the registry stays library-sourced +// (no hand-copied SVG) yet fully offline and tree-shaken (only the icons we +// list here end up in the bundle). +// +// Run after changing the map: pnpm gen:icons + +import { writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ri from '@iconify-json/ri/icons.json' with { type: 'json' }; +import { getIconData } from '@iconify/utils'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(__dirname, '../src/lib/icon-data.ts'); + +// Existing icon name → Remix icon name (ri:, prefix omitted here). +// Keep keys sorted within their group; the generated IconName union follows +// this order. Every value must exist in @iconify-json/ri (validated below). +const GROUPS = [ + ['Actions', { + plus: 'add-line', + 'chat-new': 'chat-new-line', + close: 'close-line', + check: 'check-line', + search: 'search-line', + copy: 'file-copy-line', + link: 'links-line', + 'external-link': 'external-link-line', + download: 'download-line', + undo: 'arrow-go-back-line', + send: 'send-plane-line', + image: 'image-line', + settings: 'settings-3-line', + sliders: 'equalizer-line', + 'log-in': 'login-box-line', + }], + ['Navigation & layout', { + 'chevron-down': 'arrow-down-s-line', + 'chevron-right': 'arrow-right-s-line', + 'arrow-up': 'arrow-up-line', + 'arrow-down': 'arrow-down-line', + 'arrow-right': 'arrow-right-line', + minus: 'subtract-line', + 'panel-collapse': 'contract-left-line', + 'panel-expand': 'expand-right-line', + expand: 'expand-diagonal-line', + collapse: 'collapse-diagonal-line', + list: 'list-unordered', + sort: 'sort-desc', + grip: 'draggable', + }], + ['Files & tools', { + folder: 'folder-open-line', + 'folder-closed': 'folder-line', + 'folder-plus': 'folder-add-line', + 'folder-solid': 'folder-fill', + file: 'file-line', + 'file-text': 'file-text-line', + 'file-plus': 'file-add-line', + 'file-off': 'file-line', + 'image-off': 'image-line', + code: 'code-line', + terminal: 'terminal-box-line', + pencil: 'pencil-line', + glob: 'braces-line', + globe: 'global-line', + 'check-list': 'list-check', + bolt: 'flashlight-line', + 'git-pull-request': 'git-pull-request-line', + }], + ['Communication', { + message: 'message-line', + mail: 'mail-line', + user: 'user-line', + }], + ['Status & media', { + info: 'information-line', + 'help-circle': 'question-line', + 'alert-triangle': 'alert-line', + clock: 'time-line', + sparkles: 'sparkling-line', + play: 'play-fill', + stop: 'stop-fill', + star: 'star-fill', + 'star-outline': 'star-line', + 'dots-horizontal': 'more-line', + }], +]; + +const NAME_TO_REMIX = Object.assign({}, ...GROUPS.map(([, m]) => m)); + +// --- resolve + validate ---------------------------------------------------- +const missing = []; +const data = {}; +for (const [name, riName] of Object.entries(NAME_TO_REMIX)) { + const icon = getIconData(ri, riName); + if (!icon) { + missing.push(`${name} → ri:${riName}`); + continue; + } + data[name] = { body: icon.body, width: icon.width ?? 24, height: icon.height ?? 24 }; +} + +if (missing.length) { + console.error('gen-icon-data: the following Remix icons were not found in @iconify-json/ri:'); + for (const m of missing) console.error(` ${m}`); + process.exit(1); +} + +// --- emit ------------------------------------------------------------------ +const names = Object.keys(data); +const keyOf = (n) => (/^[a-zA-Z_$][\w$]*$/.test(n) ? n : JSON.stringify(n)); + +const lines = []; +lines.push('// GENERATED FILE — do not edit by hand.'); +lines.push('// Source of truth: scripts/gen-icon-data.mjs (run `pnpm gen:icons`).'); +lines.push('// Icons are Remix Icon (ri) — https://remixicon.com/ — Apache-2.0.'); +lines.push(''); +lines.push('export type IconName ='); +for (let i = 0; i < names.length; i++) { + const suffix = i === names.length - 1 ? ';' : ''; + lines.push(` | ${JSON.stringify(names[i])}${suffix}`); +} +lines.push(''); +lines.push('export interface IconData {'); +lines.push(' /** Inner SVG markup (paths/shapes), rendered inside our wrapper. */'); +lines.push(' body: string;'); +lines.push(' /** Source grid width in px. Remix icons are 24. */'); +lines.push(' width?: number;'); +lines.push(' /** Source grid height in px. Remix icons are 24. */'); +lines.push(' height?: number;'); +lines.push('}'); +lines.push(''); +lines.push('/** Existing name → fully-qualified Remix icon id. */'); +lines.push('export const NAME_TO_REMIX: Record = {'); +for (const name of names) lines.push(` ${keyOf(name)}: ${JSON.stringify('ri:' + NAME_TO_REMIX[name])},`); +lines.push('};'); +lines.push(''); +lines.push('/** Per-icon SVG data, pulled from @iconify-json/ri. */'); +lines.push('export const ICON_DATA: Record = {'); +for (const name of names) { + const { body, width, height } = data[name]; + lines.push(` ${keyOf(name)}: { body: ${JSON.stringify(body)}, width: ${width}, height: ${height} },`); +} +lines.push('};'); +lines.push(''); + +writeFileSync(OUT, lines.join('\n')); +console.log(`gen-icon-data: wrote ${names.length} icons to src/lib/icon-data.ts`); diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 3ddae4bc4..f606ef9dd 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -582,7 +582,7 @@ onBeforeUnmount(() => {
@@ -924,7 +924,7 @@ onBeforeUnmount(() => { border: none; border-radius: var(--radius-md); background: transparent; - color: var(--color-text-muted); + color: var(--color-text); font: inherit; text-align: left; cursor: pointer; @@ -942,7 +942,7 @@ onBeforeUnmount(() => { .search-input { flex: 1; min-width: 0; - color: var(--faint); + color: var(--color-text); font-family: var(--mono); font-size: var(--ui-font-size); overflow: hidden; diff --git a/apps/kimi-web/src/components/WorkspaceGroup.vue b/apps/kimi-web/src/components/WorkspaceGroup.vue index 5b2c6d2d4..34a093eef 100644 --- a/apps/kimi-web/src/components/WorkspaceGroup.vue +++ b/apps/kimi-web/src/components/WorkspaceGroup.vue @@ -129,7 +129,7 @@ function onHeaderDragStart(event: DragEvent): void { :label="t('workspace.newInGroup')" @click.stop="emit('createInWorkspace', group.workspace.id)" > - +
@@ -259,12 +259,24 @@ function onHeaderDragStart(event: DragEvent): void { opacity: 1; } -/* More button — hidden until hover (or while its menu is open / focused). - `.gh .gh-more` out-specificities IconButton's display so the hidden default wins. */ -.gh .gh-more { display: none; } +/* More + add buttons — hidden until hover (or while the more menu is open / + focused). `.gh .gh-more` / `.gh .gh-add` out-specificity IconButton's display + so the hidden default wins. */ +.gh .gh-more, +.gh .gh-add { + opacity: 0; + pointer-events: none; +} .gh:hover .gh-more, +.gh:hover .gh-add, +.gh:focus-within .gh-more, +.gh:focus-within .gh-add, .gh-more.open, -.gh-more:focus-visible { display: inline-flex; } +.gh-more:focus-visible, +.gh-add:focus-visible { + opacity: 1; + pointer-events: auto; +} .gh-more.open { color: var(--color-text); background: var(--color-line); } .group-empty { diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index dd0123454..019bfa6dc 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -579,9 +579,9 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): @@ -593,6 +593,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): + + @@ -874,16 +878,6 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): min-height: 22px; box-sizing: border-box; } -.u-meta .u-edit svg { - margin-top: -1.5px; -} -.u-meta .u-edit-text { - max-width: 0; - overflow: hidden; - white-space: nowrap; - transition: max-width 0.15s ease; -} -.u-meta .u-edit:hover .u-edit-text { max-width: 120px; } /* User input is shown verbatim — preserve newlines, break long tokens. */ .u-text { white-space: pre-wrap; @@ -896,7 +890,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): .u-edit { display: inline-flex; align-items: center; - gap: 4px; + justify-content: center; padding: 2px 5px; background: none; border: none; @@ -904,6 +898,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): color: var(--muted); font: inherit; font-size: var(--text-base); + line-height: 1; cursor: pointer; opacity: 0.7; transition: opacity 0.12s, color 0.12s, background-color 0.12s; @@ -912,7 +907,6 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): display: block; flex: none; } -.u-edit span { line-height: 1; } .u-edit:hover { opacity: 1; color: var(--color-accent); background: var(--hover); } /* Copy button — icon-only, shares the undo button's muted→hover style. */ .u-copy { @@ -933,7 +927,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): min-height: 22px; box-sizing: border-box; } -.u-copy svg { display: block; flex: none; transform: translateY(1.3px); } +.u-copy svg { display: block; flex: none; } .u-copy:hover { opacity: 1; color: var(--color-accent); background: var(--hover); } /* Mobile bubble layout: right-align the undo button below the bubble. */ .u-edit-wrap { display: flex; justify-content: flex-end; } @@ -1004,38 +998,35 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): line-height: 1; } +/* Copy button — icon-only, shares the undo button's muted→hover style so the + message-stream action buttons (copy / undo) all read as one family. */ .a-cpbtn { display: inline-flex; align-items: center; - gap: 4px; + justify-content: center; + padding: 2px 5px; background: none; border: none; - color: var(--faint); - cursor: pointer; + border-radius: var(--radius-sm); + color: var(--muted); + font: inherit; font-size: var(--text-base); - padding: 2px 6px 2px 0; - border-radius: 4px; + line-height: 1; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.12s, color 0.12s, background-color 0.12s; + min-height: 22px; + box-sizing: border-box; } .a-cpbtn:hover { - color: var(--color-text); -} -.a-cpbtn svg, -.a-cpbtn-text { - pointer-events: none; + opacity: 1; + color: var(--color-accent); + background: var(--hover); } .a-cpbtn svg { + display: block; flex: none; } -.a-cpbtn-text { - opacity: 0; - max-width: none; - overflow: visible; - white-space: nowrap; - transition: opacity 0.15s ease; -} -.a-cpbtn:hover .a-cpbtn-text { - opacity: 1; -} /* Touch devices: always show the copy buttons (no hover to reveal them) and give the bubble-layout button a comfortable tap size. */ @media (hover: none) { @@ -1174,10 +1165,6 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): overflow: hidden; text-overflow: ellipsis; } - .a-cpbtn-text { - opacity: 1; - max-width: 120px; - } .u-edit-confirm { flex-wrap: wrap; justify-content: flex-end; diff --git a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue index 0a54d13ff..56058794e 100644 --- a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue @@ -205,7 +205,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise { :label="t('sidebar.options')" @click.stop="toggleWsMenu(g.workspace.id)" > - + @@ -250,7 +250,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise { :label="t('sidebar.options')" @click.stop="toggleMenu(s.id)" > - + diff --git a/apps/kimi-web/src/lib/icon-data.ts b/apps/kimi-web/src/lib/icon-data.ts new file mode 100644 index 000000000..54a7a3647 --- /dev/null +++ b/apps/kimi-web/src/lib/icon-data.ts @@ -0,0 +1,196 @@ +// GENERATED FILE — do not edit by hand. +// Source of truth: scripts/gen-icon-data.mjs (run `pnpm gen:icons`). +// Icons are Remix Icon (ri) — https://remixicon.com/ — Apache-2.0. + +export type IconName = + | "plus" + | "chat-new" + | "close" + | "check" + | "search" + | "copy" + | "link" + | "external-link" + | "download" + | "undo" + | "send" + | "image" + | "settings" + | "sliders" + | "log-in" + | "chevron-down" + | "chevron-right" + | "arrow-up" + | "arrow-down" + | "arrow-right" + | "minus" + | "panel-collapse" + | "panel-expand" + | "expand" + | "collapse" + | "list" + | "sort" + | "grip" + | "folder" + | "folder-closed" + | "folder-plus" + | "folder-solid" + | "file" + | "file-text" + | "file-plus" + | "file-off" + | "image-off" + | "code" + | "terminal" + | "pencil" + | "glob" + | "globe" + | "check-list" + | "bolt" + | "git-pull-request" + | "message" + | "mail" + | "user" + | "info" + | "help-circle" + | "alert-triangle" + | "clock" + | "sparkles" + | "play" + | "stop" + | "star" + | "star-outline" + | "dots-horizontal"; + +export interface IconData { + /** Inner SVG markup (paths/shapes), rendered inside our wrapper. */ + body: string; + /** Source grid width in px. Remix icons are 24. */ + width?: number; + /** Source grid height in px. Remix icons are 24. */ + height?: number; +} + +/** Existing name → fully-qualified Remix icon id. */ +export const NAME_TO_REMIX: Record = { + plus: "ri:add-line", + "chat-new": "ri:chat-new-line", + close: "ri:close-line", + check: "ri:check-line", + search: "ri:search-line", + copy: "ri:file-copy-line", + link: "ri:links-line", + "external-link": "ri:external-link-line", + download: "ri:download-line", + undo: "ri:arrow-go-back-line", + send: "ri:send-plane-line", + image: "ri:image-line", + settings: "ri:settings-3-line", + sliders: "ri:equalizer-line", + "log-in": "ri:login-box-line", + "chevron-down": "ri:arrow-down-s-line", + "chevron-right": "ri:arrow-right-s-line", + "arrow-up": "ri:arrow-up-line", + "arrow-down": "ri:arrow-down-line", + "arrow-right": "ri:arrow-right-line", + minus: "ri:subtract-line", + "panel-collapse": "ri:contract-left-line", + "panel-expand": "ri:expand-right-line", + expand: "ri:expand-diagonal-line", + collapse: "ri:collapse-diagonal-line", + list: "ri:list-unordered", + sort: "ri:sort-desc", + grip: "ri:draggable", + folder: "ri:folder-open-line", + "folder-closed": "ri:folder-line", + "folder-plus": "ri:folder-add-line", + "folder-solid": "ri:folder-fill", + file: "ri:file-line", + "file-text": "ri:file-text-line", + "file-plus": "ri:file-add-line", + "file-off": "ri:file-line", + "image-off": "ri:image-line", + code: "ri:code-line", + terminal: "ri:terminal-box-line", + pencil: "ri:pencil-line", + glob: "ri:braces-line", + globe: "ri:global-line", + "check-list": "ri:list-check", + bolt: "ri:flashlight-line", + "git-pull-request": "ri:git-pull-request-line", + message: "ri:message-line", + mail: "ri:mail-line", + user: "ri:user-line", + info: "ri:information-line", + "help-circle": "ri:question-line", + "alert-triangle": "ri:alert-line", + clock: "ri:time-line", + sparkles: "ri:sparkling-line", + play: "ri:play-fill", + stop: "ri:stop-fill", + star: "ri:star-fill", + "star-outline": "ri:star-line", + "dots-horizontal": "ri:more-line", +}; + +/** Per-icon SVG data, pulled from @iconify-json/ri. */ +export const ICON_DATA: Record = { + plus: { body: "", width: 24, height: 24 }, + "chat-new": { body: "", width: 24, height: 24 }, + close: { body: "", width: 24, height: 24 }, + check: { body: "", width: 24, height: 24 }, + search: { body: "", width: 24, height: 24 }, + copy: { body: "", width: 24, height: 24 }, + link: { body: "", width: 24, height: 24 }, + "external-link": { body: "", width: 24, height: 24 }, + download: { body: "", width: 24, height: 24 }, + undo: { body: "", width: 24, height: 24 }, + send: { body: "", width: 24, height: 24 }, + image: { body: "", width: 24, height: 24 }, + settings: { body: "", width: 24, height: 24 }, + sliders: { body: "", width: 24, height: 24 }, + "log-in": { body: "", width: 24, height: 24 }, + "chevron-down": { body: "", width: 24, height: 24 }, + "chevron-right": { body: "", width: 24, height: 24 }, + "arrow-up": { body: "", width: 24, height: 24 }, + "arrow-down": { body: "", width: 24, height: 24 }, + "arrow-right": { body: "", width: 24, height: 24 }, + minus: { body: "", width: 24, height: 24 }, + "panel-collapse": { body: "", width: 24, height: 24 }, + "panel-expand": { body: "", width: 24, height: 24 }, + expand: { body: "", width: 24, height: 24 }, + collapse: { body: "", width: 24, height: 24 }, + list: { body: "", width: 24, height: 24 }, + sort: { body: "", width: 24, height: 24 }, + grip: { body: "", width: 24, height: 24 }, + folder: { body: "", width: 24, height: 24 }, + "folder-closed": { body: "", width: 24, height: 24 }, + "folder-plus": { body: "", width: 24, height: 24 }, + "folder-solid": { body: "", width: 24, height: 24 }, + file: { body: "", width: 24, height: 24 }, + "file-text": { body: "", width: 24, height: 24 }, + "file-plus": { body: "", width: 24, height: 24 }, + "file-off": { body: "", width: 24, height: 24 }, + "image-off": { body: "", width: 24, height: 24 }, + code: { body: "", width: 24, height: 24 }, + terminal: { body: "", width: 24, height: 24 }, + pencil: { body: "", width: 24, height: 24 }, + glob: { body: "", width: 24, height: 24 }, + globe: { body: "", width: 24, height: 24 }, + "check-list": { body: "", width: 24, height: 24 }, + bolt: { body: "", width: 24, height: 24 }, + "git-pull-request": { body: "", width: 24, height: 24 }, + message: { body: "", width: 24, height: 24 }, + mail: { body: "", width: 24, height: 24 }, + user: { body: "", width: 24, height: 24 }, + info: { body: "", width: 24, height: 24 }, + "help-circle": { body: "", width: 24, height: 24 }, + "alert-triangle": { body: "", width: 24, height: 24 }, + clock: { body: "", width: 24, height: 24 }, + sparkles: { body: "", width: 24, height: 24 }, + play: { body: "", width: 24, height: 24 }, + stop: { body: "", width: 24, height: 24 }, + star: { body: "", width: 24, height: 24 }, + "star-outline": { body: "", width: 24, height: 24 }, + "dots-horizontal": { body: "", width: 24, height: 24 }, +}; diff --git a/apps/kimi-web/src/lib/icons.test.ts b/apps/kimi-web/src/lib/icons.test.ts index 9d67a19da..ba65a2f32 100644 --- a/apps/kimi-web/src/lib/icons.test.ts +++ b/apps/kimi-web/src/lib/icons.test.ts @@ -14,16 +14,22 @@ describe('ICONS registry', () => { expect(def.body.toLowerCase(), `${name}`).not.toContain(' { + for (const [name, def] of Object.entries(ICONS)) { + expect(def.fill, `${name} fill`).toBe(true); + expect(def.viewBox, `${name} viewBox`).toBe('0 0 24 24'); + } + }); }); describe('iconSvg', () => { - it('renders a line icon with the registry defaults', () => { + it('renders a Remix icon with the registry defaults', () => { const svg = iconSvg('plus'); expect(svg.startsWith(' { expect(iconSvg('plus', 'lg')).toContain('width="20" height="20"'); }); - it('keeps a custom viewBox for off-grid icons', () => { - expect(iconSvg('settings')).toContain('viewBox="0 0 24 24"'); - }); - - it('renders filled icons with currentColor and no stroke', () => { + it('renders a filled icon with currentColor and no stroke', () => { const svg = iconSvg('star'); expect(svg).toContain('fill="currentColor"'); expect(svg).not.toContain('stroke='); diff --git a/apps/kimi-web/src/lib/icons.ts b/apps/kimi-web/src/lib/icons.ts index baef6ad61..d53740d1b 100644 --- a/apps/kimi-web/src/lib/icons.ts +++ b/apps/kimi-web/src/lib/icons.ts @@ -1,229 +1,57 @@ // apps/kimi-web/src/lib/icons.ts -// Single source of truth for apps/kimi-web line icons (design-system §02). +// Single source of truth for apps/kimi-web icons (design-system §02). // -// Icons are line glyphs: stroke="currentColor", stroke-width 1.5, round caps/joins, -// no fill — colour follows text. Most sit on a 16x16 grid; a few keep a different -// source grid (24x24 for settings / expand / collapse / panel-* / git-pull-request / -// star-*, 12x12 for info, 20x20 for image-off / file-off). The rendered size is always -// the token size, and the global `.kw-icon * { vector-effect: non-scaling-stroke }` rule -// (style.css) keeps the 1.5px stroke visually identical across grids. +// Icons come from Remix Icon (https://remixicon.com/, Apache-2.0). The per-icon +// SVG data is generated into ./icon-data.ts by scripts/gen-icon-data.mjs — a +// tree-shaken subset of @iconify-json/ri — so the registry is library-sourced, +// offline, and only bundles the icons we actually use. +// +// Remix icons are fill-based (fill="currentColor") on a 24x24 source grid; the +// rendered size is always the token size. Colour follows text. // // Two consumers share this registry: // - the Vue component (components/ui/Icon.vue) for template use; // - iconSvg() below, for v-html contexts (e.g. lib/toolMeta.ts). -export type IconSize = 'sm' | 'md' | 'lg'; +import { iconToSVG } from '@iconify/utils'; +import { ICON_DATA, type IconData, type IconName } from './icon-data'; -export interface IconDef { - /** Inner SVG markup only — no outer , no stroke/fill attributes (the - * renderer adds those). Mixed icons may override fill on individual shapes. */ - body: string; - /** Source grid. Defaults to "0 0 16 16". */ - viewBox?: string; - /** Solid icon (fill="currentColor", no stroke). Defaults to line style. */ - fill?: boolean; -} +export type { IconName } from './icon-data'; +export { NAME_TO_REMIX } from './icon-data'; + +export type IconSize = 'sm' | 'md' | 'lg'; export const SIZE_PX: Record = { sm: 14, md: 16, lg: 20 }; -export const ICONS = { - // --- Actions (16-grid, line) -------------------------------------------- - plus: { body: '' }, - close: { body: '' }, - check: { body: '' }, - search: { body: '' }, - folder: { - body: '', - }, - image: { - body: '', - }, - 'chevron-down': { body: '' }, - send: { body: '' }, - sort: { body: '' }, - grip: { - body: '', - fill: true, - }, +export interface IconDef { + /** Inner SVG markup only — no outer . Remix icons are fill-based. */ + body: string; + /** Source grid. Remix icons are "0 0 24 24". */ + viewBox?: string; + /** Solid icon (fill="currentColor", no stroke). Always true for Remix. */ + fill?: boolean; +} - // --- Tool glyphs (16-grid, line) ---------------------------------------- - 'file-text': { - body: '', - }, - terminal: { - body: '', - }, - pencil: { - body: '', - }, - 'file-plus': { - body: '', - }, - glob: { - body: '', - }, - globe: { - body: '', - }, - 'check-list': { - body: '', - }, - bolt: { body: '' }, +function viewBoxOf(d: IconData): string { + return `0 0 ${d.width ?? 24} ${d.height ?? 24}`; +} - // --- 24-grid line icons ------------------------------------------------- - 'panel-collapse': { - viewBox: '0 0 24 24', - body: '', - }, - settings: { - viewBox: '0 0 24 24', - body: '', - }, - expand: { - viewBox: '0 0 24 24', - body: '', - }, - collapse: { - viewBox: '0 0 24 24', - body: '', - }, +/** Back-compat registry view over the generated Remix data. */ +export const ICONS: Record = Object.fromEntries( + (Object.entries(ICON_DATA) as [IconName, IconData][]).map(([name, d]) => [ + name, + { body: d.body, viewBox: viewBoxOf(d), fill: true }, + ]), +) as Record; - // --- Mixed / solid ------------------------------------------------------ - info: { - viewBox: '0 0 12 12', - body: '', - }, - play: { body: '', fill: true }, - stop: { body: '', fill: true }, - star: { - viewBox: '0 0 24 24', - body: '', - fill: true, - }, - - // --- More actions (16-grid, line) --------------------------------------- - 'log-in': { - body: '', - }, - message: { - body: '', - }, - mail: { - body: '', - }, - code: { - body: '', - }, - file: { - body: '', - }, - copy: { - body: '', - }, - link: { - body: '', - }, - 'external-link': { - body: '', - }, - download: { - body: '', - }, - undo: { - body: '', - }, - clock: { - body: '', - }, - sparkles: { - body: '', - }, - list: { body: '' }, - user: { - body: '', - }, - 'folder-plus': { - body: '', - }, - 'folder-closed': { - body: '', - }, - - // --- Navigation & arrows (16-grid, line) -------------------------------- - 'chevron-right': { body: '' }, - 'arrow-up': { body: '' }, - 'arrow-down': { body: '' }, - 'arrow-right': { body: '' }, - minus: { body: '' }, - - // --- More 24-grid line icons -------------------------------------------- - 'panel-expand': { - viewBox: '0 0 24 24', - body: '', - }, - 'git-pull-request': { - viewBox: '0 0 24 24', - body: '', - }, - sliders: { - viewBox: '0 0 24 24', - body: '', - }, - 'star-outline': { - viewBox: '0 0 24 24', - body: '', - }, - - - // --- Mixed / solid ------------------------------------------------------ - 'alert-triangle': { - body: '', - }, - 'help-circle': { - body: '', - }, - 'dots-vertical': { - body: '', - fill: true, - }, - 'dots-horizontal': { - body: '', - fill: true, - }, - 'folder-solid': { - body: '', - fill: true, - }, - - // --- Placeholders (20-grid, line) --------------------------------------- - 'image-off': { - viewBox: '0 0 20 20', - body: '', - }, - 'file-off': { - viewBox: '0 0 20 20', - body: '', - }, -} as const satisfies Record; - -export type IconName = keyof typeof ICONS; - -const DEFAULT_VIEWBOX = '0 0 16 16'; - -/** Typed accessor: `ICONS[name]` is a narrow literal union (some entries lack - * optional fields), so we widen to IconDef here — safe because the registry - * is `satisfies Record`. */ export function getIcon(name: IconName): IconDef { - return ICONS[name] as IconDef; + return ICONS[name]; } /** Render an icon to a full string for v-html contexts. Mirrors . */ export function iconSvg(name: IconName, size: IconSize = 'md'): string { - const def = getIcon(name); const px = SIZE_PX[size]; - const viewBox = def.viewBox ?? DEFAULT_VIEWBOX; - if (def.fill) { - return ``; - } - return ``; + const svg = iconToSVG(ICON_DATA[name], { width: px, height: px }); + const viewBox = svg.attributes.viewBox; + return ``; } diff --git a/apps/kimi-web/src/style.css b/apps/kimi-web/src/style.css index dca3c9429..9c590f25a 100644 --- a/apps/kimi-web/src/style.css +++ b/apps/kimi-web/src/style.css @@ -213,16 +213,13 @@ summary { /* -- icon primitive (design-system §02) ------------------------------------- Shared by the component and iconSvg() v-html strings (lib/icons.ts). - non-scaling-stroke keeps the 1.5px stroke identical whether an icon's source - grid is 16, 24 or 12, so every icon renders at a uniform weight. */ + Icons are Remix Icon (fill="currentColor" on a 24x24 grid), so colour follows + text and the rendered size comes from the width/height attributes. */ .kw-icon { display: inline-block; flex: none; vertical-align: -0.15em; } -.kw-icon > * { - vector-effect: non-scaling-stroke; -} /* color-scheme drives UA-rendered parts (scrollbars, form controls, etc.). An explicit choice must pin it to a single value — otherwise a dark-mode diff --git a/flake.nix b/flake.nix index 8af851005..b8d781535 100644 --- a/flake.nix +++ b/flake.nix @@ -154,7 +154,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-o753txNKfBcn/fiJOWFU0lyrSqTUJ/GU1ed1DPVPZ2U="; + hash = "sha256-+hTrX3pa8fjK+eh4q2BqOEAm9C7zvMXrKf4bwbJoG98="; }; nativeBuildInputs = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d65cbed5..0377edc7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,15 @@ importers: '@fontsource-variable/jetbrains-mono': specifier: ^5.2.8 version: 5.2.8 + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 + '@iconify/utils': + specifier: ^3.1.3 + version: 3.1.3 + '@iconify/vue': + specifier: ^5.0.1 + version: 5.0.1(vue@3.5.35(typescript@6.0.2)) '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -1608,6 +1617,9 @@ packages: peerDependencies: hono: ^4 + '@iconify-json/ri@1.2.10': + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} + '@iconify-json/simple-icons@1.2.84': resolution: {integrity: sha512-v4JVu6xIewGoETD4mm2k6UAdFAbTlY1duw5ZNSxYORfs2yFsHDhoU9Omn/BgrV0nR/ptWkF3ZIr/ZHoYXI/6Jw==} @@ -1617,6 +1629,11 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@iconify/vue@5.0.1': + resolution: {integrity: sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw==} + peerDependencies: + vue: '>=3.0.0' + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -8477,6 +8494,10 @@ snapshots: dependencies: hono: 4.12.14 + '@iconify-json/ri@1.2.10': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/simple-icons@1.2.84': dependencies: '@iconify/types': 2.0.0 @@ -8489,6 +8510,11 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@iconify/vue@5.0.1(vue@3.5.35(typescript@6.0.2))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.35(typescript@6.0.2) + '@inquirer/external-editor@1.0.3(@types/node@22.19.17)': dependencies: chardet: 2.1.1 @@ -9877,7 +9903,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: