diff --git a/src/main/__tests__/agent-shells.test.ts b/src/main/__tests__/agent-shells.test.ts index 3d67216bc..9b10df961 100644 --- a/src/main/__tests__/agent-shells.test.ts +++ b/src/main/__tests__/agent-shells.test.ts @@ -250,3 +250,38 @@ describe('AgentShells · 收工', () => { expect(second.info.id).not.toBe(first.info.id) }) }) + +describe('AgentShells · 界面旁观', () => { + it('★ peek 不消费模型的缓冲 —— 界面看过一眼,BashOutput 照样读得到', async () => { + const shells = new AgentShells() + const { info, process } = adopt(shells) + process.emit('stdout', 'ready\n') + process.emit('stderr', 'warn\n') + await flush() + expect(shells.peek(info.id)?.output).toBe('ready\nwarn\n') + expect(shells.read(info.id).stdout).toBe('ready\n') + // 模型读走之后,界面的尾巴不跟着清空 + expect(shells.peek(info.id)?.output).toBe('ready\nwarn\n') + }) + + it('peek 认不出的 id 返回 undefined,不 throw —— 界面上那条可能刚被淘汰', () => { + expect(new AgentShells().peek('bash_404')).toBeUndefined() + }) + + it('开、杀、退出各通知一次;输出增长不通知', async () => { + const shells = new AgentShells() + const listener = vi.fn() + shells.onChange(listener) + const first = adopt(shells) + expect(listener).toHaveBeenCalledTimes(1) + first.process.emit('stdout', 'log line\n') + await flush() + expect(listener).toHaveBeenCalledTimes(1) + shells.kill(first.info.id) + expect(listener).toHaveBeenCalledTimes(2) + const second = adopt(shells, { callId: 'call_2' }) + second.process.finish(0) + await flush() + expect(listener).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/main/agent-shells.ts b/src/main/agent-shells.ts index 042a6c0ed..e44d1253e 100644 --- a/src/main/agent-shells.ts +++ b/src/main/agent-shells.ts @@ -43,6 +43,14 @@ interface BackgroundEntry { info: BackgroundShellInfo stdout: Stream stderr: Stream + /** + * 给界面看的输出尾巴(stdout 与 stderr 按到达顺序交错)。 + * + * ★ 与上面两条缓冲**分开记**:那两条是「读走即清空」,专为 `BashOutput` 的 + * 「两次读之间不重不漏」服务。界面若也去读它们,就会把模型还没读到的输出吃掉 —— + * 模型下一次 `BashOutput` 什么都拿不到,而它会据此断定「服务没有任何输出」。 + */ + tail: string process: AdoptableProcess /** 环境租约。★ 必须活到进程结束——SSH 连接空闲 60s 就会被回收,见 manager.ts。 */ release: () => void @@ -69,6 +77,9 @@ function append(stream: Stream, chunk: string): void { stream.dropped = true } +/** 尾巴只留最后这么多字符。界面上只看最后几十行,不需要更多。 */ +const TAIL_CHARS = 8 * 1024 + /** 读走并清空。`filter` 是按**行**过滤的正则,调用方已经筛过 ReDoS。 */ function take(stream: Stream, filter: RegExp | undefined): string { const text = stream.buffer @@ -87,6 +98,30 @@ export class AgentShells { * 而两边都不会报错。 */ private sequence = 0 + private readonly listeners = new Set<() => void>() + + /** + * 后台列表变了(新开、退出、被杀、被淘汰)。返回退订函数。 + * + * ★ 只说「变了」,不带增量:监听方(IPC 广播)每次发全量,丢过一次通知也能自己收敛。 + * 输出增长**不**算变化 —— 一个刷日志的 dev server 每秒几十次,全量广播会被它淹没。 + */ + onChange(listener: () => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + private changed(): void { + for (const listener of this.listeners) { + try { + listener() + } catch { + // 一个坏掉的监听方不能挡住进程状态本身的推进 + } + } + } // ─────────────────────────── 前台 ─────────────────────────── @@ -160,6 +195,7 @@ export class AgentShells { }, stdout: emptyStream(), stderr: emptyStream(), + tail: '', process: req.process, release: req.release } @@ -175,7 +211,11 @@ export class AgentShells { 代价:一条序列恰好被切在两个 data 事件之间时剥不干净(极少见, 残留是几个可见字符,不会影响后续行)。 */ - source.on('data', (chunk: string) => append(stream, stripAnsi(chunk))) + source.on('data', (chunk: string) => { + const text = stripAnsi(chunk) + append(stream, text) + entry.tail = (entry.tail + text).slice(-TAIL_CHARS) + }) // 进程被杀时管道会抛 EPIPE/ECONNRESET。它不是一个要上报的错误, // 而没有这个监听器的话 Node 会把它升级成 uncaughtException 打死主进程。 source.on('error', () => {}) @@ -195,8 +235,10 @@ export class AgentShells { } entry.info.endedAt = Date.now() this.evict() + this.changed() }) this.evict() + this.changed() return { ...entry.info } } @@ -229,6 +271,7 @@ export class AgentShells { entry.info.status = 'killed' entry.info.endedAt = Date.now() entry.process.kill() + this.changed() } return { ...entry.info } } @@ -237,6 +280,16 @@ export class AgentShells { return [...this.background.values()].map((entry) => ({ ...entry.info })) } + /** + * 界面看一眼输出尾巴。**不消费**模型那两条缓冲(理由见 `BackgroundEntry.tail`)。 + * 找不到时返回 `undefined` 而不是 throw:界面上那条可能刚好被淘汰,这不是错误。 + */ + peek(id: string): { info: BackgroundShellInfo; output: string } | undefined { + const entry = this.background.get(id) + if (entry === undefined) return undefined + return { info: { ...entry.info }, output: entry.tail } + } + /** * 应用退出 / 测试收尾:杀光所有后台进程。 * @@ -254,6 +307,7 @@ export class AgentShells { } this.background.clear() this.foreground.clear() + this.changed() } /** 只淘汰**已结束**的,从最早结束的那条开始。还在跑的一律不动。 */ diff --git a/src/main/index.ts b/src/main/index.ts index 16be67c61..2fb1c887d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -292,7 +292,7 @@ function createMainWindow( /* ★ **跟着用户的主题偏好走,不能写死。** 这个色是窗口在渲染层画出第一帧之前 露出来的底色 —— 写死深色的话,浅色用户看到的是「深色空块 → 界面出来 → 啪一下 - 变浅」。两个值取自 theme.css 里的 `--color-app`(深 #2a2d2b / 浅 #f2eee6), + 变浅」。两个值取自 theme.css 里的 `--color-app`(深 #252527 / 浅 #ececf0), 改那边记得同步这里。 ★ **不能用 `store.getSettings()`。** 建窗现在排在启动迁移闸门之前,而闸门 @@ -300,7 +300,16 @@ function createMainWindow( 随后真正的 `openDatabase()` 直接抛错,启动死在那里。 `peekThemePreference()` 自己开一个只读连接,不碰那个句柄。 */ - backgroundColor: resolveTheme(peekThemePreference(join(resolveDataRoot(), DB_FILENAME))) === 'light' ? '#f2eee6' : '#2a2d2b', + backgroundColor: resolveTheme(peekThemePreference(join(resolveDataRoot(), DB_FILENAME))) === 'light' ? '#ececf0' : '#252527', + /* + ★ 苹果风格(Liquid Glass):macOS 上窗口本身是毛玻璃,桌面壁纸透过来 —— + 渲染层在 `data-vibrancy` 下把窗口底和两块主面板改成半透明(见 theme.css 末尾)。 + vibrancy 只有在背景色全透明时才看得见,所以这里把上面那个底色覆盖掉; + 首帧不会闪,因为系统材质本身就跟随深浅外观。Windows/Linux 保持实色。 + */ + ...(process.platform === 'darwin' + ? { vibrancy: 'under-window' as const, visualEffectState: 'active' as const, backgroundColor: '#00000000' } + : {}), // Windows/Linux 的任务栏与窗口图标(macOS 忽略,那边走下面的 app.dock.setIcon) icon: nativeImage.createFromPath(appIconPath), // macOS:红绿灯嵌进侧边栏(方案 §8)。Windows/Linux:只去掉系统标题栏, diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 7160877d4..7b74b7530 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -65,6 +65,7 @@ import { store } from '../state/store' import { windows, type WindowContext } from '../window/registry' +import { agentShells } from '../agent-shells' import { refreshScheduler, runScheduledTaskNow } from '../scheduled/scheduler' import { scheduledWrites, setScheduledChangeListener } from '../scheduled/bridge' import { setSchedulerRefresh } from '../scheduled/refresh' @@ -604,6 +605,10 @@ const handlers: HandlerMap = { // ── Agent 的 shell:只停这一条命令,run 继续 ── 'shell:stopToolCall': (req, ctx) => stopToolCall(req, ctx), 'shell:backgroundToolCall': (req, ctx) => backgroundToolCall(req, ctx), + // ── 后台 shell:右上角活动面板。按 id 操作,与模型的 BashOutput / KillShell 同一张表 ── + 'shell:listBackground': ({ workspaceId }) => agentShells.list().filter((shell) => shell.workspaceId === workspaceId), + 'shell:peekBackground': ({ id }) => agentShells.peek(id) ?? null, + 'shell:killBackground': ({ id }) => agentShells.peek(id) === undefined ? null : agentShells.kill(id), 'agent:backgroundSubagent': (req, ctx) => backgroundSubagent(req, ctx), // ── 步骤 8:终端 ── 'terminal:create': (req, ctx) => terminalHost.create(req, ctx.sender), @@ -1078,6 +1083,8 @@ export function registerIpc(): void { `windows.emitToAll` —— 那个模块拖着 electron,所以由这一侧装进去。 */ setScheduledChangeListener((change) => windows.emitToAll('scheduled:changed', change)) + // 同一种接线:`agent-shells.ts` 不认识窗口,广播由这一侧装进去 + agentShells.onChange(() => windows.emitToAll('shell:backgroundChanged', { shells: agentShells.list() })) /* 导入服务的接线,和上面 `setMcpChangeListener` 是同一种: diff --git a/src/renderer/index.html b/src/renderer/index.html index 844246e28..93c50f4c4 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/renderer/src/components/ProviderModelMenu.tsx b/src/renderer/src/components/ProviderModelMenu.tsx index 60921f880..ae6918799 100644 --- a/src/renderer/src/components/ProviderModelMenu.tsx +++ b/src/renderer/src/components/ProviderModelMenu.tsx @@ -60,7 +60,7 @@ const PANEL = cn( ) const ITEM = cn( - 'flex w-full cursor-default select-none items-center gap-2.5 rounded-[7px] px-2.5 py-[7px]', + 'flex w-full cursor-default select-none items-center gap-2.5 rounded-[8px] px-2.5 py-[7px]', 'text-left text-[13px] text-fg outline-none transition-colors', 'data-[highlighted]:bg-tint-strong data-[disabled]:opacity-40' ) diff --git a/src/renderer/src/components/arc/action-button/action-button.module.css b/src/renderer/src/components/arc/action-button/action-button.module.css index 704de76f0..8f1035ea3 100644 --- a/src/renderer/src/components/arc/action-button/action-button.module.css +++ b/src/renderer/src/components/arc/action-button/action-button.module.css @@ -1,5 +1,5 @@ -.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; overflow: hidden; padding: 0 var(--space-5); border: 1px solid var(--foreground); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); } -@media (hover: hover) and (pointer: fine) { .button:hover:not(:disabled) { box-shadow: var(--shadow-resting); } } +.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; overflow: hidden; padding: 0 var(--space-5); border: 1px solid transparent; border-radius: var(--radius-control); background: linear-gradient(to bottom, rgb(255 255 255 / .1), rgb(255 255 255 / 0)) var(--accent); color: var(--accent-foreground); box-shadow: 0 0 0 .5px color-mix(in srgb, var(--accent), black 25%), 0 1px 1.5px rgb(0 0 0 / .12); font: inherit; font-size: 13px; font-weight: 500; line-height: var(--leading-body); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); } +@media (hover: hover) and (pointer: fine) { .button:hover:not(:disabled) { background-color: color-mix(in srgb, var(--accent), white 8%); } } .button:disabled { cursor: not-allowed; } .button:disabled:not([data-state="pending"]) { opacity: .7; } .button[data-state="pending"] { cursor: progress; } diff --git a/src/renderer/src/components/arc/button/button.module.css b/src/renderer/src/components/arc/button/button.module.css index a156b98b0..bcf7ee6d0 100644 --- a/src/renderer/src/components/arc/button/button.module.css +++ b/src/renderer/src/components/arc/button/button.module.css @@ -1,4 +1,4 @@ -.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; gap: var(--space-2); border: 1px solid transparent; border-radius: var(--radius-control); padding: 0 var(--space-4); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } +.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; gap: var(--space-2); border: 1px solid transparent; border-radius: var(--radius-control); padding: 0 var(--space-4); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } .button:disabled { cursor: not-allowed; opacity: .52; } .button[aria-busy="true"] { cursor: progress; } /* Anchors of floating layers never scale: Radix opens on pointerdown and would measure a shrunken trigger. */ @@ -10,27 +10,29 @@ .labelContent { display: inline-flex; flex: none; align-items: center; } .labelPhase { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); white-space: nowrap; } .labelPhase svg { flex: none; } -.primary { color: var(--background); background: var(--foreground); border-color: var(--foreground); } -.secondary { color: var(--foreground); background: var(--surface); border-color: var(--border); } +/* macOS 默认按钮:强调色实底 + 顶部一道极淡的高光渐变 */ +.primary { color: var(--accent-foreground); background: linear-gradient(to bottom, rgb(255 255 255 / .1), rgb(255 255 255 / 0)) var(--accent); border-color: transparent; box-shadow: 0 0 0 .5px color-mix(in srgb, var(--accent), black 25%), 0 1px 1.5px rgb(0 0 0 / .12); } +/* macOS 普通按钮:白底(深色下是一层亮灰) + 发丝收边 + 一点投影,没有实线描边 */ +.secondary { color: var(--foreground); background: var(--surface); border-color: var(--border-strong); } + .ghost { color: var(--text-secondary); background: transparent; border-color: transparent; } -.danger { color: var(--danger); background: var(--surface); border-color: var(--border); } +.danger { color: var(--danger); background: var(--surface); border-color: var(--border-strong); } /* A disabled outlined button keeps its shape and fades only its label, so it still reads as a button that is off, not as plain text. */ -.secondary:disabled, .danger:disabled { opacity: 1; color: var(--text-muted); background: var(--surface-muted); border-color: var(--border); } -.danger:disabled { color: color-mix(in oklab, var(--danger) 55%, var(--surface-muted)); } +.secondary:disabled, .danger:disabled { opacity: .55; color: var(--text-muted); } +.danger:disabled { color: color-mix(in oklab, var(--danger) 55%, var(--surface)); } .ghost:disabled { opacity: 1; color: var(--text-muted); } @media (hover: hover) and (pointer: fine) { - .primary:hover:not(:disabled) { opacity: .91; box-shadow: var(--shadow-resting); } - .secondary:hover:not(:disabled) { background: var(--surface-muted); box-shadow: var(--shadow-resting); } + .primary:hover:not(:disabled) { background-color: color-mix(in srgb, var(--accent), white 8%); } + .secondary:hover:not(:disabled), .danger:hover:not(:disabled) { background-color: color-mix(in srgb, var(--surface), var(--foreground) 4%); } .ghost:hover:not(:disabled) { color: var(--foreground); background: var(--surface-muted); } - .danger:hover:not(:disabled) { border-color: var(--danger); background: var(--surface-muted); } } /* Color feedback on press, so touch and menu triggers (which never scale) still answer the finger. */ -.primary:active:not(:disabled) { opacity: .84; } -.secondary:active:not(:disabled), .ghost:active:not(:disabled) { color: var(--foreground); background: var(--surface-muted); } -.danger:active:not(:disabled) { border-color: var(--danger); background: var(--surface-muted); } -.sm { min-height: var(--control-height-sm); padding-inline: var(--space-3); font-size: var(--text-sm); } -.md { min-height: var(--control-height-md); } -.lg { min-height: var(--control-height-lg); padding-inline: var(--space-5); font-size: var(--text-sm); } +.primary:active:not(:disabled) { background-color: color-mix(in srgb, var(--accent), black 14%); } +.secondary:active:not(:disabled), .danger:active:not(:disabled) { background-color: color-mix(in srgb, var(--surface), var(--foreground) 10%); } +.ghost:active:not(:disabled) { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 10%, transparent); } +.sm { min-height: var(--control-height-sm); padding-inline: 12px; font-size: 12px; } +.md { min-height: var(--control-height-md); padding-inline: 14px; font-size: 13px; } +.lg { min-height: var(--control-height-lg); padding-inline: var(--space-5); font-size: 13px; } .loader { position: absolute; left: 50%; top: 50%; display: grid; width: var(--space-4); height: var(--space-4); margin: calc(var(--space-4) / -2); } .spinner { border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; } .loadingLabel { opacity: 0; } diff --git a/src/renderer/src/components/arc/context-menu/context-menu.module.css b/src/renderer/src/components/arc/context-menu/context-menu.module.css index 208c51caa..26e8cd727 100644 --- a/src/renderer/src/components/arc/context-menu/context-menu.module.css +++ b/src/renderer/src/components/arc/context-menu/context-menu.module.css @@ -1,13 +1,15 @@ .target { display: block; outline: none; } /* Motion scales the menu from the pointer (transform-origin is set inline) and fades it out faster than it arrives. */ -.menu { position: fixed; z-index: 100; min-width: min(13rem, calc(100vw - 16px)); max-width: calc(100vw - 16px); max-height: min(300px, calc(100vh - 16px)); overflow-y: auto; padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); outline: none; } -.highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: calc(var(--radius-panel) - 6px); background: var(--surface-muted); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); } -.highlight[data-tone="danger"] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); } -.item { position: relative; display: flex; width: 100%; min-height: 34px; align-items: center; gap: 9px; padding: 0 11px; border: 0; border-radius: calc(var(--radius-panel) - 6px); background: transparent; color: inherit; font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: var(--leading-body); text-align: left; cursor: pointer; outline: none; transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-fast) var(--ease-standard); animation: context-item-in var(--duration-standard) var(--ease-enter) both; animation-delay: calc(min(var(--i, 0), 4) * 35ms); } +.menu { position: fixed; z-index: 100; min-width: min(13rem, calc(100vw - 16px)); max-width: calc(100vw - 16px); max-height: min(300px, calc(100vh - 16px)); overflow-y: auto; padding: 5px; border: 0; border-radius: 10px; background: var(--material-menu); -webkit-backdrop-filter: var(--material-blur); backdrop-filter: var(--material-blur); color: var(--foreground); box-shadow: var(--shadow-floating); outline: none; } +.highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: 5px; background: var(--accent); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); } +.highlight[data-tone="danger"] { background: var(--danger); } +.item { position: relative; display: flex; width: 100%; min-height: 24px; align-items: center; gap: 8px; padding: 0 9px; border: 0; border-radius: 5px; background: transparent; color: inherit; font: inherit; font-size: 13px; letter-spacing: var(--tracking-body); line-height: var(--leading-body); text-align: left; cursor: pointer; outline: none; transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-fast) var(--ease-standard); animation: context-item-in var(--duration-standard) var(--ease-enter) both; animation-delay: calc(min(var(--i, 0), 4) * 35ms); } /* The gliding highlight is the focus indicator, as in the dropdown menu. */ .item:focus-visible { outline: none; } +/* 选中行压在强调色上,文字和图标翻成白色 */ +.item:focus:not(:disabled), .item:focus:not(:disabled) .icon { color: var(--accent-foreground); } /* Rows press with a tint, not a scale. */ -.item:active:not(:disabled) { background: color-mix(in oklab, var(--foreground) 5%, transparent); } +.item:active:not(:disabled) { background: transparent; } .item:disabled { opacity: .45; cursor: default; } .item.destructive { color: var(--danger); } .icon { display: inline-flex; flex: none; width: 16px; align-items: center; justify-content: center; color: var(--text-secondary); } diff --git a/src/renderer/src/components/arc/dialog/dialog.module.css b/src/renderer/src/components/arc/dialog/dialog.module.css index ce024a9ff..94b58855e 100644 --- a/src/renderer/src/components/arc/dialog/dialog.module.css +++ b/src/renderer/src/components/arc/dialog/dialog.module.css @@ -1,6 +1,6 @@ -.overlay { position: fixed; inset: 0; z-index: 50; background: oklch(10% 0 0 / .46); backdrop-filter: blur(7px); } +.overlay { position: fixed; inset: 0; z-index: 50; background: rgb(0 0 0 / .28); } /* Centered with auto margins (like a native modal dialog) so transform stays free for the entrance spring. */ -.content { position: fixed; z-index: 51; inset: 0; width: min(calc(100vw - var(--space-8)), 440px); height: fit-content; max-height: calc(100vh - var(--space-8)); max-height: calc(100dvh - var(--space-8)); margin: auto; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); } +.content { position: fixed; z-index: 51; inset: 0; width: min(calc(100vw - var(--space-8)), 440px); height: fit-content; max-height: calc(100vh - var(--space-8)); max-height: calc(100dvh - var(--space-8)); margin: auto; overflow: auto; border: 0; border-radius: var(--radius-surface); background: var(--material-sheet); -webkit-backdrop-filter: var(--material-blur); backdrop-filter: var(--material-blur); color: var(--foreground); box-shadow: var(--shadow-floating); } /* Motion animates both layers. Keyframes only run when DialogContent sits under a bare Radix root. */ .keyframes.overlay[data-state="open"] { animation: overlay-in var(--duration-standard) var(--ease-enter) both; } .keyframes.overlay[data-state="closed"] { animation: overlay-out var(--duration-fast) var(--ease-standard) both; } @@ -9,23 +9,23 @@ .content:focus { outline: none; } /* While the layers leave, clicks pass through, so the trigger can reopen the dialog mid-exit. Radix sets pointer-events inline on the overlay. */ .overlay[data-state="closed"], .content[data-state="closed"] { pointer-events: none !important; } -.header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-5); padding: var(--space-6); border-bottom: 1px solid var(--border); } -.title { margin: 0; font-family: var(--font-body); font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); } -.description { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); } +.header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-5); padding: var(--space-5) var(--space-5) var(--space-3); border-bottom: 0; } +.title { margin: 0; font-family: var(--font-body); font-size: 15px; font-weight: 600; letter-spacing: var(--tracking-body); line-height: var(--leading-body); } +.description { margin: var(--space-1) 0 0; color: var(--text-secondary); font-size: 13px; line-height: var(--leading-body); } .title, .description { position: relative; overflow-wrap: anywhere; } .swap { display: block; } /* Line height sits on the text itself, so a page-level heading reset cannot shrink the title and pull the close button off its line. */ .title .swap { line-height: var(--leading-body); } /* A compact icon button: quick press, spring release. The negative margin centres it on the title's first line. */ -.close { flex: 0 0 auto; margin-block: calc((var(--text-lg) * var(--leading-body) - var(--space-8)) / 2); display: grid; place-items: center; width: var(--space-8); height: var(--space-8); border: 1px solid var(--border); border-radius: var(--radius-control); background: color-mix(in oklab, var(--surface-muted) 50%, transparent); color: var(--text-secondary); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); } -@media (hover: hover) and (pointer: fine) { .close:hover { background: var(--surface-muted); color: var(--foreground); } } +.close { flex: 0 0 auto; margin-block: calc((15px * var(--leading-body) - 22px) / 2); display: grid; place-items: center; width: 22px; height: 22px; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--text-secondary); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); } +@media (hover: hover) and (pointer: fine) { .close:hover { background: color-mix(in srgb, var(--foreground) 14%, transparent); color: var(--foreground); } } .close:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); } -.body { padding: var(--space-6); font-size: var(--text-sm); line-height: var(--leading-body); } +.body { padding: var(--space-2) var(--space-5) var(--space-5); font-size: 13px; line-height: var(--leading-body); } @keyframes overlay-in { from { opacity: 0; } } @keyframes overlay-out { to { opacity: 0; } } @keyframes content-fade-in { from { opacity: 0; } } -@keyframes content-rise-in { from { transform: translateY(8px) scale(.96); } } -@keyframes content-out { to { opacity: 0; transform: translateY(4px) scale(.98); } } +@keyframes content-rise-in { from { transform: scale(.94); } } +@keyframes content-out { to { opacity: 0; transform: scale(.97); } } @keyframes content-fade-out { to { opacity: 0; } } @media (prefers-reduced-motion: reduce) { .keyframes.overlay[data-state="open"], .keyframes.content[data-state="open"] { animation: overlay-in var(--duration-instant) var(--ease-standard) both; } diff --git a/src/renderer/src/components/arc/dropdown-menu/dropdown-menu.module.css b/src/renderer/src/components/arc/dropdown-menu/dropdown-menu.module.css index 5d6138668..b4b745b21 100644 --- a/src/renderer/src/components/arc/dropdown-menu/dropdown-menu.module.css +++ b/src/renderer/src/components/arc/dropdown-menu/dropdown-menu.module.css @@ -1,8 +1,8 @@ -.trigger { display: inline-flex; max-width: 100%; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: background-color var(--duration-instant) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } -@media (hover: hover) and (pointer: fine) { .trigger:hover { border-color: var(--border-strong); background: var(--surface-muted); box-shadow: var(--shadow-resting); } } +.trigger { display: inline-flex; max-width: 100%; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: background-color var(--duration-instant) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } +@media (hover: hover) and (pointer: fine) { .trigger:hover { background: color-mix(in srgb, var(--surface), var(--foreground) 4%); } } /* The trigger anchors the menu, so press feedback stays in color. A scaled rect would be measured on pointerdown and shift the menu. */ -.trigger[data-state="open"] { border-color: var(--border-strong); background: var(--surface-muted); box-shadow: none; } -.trigger:active { border-color: var(--border-strong); background: color-mix(in oklab, var(--surface-muted), var(--border) 55%); box-shadow: none; } +.trigger[data-state="open"] { background: color-mix(in srgb, var(--surface), var(--foreground) 8%); } +.trigger:active { background: color-mix(in srgb, var(--surface), var(--foreground) 10%); } .item:focus-visible { outline: none; } .triggerIcon { display: inline-flex; flex: none; color: var(--text-secondary); } .label { position: relative; display: inline-flex; min-width: 0; flex: 0 1 auto; overflow-x: clip; } @@ -12,7 +12,7 @@ .trigger[data-state="open"] .chevron { transform: rotate(180deg); } /* The menu grows from the trigger edge: offset toward the trigger, scale from the Radix origin, fade quickly. Transitions instead of keyframes, so a close that interrupts the open (or a reopen during the close) reverses from where it is. */ -.menu { --menu-x: 0px; --menu-y: -5px; position: relative; z-index: 60; min-width: min(12rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); } +.menu { --menu-x: 0px; --menu-y: -5px; position: relative; z-index: 60; min-width: min(12rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: 5px; border: 0; border-radius: 10px; background: var(--material-menu); -webkit-backdrop-filter: var(--material-blur); backdrop-filter: var(--material-blur); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); } .menu[data-side="top"] { --menu-y: 5px; } .menu[data-side="left"] { --menu-x: 5px; --menu-y: 0px; } .menu[data-side="right"] { --menu-x: -5px; --menu-y: 0px; } @@ -22,11 +22,13 @@ } /* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */ .menu[data-state="closed"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity 130ms var(--ease-standard), transform 130ms var(--ease-standard); animation: menu-exit 130ms linear both; pointer-events: none; } -.highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: calc(var(--radius-panel) - 6px); background: var(--surface-muted); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); } -.highlight[data-tone="danger"] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); } -.item { position: relative; display: flex; min-height: 36px; align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-panel) - 6px); color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms); } -.icon { display: inline-flex; width: 17px; color: var(--text-secondary); } -.separator { height: 1px; margin: var(--space-1) calc(var(--space-1) * -1); background: var(--border-subtle); } +/* macOS 菜单的选中行:强调色实底 + 白字 */ +.highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: 5px; background: var(--accent); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); } +.highlight[data-tone="danger"] { background: var(--danger); } +.item { position: relative; display: flex; min-height: 24px; align-items: center; gap: 8px; padding: 0 9px; border-radius: 5px; color: var(--foreground); font-size: 13px; cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms); } +.icon { display: inline-flex; width: 16px; color: var(--text-secondary); } +.item[data-highlighted], .item[data-highlighted] .icon { color: var(--accent-foreground); } +.separator { height: 1px; margin: 5px 9px; background: var(--border); } .destructive { color: var(--danger); } .destructive .icon { color: var(--danger); } .item[data-disabled] { opacity: .45; cursor: default; } diff --git a/src/renderer/src/components/arc/empty-state/empty-state.module.css b/src/renderer/src/components/arc/empty-state/empty-state.module.css index 127e96770..02636551d 100644 --- a/src/renderer/src/components/arc/empty-state/empty-state.module.css +++ b/src/renderer/src/components/arc/empty-state/empty-state.module.css @@ -1,9 +1,9 @@ .root { display: flex; width: 100%; flex-direction: column; align-items: center; padding: clamp(var(--space-8), 8vw, var(--space-12)) var(--space-5); text-align: center; } -.icon { position: relative; display: grid; width: var(--space-12); height: var(--space-12); place-items: center; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-muted); color: var(--text-secondary); animation: empty-state-icon var(--duration-considered) var(--ease-enter) both; } +.icon { position: relative; display: grid; width: var(--space-12); height: var(--space-12); place-items: center; border: 0; border-radius: 12px; background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--text-secondary); animation: empty-state-icon var(--duration-considered) var(--ease-enter) both; } .glyph { display: grid; place-items: center; } .frame { width: 100%; max-width: 100%; } .copy { display: flow-root; } -.root h3 { position: relative; margin: var(--space-5) 0 0; color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); } +.root h3 { position: relative; margin: var(--space-4) 0 0; color: var(--foreground); font-size: 15px; font-weight: 600; line-height: var(--leading-body); } .root p { position: relative; max-width: 18rem; margin: var(--space-2) auto 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); } /* Centered copy wraps into even lines, so a new description never leaves a single word hanging. */ .line { display: block; text-wrap: balance; } diff --git a/src/renderer/src/components/arc/input/input.module.css b/src/renderer/src/components/arc/input/input.module.css index fd02e4a42..0951be41d 100644 --- a/src/renderer/src/components/arc/input/input.module.css +++ b/src/renderer/src/components/arc/input/input.module.css @@ -1,11 +1,11 @@ .field { display: grid; min-width: 0; } .label { margin-bottom: var(--space-2); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); } /* Focus answers with the border colour alone (no rings or halos); the field itself never changes size. */ -.input { width: 100%; min-height: var(--control-height-md); padding: 0 var(--space-3); border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); outline: none; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); } +.input { width: 100%; min-height: var(--control-height-md); padding: 0 var(--space-3); border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding-inline: 14px; background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); outline: none; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } .input::placeholder { color: var(--text-muted); } -@media (hover: hover) and (pointer: fine) { .input:hover:not(:disabled, [aria-invalid="true"]) { border-color: var(--foreground); } } -.input:focus-visible { border-color: var(--foreground); } -.input[aria-invalid="true"] { border-color: var(--danger); } +@media (hover: hover) and (pointer: fine) { .input:hover:not(:disabled, [aria-invalid="true"]) { border-color: var(--border-strong); } } +.input:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-outline); } +.input[aria-invalid="true"] { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 30%, transparent); } .input:disabled { cursor: not-allowed; opacity: .5; background: var(--surface-muted); } /* The row's space is padding inside the clipped slot, so the height animates from a true zero. */ .messageSlot { display: block; overflow: hidden; } diff --git a/src/renderer/src/components/arc/number-field/number-field.module.css b/src/renderer/src/components/arc/number-field/number-field.module.css index 14c840a67..9792e21e7 100644 --- a/src/renderer/src/components/arc/number-field/number-field.module.css +++ b/src/renderer/src/components/arc/number-field/number-field.module.css @@ -11,10 +11,10 @@ .label[data-scrub] { cursor: ew-resize; touch-action: pan-y; user-select: none; -webkit-user-select: none; } /* A hairline shell. Focus darkens the border only (the library draws no rings); a typed value past a limit tints it with the warning role instead, calm rather than alarming. Set --number-field-width to resize the shell. */ -.control { display: flex; align-items: center; width: min(100%, var(--nf-width)); min-height: var(--nf-height); gap: 2px; border: 1px solid var(--border); border-radius: var(--radius-control); padding: var(--nf-inset); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); } +.control { display: flex; align-items: center; width: min(100%, var(--nf-width)); min-height: var(--nf-height); gap: 2px; border: .5px solid var(--border-strong); border-radius: var(--radius-control); padding: var(--nf-inset); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); } @media (hover: hover) and (pointer: fine) { .control:hover, .field:has(.label[data-scrub]:hover) .control { border-color: var(--border-strong); } } -.control[data-scrubbing] { border-color: var(--foreground); } -.control:has(input:focus) { border-color: var(--foreground); } +.control[data-scrubbing] { border-color: var(--accent); } +.control:has(input:focus) { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-outline); } .control[data-warn], .control[data-warn]:hover { border-color: color-mix(in oklab, var(--warning) 55%, var(--border)); background: color-mix(in oklab, var(--warning) 5%, var(--surface)); } .control[data-warn]:has(input:focus) { border-color: color-mix(in oklab, var(--warning) 80%, var(--border)); } .control[data-disabled] { opacity: .5; background: var(--surface-muted); } diff --git a/src/renderer/src/components/arc/progress/progress.module.css b/src/renderer/src/components/arc/progress/progress.module.css index 99829dc22..8348c565e 100644 --- a/src/renderer/src/components/arc/progress/progress.module.css +++ b/src/renderer/src/components/arc/progress/progress.module.css @@ -1 +1 @@ -.progress { width: 100%; color: var(--text-secondary); font-size: var(--text-sm); } .meta { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 8px; line-height: var(--leading-body); } .label { position: relative; min-width: 0; } .line { display: block; } .value { display: inline-flex; flex: none; align-items: center; gap: 4px; color: var(--text-muted); font-variant-numeric: tabular-nums; } .count { display: inline-grid; justify-items: end; } /* A hidden "100%" row reserves the widest count, so the check never shifts as the number gains a digit. */ .count::before { content: "100%"; height: 0; overflow: hidden; visibility: hidden; } .done { display: grid; place-items: center; color: var(--success); } .track { height: 7px; overflow: hidden; border: 1px solid var(--border); border-radius: 99px; background: var(--surface-muted); } .fill { display: block; width: 100%; height: 100%; border-radius: inherit; background: var(--accent); transition: background-color var(--duration-standard) var(--ease-standard); } .progress[data-complete] .fill { background: var(--success); transition-delay: 200ms; } @media (prefers-reduced-motion: reduce) { .fill, .progress[data-complete] .fill { transition: none; } } +.progress { width: 100%; color: var(--text-secondary); font-size: var(--text-sm); } .meta { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 8px; line-height: var(--leading-body); } .label { position: relative; min-width: 0; } .line { display: block; } .value { display: inline-flex; flex: none; align-items: center; gap: 4px; color: var(--text-muted); font-variant-numeric: tabular-nums; } .count { display: inline-grid; justify-items: end; } /* A hidden "100%" row reserves the widest count, so the check never shifts as the number gains a digit. */ .count::before { content: "100%"; height: 0; overflow: hidden; visibility: hidden; } .done { display: grid; place-items: center; color: var(--success); } .track { height: 6px; overflow: hidden; border: 0; border-radius: 99px; background: color-mix(in srgb, var(--foreground) 10%, transparent); } .fill { display: block; width: 100%; height: 100%; border-radius: inherit; background: var(--accent); transition: background-color var(--duration-standard) var(--ease-standard); } .progress[data-complete] .fill { background: var(--success); transition-delay: 200ms; } @media (prefers-reduced-motion: reduce) { .fill, .progress[data-complete] .fill { transition: none; } } diff --git a/src/renderer/src/components/arc/radio-cards/radio-cards.module.css b/src/renderer/src/components/arc/radio-cards/radio-cards.module.css index 7013105a1..cf25a9805 100644 --- a/src/renderer/src/components/arc/radio-cards/radio-cards.module.css +++ b/src/renderer/src/components/arc/radio-cards/radio-cards.module.css @@ -18,8 +18,8 @@ z-index: 1; top: 0; left: 0; - border: 1.5px solid var(--control-on); - border-radius: 20px; + border: 2px solid var(--control-on); + border-radius: 10px; pointer-events: none; } @@ -31,7 +31,7 @@ gap: 4px 12px; padding: 14px 16px; border: 1px solid var(--border); - border-radius: 20px; + border-radius: 10px; background: var(--surface); cursor: pointer; user-select: none; diff --git a/src/renderer/src/components/arc/search-field/search-field.module.css b/src/renderer/src/components/arc/search-field/search-field.module.css index ebcd097cd..4c2e56c62 100644 --- a/src/renderer/src/components/arc/search-field/search-field.module.css +++ b/src/renderer/src/components/arc/search-field/search-field.module.css @@ -1,9 +1,10 @@ .field { display: grid; gap: var(--space-2); min-width: 0; } .field label { font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); } /* Focus darkens the border; the shell never changes size. */ -.shell { display: flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-2); border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 var(--space-3); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard); } -@media (hover: hover) and (pointer: fine) { .shell:hover:not(:focus-within) { border-color: var(--foreground); } } -.shell:focus-within { border-color: var(--foreground); } +.shell { display: flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-2); /* NSSearchField:灰底圆角,聚焦时才变白并亮起焦点环 */ + border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 var(--space-3); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } +@media (hover: hover) and (pointer: fine) { .shell:hover:not(:focus-within) { border-color: color-mix(in srgb, var(--foreground) 24%, transparent); } } +.shell:focus-within { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--focus-outline); } /* The glass wakes up with the field: muted at rest, full contrast while searching. */ .shell > svg { flex: 0 0 auto; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); } .shell:focus-within > svg, .shell[data-filled="true"] > svg { color: var(--foreground); } diff --git a/src/renderer/src/components/arc/segmented-control/segmented-control.module.css b/src/renderer/src/components/arc/segmented-control/segmented-control.module.css index 826a1d9d6..f855beb98 100644 --- a/src/renderer/src/components/arc/segmented-control/segmented-control.module.css +++ b/src/renderer/src/components/arc/segmented-control/segmented-control.module.css @@ -1,14 +1,15 @@ -.root { display: inline-flex; min-width: 0; max-width: 100%; flex-shrink: 1; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); } +.root { display: inline-flex; min-width: 0; max-width: 100%; flex-shrink: 1; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--surface); } /* The track owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */ -.track { --fade-start: 0px; --fade-end: 0px; isolation: isolate; display: flex; min-width: 0; gap: 2px; overflow-x: auto; overscroll-behavior-x: contain; padding: 3px; border-radius: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); } +.track { --fade-start: 0px; --fade-end: 0px; isolation: isolate; display: flex; min-width: 0; gap: 2px; overflow-x: auto; overscroll-behavior-x: contain; padding: 2px; border-radius: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); } .track::-webkit-scrollbar { display: none; } .track[data-fade-start] { --fade-start: 20px; } .track[data-fade-end] { --fade-end: 20px; } /* Items never scale or change weight; only the highlight travels and the label color follows it. */ -.button { position: relative; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - 4px); padding: 0 12px; background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); } +.button { position: relative; flex: 0 0 auto; min-height: 22px; border: 0; border-radius: 999px; padding: 0 12px; background: transparent; color: var(--text-secondary); font-size: 12px; font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); } @media (hover: hover) and (pointer: fine) { .button:hover { color: var(--foreground); } } .button:active, .button[aria-pressed="true"] { color: var(--foreground); } -.selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; background: var(--surface); box-shadow: var(--shadow-resting); } +.selection { position: absolute; z-index: -1; inset: 0; border: 0; border-radius: inherit; background: var(--color-surface-input); box-shadow: 0 0 0 .5px rgb(0 0 0 / .06), 0 1px 2px rgb(0 0 0 / .12); } +:root[data-theme="dark"] .selection { background: color-mix(in srgb, var(--foreground) 24%, transparent); box-shadow: inset 0 .5px 0 rgb(255 255 255 / .1), 0 1px 2px rgb(0 0 0 / .3); } .label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 6px; } @media (max-width: 380px) { .button { padding: 0 10px; } } @media (prefers-reduced-motion: reduce) { .button { transition: none; } } diff --git a/src/renderer/src/components/arc/select/select.module.css b/src/renderer/src/components/arc/select/select.module.css index 6da66f56c..9a79a7d0c 100644 --- a/src/renderer/src/components/arc/select/select.module.css +++ b/src/renderer/src/components/arc/select/select.module.css @@ -1,29 +1,31 @@ .field { display: grid; gap: var(--space-2); min-width: 0; } .field label { font-size: var(--text-sm); font-weight: 500; } /* The trigger anchors the floating menu. Radix opens on pointerdown and measures this box, so it never scales: press feedback is color only. */ -.trigger { position: relative; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%; min-height: var(--control-height-md); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-3); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } -@media (hover: hover) and (pointer: fine) { .trigger:hover:not([data-disabled]) { border-color: var(--border-strong); background: var(--surface-muted); } } -.trigger:active:not([data-disabled]), .trigger[data-state="open"] { border-color: var(--border-strong); background: var(--surface-muted); } -.trigger:focus-visible { border-color: var(--border-strong); } +.trigger { position: relative; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%; min-height: var(--control-height-md); border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 var(--space-3) 0 14px; background: var(--surface); color: var(--foreground); font: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } +@media (hover: hover) and (pointer: fine) { .trigger:hover:not([data-disabled]) { background: color-mix(in srgb, var(--surface), var(--foreground) 4%); } } +.trigger:active:not([data-disabled]), .trigger[data-state="open"] { background: color-mix(in srgb, var(--surface), var(--foreground) 8%); } +.trigger:focus-visible { box-shadow: 0 0 0 3px var(--focus-outline); } + .trigger[data-disabled] { opacity: .5; cursor: not-allowed; } .valueText { display: grid; flex: 1; min-width: 0; grid-template-columns: minmax(0, 1fr); } .valueText > span { grid-area: 1 / 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .valueText > [data-placeholder] { color: var(--text-muted); } .srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } -.chevron { display: inline-flex; flex: 0 0 auto; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); } +.chevron { display: inline-flex; flex: 0 0 auto; color: var(--text-secondary); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); } .trigger[data-state="open"] .chevron { transform: rotate(180deg); } .hint { color: var(--text-muted); font-size: var(--text-xs); } /* Radix holds the animation until the menu is positioned, so it always grows from the trigger edge. */ -.content { --menu-x: 0px; --menu-y: -6px; box-sizing: border-box; z-index: 1000; width: var(--radix-select-trigger-width); min-width: var(--radix-select-trigger-width); max-width: min(24rem, calc(100vw - 20px)); max-height: min(320px, var(--radix-select-content-available-height)); overflow: hidden; border: 1px solid var(--border); border-radius: calc(var(--radius-control) + 2px); padding: 6px; background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); transform-origin: var(--radix-select-content-transform-origin); animation: select-in var(--duration-standard) var(--ease-enter) both; } +.content { --menu-x: 0px; --menu-y: -6px; box-sizing: border-box; z-index: 1000; width: var(--radix-select-trigger-width); min-width: var(--radix-select-trigger-width); max-width: min(24rem, calc(100vw - 20px)); max-height: min(320px, var(--radix-select-content-available-height)); overflow: hidden; border: 0; border-radius: 10px; padding: 5px; background: var(--material-menu); -webkit-backdrop-filter: var(--material-blur); backdrop-filter: var(--material-blur); color: var(--foreground); box-shadow: var(--shadow-floating); transform-origin: var(--radix-select-content-transform-origin); animation: select-in var(--duration-standard) var(--ease-enter) both; } .content[data-side="top"] { --menu-y: 6px; } .content[data-side="left"] { --menu-x: 6px; --menu-y: 0px; } .content[data-side="right"] { --menu-x: -6px; --menu-y: 0px; } .content[data-state="closed"] { animation: select-out var(--duration-instant) var(--ease-standard) both; } .viewport { padding: 2px 0; } -.item { position: relative; display: flex; align-items: center; min-height: 36px; border-radius: calc(var(--radius-control) - 4px); padding: 0 34px 0 11px; color: var(--foreground); font-size: var(--text-sm); cursor: default; outline: none; user-select: none; transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-instant) var(--ease-standard); } -.item[data-highlighted] { background: var(--surface-muted); } +.item { position: relative; display: flex; align-items: center; min-height: 24px; border-radius: 5px; padding: 0 28px 0 9px; color: var(--foreground); font-size: 13px; cursor: default; outline: none; user-select: none; transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-instant) var(--ease-standard); } +.item[data-highlighted] { background: var(--accent); color: var(--accent-foreground); } +.item[data-highlighted] .indicator { color: var(--accent-foreground); } .item[data-disabled] { opacity: .45; } -.indicator { position: absolute; right: 10px; display: inline-flex; align-items: center; color: var(--foreground); animation: indicator-in var(--duration-standard) var(--ease-enter) 40ms both; } +.indicator { position: absolute; right: 8px; display: inline-flex; align-items: center; color: var(--foreground); animation: indicator-in var(--duration-standard) var(--ease-enter) 40ms both; } .scrollButton { display: grid; height: 28px; place-items: center; color: var(--text-muted); } @keyframes select-in { from { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); } } @keyframes select-out { to { opacity: 0; transform: translate(calc(var(--menu-x) / 2), calc(var(--menu-y) / 2)) scale(.98); } } diff --git a/src/renderer/src/components/arc/slider/slider.module.css b/src/renderer/src/components/arc/slider/slider.module.css index 39548600d..1c7ced375 100644 --- a/src/renderer/src/components/arc/slider/slider.module.css +++ b/src/renderer/src/components/arc/slider/slider.module.css @@ -1,4 +1,4 @@ -.root { --slider-thumb: 22px; --slider-track: 6px; display: grid; width: 100%; min-width: 0; color: var(--foreground); font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); user-select: none; -webkit-user-select: none; } +.root { --slider-thumb: 20px; --slider-track: 4px; display: grid; width: 100%; min-width: 0; color: var(--foreground); font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); user-select: none; -webkit-user-select: none; } .header { display: flex; min-width: 0; align-items: baseline; justify-content: space-between; gap: var(--space-4); } .label { min-width: 0; font-weight: 500; } .readout { display: inline-flex; flex: none; align-items: center; gap: .3em; color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; } @@ -25,7 +25,7 @@ the track, written each frame, and cqw measures the track, so the clamp needs no JavaScript measuring. It floats higher on touch so a fingertip never hides it. */ .bubbleAnchor { --edge: calc(var(--slider-thumb) / 2); --x: calc(var(--at, 0) * 1cqw); position: absolute; right: 0; bottom: calc(50% + var(--slider-thumb) / 2 + 8px); display: flex; translate: clamp(calc(100% - var(--edge) - var(--x)), 50%, calc(100cqw + var(--edge) - var(--x))) 0; pointer-events: none; } @media (pointer: coarse) { .bubbleAnchor { bottom: calc(50% + var(--slider-thumb) / 2 + 18px); } } -.bubble { display: inline-flex; min-height: 1.75rem; align-items: center; padding: 0 var(--space-3); border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-pill); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; transform-origin: 50% 100%; } +.bubble { display: inline-flex; min-height: 1.5rem; align-items: center; padding: 0 var(--space-2); border: 0; border-radius: 6px; background: var(--material-menu); -webkit-backdrop-filter: var(--material-blur); backdrop-filter: var(--material-blur); color: var(--foreground); box-shadow: var(--shadow-raised); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; transform-origin: 50% 100%; } /* Mark labels sit on their ticks; one at a limit keeps its outer edge inside the component. */ .marks { position: relative; grid-area: 2 / 2; height: 1.25rem; margin: calc(var(--space-1) * -1) calc(var(--slider-thumb) / 2) 0; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; } .markLabel { position: absolute; top: 0; translate: -50% 0; white-space: nowrap; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); } diff --git a/src/renderer/src/components/arc/switch/switch.module.css b/src/renderer/src/components/arc/switch/switch.module.css index f98ca33a3..95484a144 100644 --- a/src/renderer/src/components/arc/switch/switch.module.css +++ b/src/renderer/src/components/arc/switch/switch.module.css @@ -1,12 +1,13 @@ .switch { display: inline-flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-3); padding: 0; border: 0; color: var(--foreground); background: transparent; font: inherit; font-size: var(--text-sm); cursor: pointer; -webkit-tap-highlight-color: transparent; } .switch:disabled { cursor: not-allowed; opacity: .5; } /* Fixed box: the thumb moves inside it with transforms, so nothing around the switch shifts. The track crossfades between the two fills. */ -.track { position: relative; box-sizing: border-box; display: flex; flex: 0 0 auto; width: 42px; height: 24px; align-items: center; padding: 3px; border-radius: var(--radius-pill); background: var(--control-track); isolation: isolate; transition: background-color var(--duration-fast) var(--ease-standard); } +.track { position: relative; box-sizing: border-box; display: flex; flex: 0 0 auto; width: 38px; height: 22px; align-items: center; padding: 2px; border-radius: var(--radius-pill); background: var(--control-track); box-shadow: inset 0 0 0 .5px rgb(0 0 0 / .06); isolation: isolate; transition: background-color var(--duration-fast) var(--ease-standard); } .track::before { content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: var(--control-on); opacity: 0; transition: opacity var(--duration-standard) var(--ease-standard); } .switch[data-state="checked"] .track::before { opacity: 1; } .thumb { flex: 0 0 auto; width: 18px; height: 18px; border-radius: var(--radius-pill); background: var(--control-thumb); box-shadow: var(--control-thumb-shadow); will-change: transform; transition: background-color var(--duration-standard) var(--ease-standard); } .switch[data-state="checked"] .thumb { background: var(--control-thumb-on); } @media (hover: hover) and (pointer: fine) { .switch[data-state="unchecked"]:hover:not(:disabled) .track { background: var(--control-track-hover); } } .switch:focus-visible { outline: none; } +.switch:focus-visible .track { box-shadow: 0 0 0 3px var(--focus-outline); } .label { line-height: var(--leading-body); } @media (prefers-reduced-motion: reduce) { .track, .track::before, .thumb { transition: none; } } diff --git a/src/renderer/src/components/arc/textarea/textarea.module.css b/src/renderer/src/components/arc/textarea/textarea.module.css index 337fbe947..c009f37ae 100644 --- a/src/renderer/src/components/arc/textarea/textarea.module.css +++ b/src/renderer/src/components/arc/textarea/textarea.module.css @@ -1,10 +1,10 @@ .field { display: grid; min-width: 0; } .field label { margin-bottom: var(--space-2); font-size: var(--text-sm); font-weight: 500; } /* Same focus language as Input: the ring contracts onto the border while it fades in, and the box never changes size. */ -.control { width: 100%; min-height: 110px; resize: vertical; border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: var(--space-3); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); outline: none; box-shadow: 0 0 0 6px transparent; transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); } +.control { width: 100%; min-height: 110px; resize: vertical; border: 1px solid var(--border-strong); border-radius: 12px; padding: var(--space-2) var(--space-3); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); outline: none; box-shadow: 0 0 0 3px transparent; transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); } .control::placeholder { color: var(--text-muted); } -@media (hover: hover) and (pointer: fine) { .control:hover:not(:disabled, [aria-invalid="true"]) { border-color: var(--foreground); } } -.control:focus-visible { border-color: var(--foreground); box-shadow: 0 0 0 3px var(--focus-ring); outline: 2px solid transparent; outline-offset: 2px; } +@media (hover: hover) and (pointer: fine) { .control:hover:not(:disabled, [aria-invalid="true"]) { border-color: var(--border-strong); } } +.control:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-outline); outline: 2px solid transparent; outline-offset: 2px; } .control[aria-invalid="true"] { border-color: var(--danger); } .control:disabled { cursor: not-allowed; background: var(--surface-muted); opacity: .6; } /* The row's space is padding inside the clipped slot, so the height animates from a true zero. */ diff --git a/src/renderer/src/components/arc/toast-stack/toast-stack.module.css b/src/renderer/src/components/arc/toast-stack/toast-stack.module.css index b97f23389..781a4f1f4 100644 --- a/src/renderer/src/components/arc/toast-stack/toast-stack.module.css +++ b/src/renderer/src/components/arc/toast-stack/toast-stack.module.css @@ -11,10 +11,10 @@ .list { position: relative; margin: 0; padding: 0; list-style: none; pointer-events: auto; } /* Tucked toasts scale from their top edge, so exactly the peek shows above the toast in front. */ .item { position: absolute; right: 0; bottom: 0; left: 0; transform-origin: 50% 0; } -.card { position: absolute; inset: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-raised); container-type: inline-size; touch-action: pan-y; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard); } +.card { position: absolute; inset: 0; overflow: hidden; border: 0; border-radius: 14px; background: var(--material-menu); -webkit-backdrop-filter: var(--material-blur); backdrop-filter: var(--material-blur); color: var(--foreground); box-shadow: var(--shadow-raised); container-type: inline-size; touch-action: pan-y; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard); } .item[data-front="true"] .card, .item[data-expanded="true"] .card { box-shadow: var(--shadow-floating); } /* Tucked toasts sit a step back in tone as well as scale. */ -.item[data-front="false"][data-expanded="false"] .card { background: var(--surface-muted); } +.item[data-front="false"][data-expanded="false"] .card { background: color-mix(in srgb, var(--color-surface-raised) 92%, var(--foreground)); } .card[data-dragging] { cursor: grabbing; user-select: none; } .content { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4); } @@ -37,10 +37,10 @@ .actionSlot { display: flex; flex: none; justify-content: flex-end; margin-top: -4px; } .actionSlot[data-moving="true"] { overflow: hidden; } .action, .close { flex: none; border: 1px solid transparent; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: scale var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); } -.action { height: 28px; padding: 0 var(--space-3); border-color: var(--border); border-radius: var(--radius-pill); background: var(--surface); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; } +.action { height: 24px; padding: 0 var(--space-3); border-color: transparent; border-radius: var(--radius-control); background: var(--surface); box-shadow: var(--shadow-resting); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; } .close { display: grid; width: 28px; height: 28px; margin: -4px -2px 0 0; place-items: center; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); } @media (hover: hover) and (pointer: fine) { - .action:hover { border-color: var(--border-strong); background: var(--surface-muted); } + .action:hover { background: color-mix(in srgb, var(--surface), var(--foreground) 5%); } .close:hover { background: var(--surface-muted); color: var(--foreground); } } /* Quick press, spring release. */ diff --git a/src/renderer/src/components/arc/tooltip/tooltip.module.css b/src/renderer/src/components/arc/tooltip/tooltip.module.css index 92e905dc5..dc5745027 100644 --- a/src/renderer/src/components/arc/tooltip/tooltip.module.css +++ b/src/renderer/src/components/arc/tooltip/tooltip.module.css @@ -1,4 +1,4 @@ -.tooltip { --tooltip-y: 3px; z-index: 90; max-width: 15rem; padding: var(--space-3) var(--space-4); border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); } +.tooltip { --tooltip-y: 3px; z-index: 90; max-width: 15rem; padding: 4px 8px; border: 0; border-radius: 6px; background: var(--material-menu); -webkit-backdrop-filter: var(--material-blur); backdrop-filter: var(--material-blur); color: var(--foreground); box-shadow: var(--shadow-raised); font-size: 12px; font-weight: 400; line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); } .tooltip[data-side="bottom"] { --tooltip-y: -3px; } /* The first tooltip waits, then rises a few pixels from its trigger. Within the skip window the next one only fades. Transitions instead of keyframes, so returning to the trigger while it fades out reverses the fade instead of restarting it. */ @@ -10,8 +10,8 @@ /* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */ .tooltip[data-state="closed"] { opacity: 0; transform: scale(.98); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: tooltip-exit var(--duration-instant) linear both; } .text { position: relative; display: block; overflow: clip; overflow-clip-margin: var(--space-2); } -.measure { position: absolute; top: 0; left: 0; width: max-content; max-width: calc(15rem - 2 * var(--space-4) - 2px); visibility: hidden; pointer-events: none; } -.line { display: block; width: max-content; max-width: calc(15rem - 2 * var(--space-4) - 2px); } +.measure { position: absolute; top: 0; left: 0; width: max-content; max-width: calc(15rem - 16px); visibility: hidden; pointer-events: none; } +.line { display: block; width: max-content; max-width: calc(15rem - 16px); } @keyframes tooltip-exit { to { --tooltip-exit: 1; } } @media (prefers-reduced-motion: reduce) { .tooltip, .tooltip[data-state="closed"], .tooltip[data-instant][data-state$="-open"] { transform: none; transition: opacity var(--duration-instant) var(--ease-standard); } diff --git a/src/renderer/src/components/code/code.css b/src/renderer/src/components/code/code.css index 31f74a6b5..0579807aa 100644 --- a/src/renderer/src/components/code/code.css +++ b/src/renderer/src/components/code/code.css @@ -11,23 +11,23 @@ * 而且全程零报错。`.agent-markdown` 一并算作用域:markdown 正文里的围栏 * 不必再多包一层。 * - * 这些值原封不动来自 markdown.css(见那边留下的指路注释),不是重新量的。 + * 苹果风格分支:配色换成 Xcode 默认主题(Default Light / Default Dark)的语法色。 */ :is(.agent-markdown, .code-scope) { - --md-keyword: #c7a7e8; - --md-string: #a3c99e; - --md-number: #e1bb89; - --md-type: #8fc7d0; - --md-comment: #89938e; + --md-keyword: #ff7ab2; + --md-string: #ff8170; + --md-number: #d9c97c; + --md-type: #5dd8ff; + --md-comment: #7f8c98; } :root[data-theme='light'] :is(.agent-markdown, .code-scope) { - --md-keyword: #7953a1; - --md-string: #386a46; - --md-number: #985524; - --md-type: #2c687b; - --md-comment: #6a7770; + --md-keyword: #ad3da4; + --md-string: #c41a16; + --md-number: #1c00cf; + --md-type: #0b4f79; + --md-comment: #5d6c79; } :is(.agent-markdown, .code-scope) .tok-keyword, diff --git a/src/renderer/src/components/ui/ChoiceCards.tsx b/src/renderer/src/components/ui/ChoiceCards.tsx index 698affe15..8df2a5ac0 100644 --- a/src/renderer/src/components/ui/ChoiceCards.tsx +++ b/src/renderer/src/components/ui/ChoiceCards.tsx @@ -23,13 +23,13 @@ export interface ChoiceOption { */ /** 行的外框。**它不是按钮** —— 可点区是里面那层,理由见 `OptionRow`。 */ -const ROW_FRAME = 'app-no-drag rounded-[8px] border transition-[background-color,border-color] duration-150 motion-reduce:transition-none' +const ROW_FRAME = 'app-no-drag rounded-[10px] border transition-[background-color,border-color] duration-150 motion-reduce:transition-none' /** * 需求:审批选项要像一列紧凑的 agent 决策项,而不是一摞彼此争抢注意力的表单卡。 * 选中态仍保留整行槽色,确保键盘高亮与真实选择都能一眼看见。 */ -const ROW_ON = 'border-accent bg-tint' +const ROW_ON = 'border-accent/50 bg-accent/10' const ROW_OFF = 'border-transparent bg-transparent hover:border-hairline hover:bg-tint-hover/55' /** 可点区的类名。外框不可点,所以这层必须自己撑满、自己接焦点环。 */ diff --git a/src/renderer/src/components/ui/ContextMenu.tsx b/src/renderer/src/components/ui/ContextMenu.tsx index fcde10397..ad70af69d 100644 --- a/src/renderer/src/components/ui/ContextMenu.tsx +++ b/src/renderer/src/components/ui/ContextMenu.tsx @@ -142,7 +142,7 @@ export function ContextMenu({ visibility: placed === null ? 'hidden' : undefined }} className={cn( - 'app-no-drag fixed z-50 rounded-panel border-0 bg-surface-menu p-1.5 shadow-menu outline-none', + 'app-no-drag fixed z-50 rounded-[10px] border-0 bg-surface-menu p-[5px] shadow-menu outline-none', 'transition-[opacity,transform,translate,scale] duration-150 ease-panel motion-reduce:transition-none motion-reduce:transform-none motion-reduce:translate-y-0 motion-reduce:scale-100', shown && !closing ? 'translate-y-0 scale-100 opacity-100' diff --git a/src/renderer/src/components/ui/Menu.tsx b/src/renderer/src/components/ui/Menu.tsx index d6afe52b1..919867d5d 100644 --- a/src/renderer/src/components/ui/Menu.tsx +++ b/src/renderer/src/components/ui/Menu.tsx @@ -258,9 +258,9 @@ export function Menu({ visibility: pos === null ? 'hidden' : undefined }} className={cn( - 'app-no-drag scroll-thin fixed z-50 overflow-y-auto rounded-[12px]', + 'app-no-drag scroll-thin fixed z-50 overflow-y-auto rounded-[10px]', align === 'end' ? 'origin-top-right' : 'origin-top-left', - 'border-0 bg-surface-menu p-1.5 shadow-menu', + 'border-0 bg-surface-menu p-[5px] shadow-menu', 'transition-[opacity,transform,translate,scale] duration-180 ease-panel motion-reduce:transition-none motion-reduce:transform-none motion-reduce:translate-y-0 motion-reduce:scale-100', presence.shown ? 'translate-y-0 scale-100 opacity-100' @@ -327,44 +327,77 @@ export function MenuItem({ onFocus={activate} onClick={onSelect} className={cn( - 'relative flex w-full items-center gap-2.5 rounded-[8px] px-2.5 py-[7px] text-left text-[13px]', - 'transition-colors disabled:opacity-40', + 'group/item relative flex w-full items-center gap-2 rounded-[5px] px-2 py-[3px] text-left text-[13px]', + 'disabled:opacity-40', + // 苹果风格:选中行是强调色实底 + 白字(NSMenu)。 // 有 provider 时由那块滑动的高亮负责;没有时退回逐行 hover - !gliding && 'hover:bg-tint-strong disabled:hover:bg-transparent', - danger ? 'text-danger' : 'text-fg' + !gliding && 'hover:bg-accent hover:text-accent-fg disabled:hover:bg-transparent', + lit ? 'text-accent-fg' : danger ? 'text-danger' : 'text-fg' )} > {lit && ( )} {icon !== undefined && ( - {icon} + + {icon} + )} {children} {description !== undefined && ( - {description} + + {description} + )} {accelerator !== undefined && ( - {accelerator} + + {accelerator} + )} {checked !== undefined && ( - + )} ) } export function MenuSeparator(): ReactNode { - return
+ return
} export function MenuLabel({ children }: { children: ReactNode }): ReactNode { - return
{children}
+ return
{children}
} diff --git a/src/renderer/src/components/ui/Select.tsx b/src/renderer/src/components/ui/Select.tsx index 8164192c2..dd3dc1ba0 100644 --- a/src/renderer/src/components/ui/Select.tsx +++ b/src/renderer/src/components/ui/Select.tsx @@ -82,12 +82,12 @@ export function Select({ aria-label={ariaLabel} disabled={disabled} className={cn( - "app-no-drag group flex h-7 w-full items-center gap-1.5 rounded-[9px] border border-border", - "bg-surface-field px-2.5 text-left text-[11.5px] text-fg outline-none", + "app-no-drag group flex h-7 w-full items-center gap-1.5 rounded-full border border-[var(--border-strong)]", + "bg-[var(--surface)] px-3 text-left text-[12px] text-fg outline-none", "transition-[background-color,border-color,box-shadow,border-radius] duration-150", - "hover:bg-tint focus-visible:border-fg-faint focus-visible:ring-2 focus-visible:ring-fg-faint/15", - // 展开时朝向面板的那条边先收平,面板再分离出去 —— 两者像是一整块被捏开 - "data-[state=open]:border-fg-faint data-[state=open]:bg-tint data-[state=open]:rounded-b-[4px]", + "hover:bg-tint focus-visible:ring-3 focus-visible:ring-accent/50", + // 苹果风格:弹出按钮展开时只压暗一档,不再改圆角 + "data-[state=open]:bg-tint", className, )} > @@ -113,8 +113,8 @@ export function Select({ className={cn( "app-no-drag max-h-[min(240px,var(--radix-select-content-available-height))]", inModal ? "z-[150]" : "z-[60]", - "w-[var(--radix-select-trigger-width)] overflow-hidden rounded-[12px] border-0", - "bg-surface-menu p-1.5 shadow-menu outline-none", + "w-[var(--radix-select-trigger-width)] overflow-hidden rounded-[10px] border-0", + "bg-surface-menu p-[5px] shadow-menu outline-none", // 入场:从触发器那一侧轻轻展开。transform-origin 用 Radix 算好的锚点,翻到上方时自动跟着翻 "origin-[var(--radix-select-content-transform-origin)]", "data-[state=open]:animate-[select-unfold_0.22s_cubic-bezier(0.16,1,0.3,1)] motion-reduce:animate-none", @@ -135,14 +135,14 @@ export function Select({ {option.label} - + diff --git a/src/renderer/src/components/ui/Submenu.tsx b/src/renderer/src/components/ui/Submenu.tsx index 39e328424..dd2c697c4 100644 --- a/src/renderer/src/components/ui/Submenu.tsx +++ b/src/renderer/src/components/ui/Submenu.tsx @@ -90,7 +90,7 @@ export function Submenu({ // 量到位置之前先渲染出来(否则量不到高度),但别让人看见左上角那一帧 visibility: placed === null ? 'hidden' : undefined }} - className="app-no-drag scroll-thin fixed z-[60] overflow-y-auto rounded-panel border-0 bg-surface-menu p-1.5 shadow-menu" + className="app-no-drag scroll-thin fixed z-[60] overflow-y-auto rounded-[10px] border-0 bg-surface-menu p-[5px] shadow-menu" > {children}
, diff --git a/src/renderer/src/components/ui/TextArea.tsx b/src/renderer/src/components/ui/TextArea.tsx index 66e693c00..0c2fe97ca 100644 --- a/src/renderer/src/components/ui/TextArea.tsx +++ b/src/renderer/src/components/ui/TextArea.tsx @@ -50,7 +50,7 @@ export function TextArea({ onChange={(e) => setDraft(e.target.value)} onBlur={() => commit(onCommit)} className={cn( - 'app-no-drag selectable w-full resize-none rounded-[8px] border border-border', + 'apple-field app-no-drag selectable w-full resize-none rounded-[8px] border border-border', 'bg-surface-field px-2.5 py-2 text-[13px] leading-[1.6] text-fg outline-none', 'transition-colors placeholder:text-fg-faint focus:border-fg-faint disabled:opacity-40', className diff --git a/src/renderer/src/components/ui/TextInput.tsx b/src/renderer/src/components/ui/TextInput.tsx index 78c8e1e73..4d8f5badc 100644 --- a/src/renderer/src/components/ui/TextInput.tsx +++ b/src/renderer/src/components/ui/TextInput.tsx @@ -84,7 +84,7 @@ export function TextInput({ ref={fieldRef} data-state={hasError ? 'error' : success ? 'success' : 'idle'} className={cn( - 'app-no-drag flex items-center gap-2 rounded-[9px] border transition-[border-color,box-shadow] duration-200', + 'apple-field app-no-drag flex items-center gap-2', 'bg-surface-field', size === 'sm' ? 'h-7 px-2' : 'h-8 px-2.5', hasError diff --git a/src/renderer/src/i18n/agent.ts b/src/renderer/src/i18n/agent.ts index 50ead9e4e..0cbe64b74 100644 --- a/src/renderer/src/i18n/agent.ts +++ b/src/renderer/src/i18n/agent.ts @@ -212,6 +212,31 @@ export const agentZh = { 'agent.error.pinnedProviderDisabled': '供应商「{provider}」已停用,「{model}」不会自动切到其它供应商。请启用它,或另选一个模型。', 'agent.error.pinnedModelMissing': '供应商「{provider}」下已经没有可用的模型「{model}」,请重新选择。', + // 右上角活动胶囊:任务清单 / 子代理 / 后台命令三栏(见 views/chat/ActivityCapsule.tsx) + 'activity.open': '后台活动:{summary}', + 'activity.title': '后台活动', + 'activity.tab.tasks': '任务', + 'activity.tab.agents': 'Agent', + 'activity.tab.shells': 'Bash', + 'activity.summary.tasks': '任务 {done}/{total}', + 'activity.summary.agents': '{count} 个 Agent 在运行', + 'activity.summary.shells': '{count} 条后台命令在运行', + 'activity.empty.tasks': '这个对话还没有任务清单', + 'activity.empty.agents': '这个对话还没有派出子代理', + 'activity.empty.shells': '这个项目没有后台命令', + 'activity.view': '查看', + 'activity.stop': '停止', + 'activity.output': '输出', + 'activity.hideOutput': '收起', + 'activity.noOutput': '还没有输出', + 'activity.background': '后台', + 'activity.status.running': '运行中', + 'activity.status.done': '已完成', + 'activity.status.error': '出错', + 'activity.status.stopped': '已停止', + 'activity.status.exited': '已退出 · {code}', + 'activity.status.exitedNoCode': '已退出', + 'activity.status.failed': '启动失败', } export const agentEn: Record = { @@ -400,6 +425,30 @@ export const agentEn: Record = { 'Provider “{provider}” is disabled. “{model}” will not fall back to another provider. Enable it, or pick a different model.', 'agent.error.pinnedModelMissing': 'Provider “{provider}” no longer offers the model “{model}”. Pick a model again.', + 'activity.open': 'Background activity: {summary}', + 'activity.title': 'Background activity', + 'activity.tab.tasks': 'Tasks', + 'activity.tab.agents': 'Agents', + 'activity.tab.shells': 'Bash', + 'activity.summary.tasks': 'Tasks {done}/{total}', + 'activity.summary.agents': '{count} agent(s) running', + 'activity.summary.shells': '{count} background command(s) running', + 'activity.empty.tasks': 'No task checklist in this conversation', + 'activity.empty.agents': 'No subagents in this conversation', + 'activity.empty.shells': 'No background commands in this project', + 'activity.view': 'Open', + 'activity.stop': 'Stop', + 'activity.output': 'Output', + 'activity.hideOutput': 'Hide', + 'activity.noOutput': 'No output yet', + 'activity.background': 'Background', + 'activity.status.running': 'Running', + 'activity.status.done': 'Done', + 'activity.status.error': 'Error', + 'activity.status.stopped': 'Stopped', + 'activity.status.exited': 'Exited · {code}', + 'activity.status.exitedNoCode': 'Exited', + 'activity.status.failed': 'Failed to start', } export function agentErrorText(error: AgentError, t: Translate): string { diff --git a/src/renderer/src/i18n/device.ts b/src/renderer/src/i18n/device.ts index 116dc7c2b..d0d027c47 100644 --- a/src/renderer/src/i18n/device.ts +++ b/src/renderer/src/i18n/device.ts @@ -3,6 +3,7 @@ export const deviceZh = { 'devices.title': '已配对设备', 'devices.scopeNote': '选择设备后,当前版本只把设置、工作区列表/改名/关闭、文件浏览/读写、会话列表/创建/翻页、技能/命令/子代理/模式/钩子/MCP 列表,以及 Git 的查看、暂存、提交和分支操作转到该设备;纯文本任务的发起、事件显示、停止与审批、终端也会转到该设备;带附件、子代理等其余需要设备的操作会被拒绝,而不是在本机执行。设备离线时同样如此,不会回退到本机。重启后回到这台电脑。这里的清单与密钥只保存在这台电脑上。', 'devices.empty': '还没有配对任何设备。', + 'devices.emptyHint': '在另一台机器上装好服务并生成邀请,粘到下面的输入框就能配对;安装步骤也在那一段里。', 'devices.thisComputer': '这台电脑', 'devices.thisComputerHint': '使用本机运行任务', 'devices.current': '当前', @@ -26,6 +27,18 @@ export const deviceZh = { 'devices.removeConfirm': '确认移除“{name}”的连接?', 'devices.add': '添加设备', 'devices.addDescription': '在设备上生成配对邀请(以 ncwpair1. 开头的文本),粘贴到这里。邀请 5 分钟内有效,只能用一次。', + 'devices.installTitle': '还没在别的机器上装服务?', + 'devices.installHint': '装好之后那台机器就是一台设备:Agent、数据库、工作区、Git 与终端都跑在它上面,关掉这台桌面也不停。', + 'devices.installStep1': '在新设备上安装(Node.js 24 或更新)', + 'devices.installStep1Note': '装完会多出 ncw-device-service 命令。npm 不通的机器可以直接装 Release 里的 tgz。', + 'devices.installStep2': '先自检这台机器能不能跑', + 'devices.installStep2Note': '它真的拉起一个 shell、真开一个内存库、真写一次数据目录;有红项先解决再往下走。', + 'devices.installStep3': '启动服务,并让它打印一条配对邀请', + 'devices.installStep3Note': '结尾的 --invite 让服务启动后签发一条 5 分钟、只能用一次的邀请,打印在它的输出里。要常驻就把两个端口写死(默认 0 是每次启动随机分配);Windows 上把 ~/ncw-device 换成实际路径。', + 'devices.installFinish': '把打印出来的 ncwpair1.… 粘到上面的输入框,先点「检查邀请」核对指纹,一致再配对。', + 'devices.installMore': '开机自启(systemd / LaunchDaemon / WinSW)、从已有桌面数据根原地接管、排障,都写在包的 README 里(与本仓库的 docs/device-service.md 是同一份)。', + 'devices.copied': '已复制', + 'devices.copyFailed': '复制失败,请手动选中命令', 'devices.invitation': '配对邀请', 'devices.invitationPlaceholder': 'ncwpair1.…', 'devices.inspect': '检查邀请', @@ -48,6 +61,7 @@ export const deviceEn: Record = { 'devices.title': 'Paired devices', 'devices.scopeNote': 'After you select a device, this version only forwards settings, the workspace list, renaming and closing workspaces, file browsing, reading and writing, the session list, creation and paging, the skill/command/agent/mode/hook/MCP lists, and Git viewing, staging, committing and branching to it. Starting plain-text tasks, showing their events, stopping them, approvals and terminals also go to the device; other actions that need a device (attachments, sub-agents and so on) are refused instead of running on this computer, and the same applies while the device is offline. The app returns to this computer after a restart. The list and keys stay on this computer only.', 'devices.empty': 'No devices are paired yet.', + 'devices.emptyHint': 'Install the service on another machine, generate an invitation and paste it below to pair — the install steps are in that section too.', 'devices.thisComputer': 'This computer', 'devices.thisComputerHint': 'Run tasks on this machine', 'devices.current': 'Current', @@ -71,6 +85,18 @@ export const deviceEn: Record = { 'devices.removeConfirm': 'Remove the connection to "{name}"?', 'devices.add': 'Add a device', 'devices.addDescription': 'Create a pairing invitation on the device (text starting with ncwpair1.) and paste it here. An invitation is valid for 5 minutes and works once.', + 'devices.installTitle': 'Nothing installed on another machine yet?', + 'devices.installHint': 'Once it is installed, that machine is a device: the agent, the database, the workspaces, Git and the terminals run there and keep running after you close this desktop.', + 'devices.installStep1': 'Install it on the new machine (Node.js 24 or newer)', + 'devices.installStep1Note': 'This adds the ncw-device-service command. On a machine without npm, install the tgz from the Release instead.', + 'devices.installStep2': 'Check that the machine can run it', + 'devices.installStep2Note': 'It really spawns a shell, opens an in-memory database and writes to the data directory; fix any red item before going on.', + 'devices.installStep3': 'Start the service and have it print an invitation', + 'devices.installStep3Note': 'The trailing --invite makes it issue an invitation valid for 5 minutes and usable once, printed in its output. To keep it running, pin both ports (the default 0 picks a new one on every start); on Windows replace ~/ncw-device with a real path.', + 'devices.installFinish': 'Paste the printed ncwpair1.… into the field above, click Check invitation and compare the fingerprint before pairing.', + 'devices.installMore': 'Starting at boot (systemd / LaunchDaemon / WinSW), taking over an existing desktop data root and troubleshooting are all in the package README (the same document as docs/device-service.md in this repository).', + 'devices.copied': 'Copied', + 'devices.copyFailed': 'Copy failed — select the command manually', 'devices.invitation': 'Pairing invitation', 'devices.invitationPlaceholder': 'ncwpair1.…', 'devices.inspect': 'Check invitation', diff --git a/src/renderer/src/i18n/index.tsx b/src/renderer/src/i18n/index.tsx index 9e505b70d..b040267ec 100644 --- a/src/renderer/src/i18n/index.tsx +++ b/src/renderer/src/i18n/index.tsx @@ -1614,6 +1614,11 @@ const ZH: Messages = { "data.cloudSyncErrorConflict": "本机与云端配置同时发生了变化,请处理冲突。", "data.cloudSyncErrorStorage": "加密同步空间暂时不可用或已达容量限制。", "data.cloudSyncErrorLocked": "请先输入同步密码解锁。", + "data.cloudSyncErrorSignedOut": "登录已失效,请重新登录 NextCoWork 账户后再试。", + "data.cloudSyncErrorInvalidData": "同步服务拒绝了这次请求,数据校验未通过。请更新到最新版本后重试。", + "data.cloudSyncErrorAccountChanged": "账户已切换,本次同步操作已取消。", + "data.cloudSyncErrorBusy": "正在处理其他操作,请稍后重试。", + "data.cloudSyncErrorUnknown": "同步失败,请稍后重试。", "data.cloudSyncReview": "首次同步预览", 'data.cloudSyncReviewHint': '请确认要合并当前设备和云端的基础配置。会话、附件和本地路径不会同步。', @@ -3625,6 +3630,11 @@ const EN: Messages = { "data.cloudSyncErrorConflict": "Local and cloud configuration changed at the same time. Resolve the conflict to continue.", "data.cloudSyncErrorStorage": "The encrypted sync space is unavailable or has reached its storage limit.", "data.cloudSyncErrorLocked": "Enter your sync password to unlock sync.", + "data.cloudSyncErrorSignedOut": "Your session has expired. Sign in to your NextCoWork account again and retry.", + "data.cloudSyncErrorInvalidData": "The sync service rejected the request because the data failed validation. Update to the latest version and retry.", + "data.cloudSyncErrorAccountChanged": "The account changed, so this sync operation was cancelled.", + "data.cloudSyncErrorBusy": "Another operation is in progress. Try again shortly.", + "data.cloudSyncErrorUnknown": "Sync failed. Try again later.", "data.cloudSyncReview": "Review first sync", 'data.cloudSyncReviewHint': "Confirm whether to merge this device's basic settings with the cloud. Sessions, attachments, and local paths are never synced.", diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index c378fb57c..083d156a1 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -5,6 +5,10 @@ import './styles/theme.css' import './styles/arc-integration.css' import { I18nProvider } from './i18n' import { MigrationGateHost } from './views/MigrationGate' +import { IS_MAC } from './lib/platform' + +// 苹果风格:macOS 窗口开了 vibrancy(见 main/index.ts),界面据此把窗口底和主面板改成半透明玻璃 +if (IS_MAC) document.documentElement.dataset.vibrancy = 'true' const container = document.getElementById('root') if (!container) throw new Error('#root not found') diff --git a/src/renderer/src/services/shell.ts b/src/renderer/src/services/shell.ts index 4e6e90f32..81031be81 100644 --- a/src/renderer/src/services/shell.ts +++ b/src/renderer/src/services/shell.ts @@ -6,7 +6,8 @@ * 下一个人很容易顺手在停止按钮上改调 `abortRun`,而那会把模型写到一半的回复 * 一起停掉,且没有任何报错。 */ -import { invoke } from './ipc' +import type { BackgroundShellInfo } from '../../../shared/domain/shell' +import { invoke, on } from './ipc' /** * 停掉某次工具调用正在跑的命令。 @@ -26,3 +27,23 @@ export function stopToolCall(runId: string, callId: string): Promise { export function backgroundToolCall(runId: string, callId: string): Promise { return invoke('shell:backgroundToolCall', { runId, callId }) } + +/** 这个工作区里 agent 开过的后台 shell(含已结束的)。之后的变化订阅 `onBackgroundShellsChanged`。 */ +export function listBackgroundShells(workspaceId: string): Promise { + return invoke('shell:listBackground', { workspaceId }) +} + +/** 看一眼输出尾巴,**不消费**模型那份 `BashOutput` 缓冲。`null` = 已被淘汰。 */ +export function peekBackgroundShell(id: string): Promise<{ info: BackgroundShellInfo; output: string } | null> { + return invoke('shell:peekBackground', { id }) +} + +/** 停掉一个后台 shell,与模型调 `KillShell` 是同一件事。`null` = 已被淘汰。 */ +export function killBackgroundShell(id: string): Promise { + return invoke('shell:killBackground', { id }) +} + +/** 后台 shell 列表变了(全量、所有工作区)。返回退订函数。 */ +export function onBackgroundShellsChanged(callback: (shells: BackgroundShellInfo[]) => void): () => void { + return on('shell:backgroundChanged', ({ shells }) => callback(shells)) +} diff --git a/src/renderer/src/settings/SettingsOverlay.tsx b/src/renderer/src/settings/SettingsOverlay.tsx index 2bd4f0d1c..90fe51afe 100644 --- a/src/renderer/src/settings/SettingsOverlay.tsx +++ b/src/renderer/src/settings/SettingsOverlay.tsx @@ -160,15 +160,15 @@ export function SettingsOverlay({ aria-modal="true" aria-label={t('common.settings')} > - {/* 纯黑 35% —— 这个数是从参考图反解出来的,见 theme.css 的 --color-scrim */} -
+ {/* 苹果风格:轻遮罩、不模糊 —— 面板自己是毛玻璃,背景糊掉反而看不出玻璃感 */} +
- + {pageLabel(t, p.id)} diff --git a/src/renderer/src/settings/pages/DataPage.tsx b/src/renderer/src/settings/pages/DataPage.tsx index b31c85022..0fe5b155e 100644 --- a/src/renderer/src/settings/pages/DataPage.tsx +++ b/src/renderer/src/settings/pages/DataPage.tsx @@ -828,6 +828,12 @@ function syncErrorMessage( if (code === "conflict") return t("data.cloudSyncErrorConflict"); if (code === "storage") return t("data.cloudSyncErrorStorage"); if (code === "locked") return t("data.cloudSyncErrorLocked"); + if (code === "signedOut") return t("data.cloudSyncErrorSignedOut"); + if (code === "invalidData") return t("data.cloudSyncErrorInvalidData"); + if (code === "accountChanged") return t("data.cloudSyncErrorAccountChanged"); + if (code === "busy") return t("data.cloudSyncErrorBusy"); + // 其余 configSync.* 是内部错误码,不能把 i18n key 原样丢给用户 + if (code !== null) return t("data.cloudSyncErrorUnknown"); return message; } @@ -1403,7 +1409,7 @@ function PreviewCounts({ {rows.map(([label, value]) => (
{label}
diff --git a/src/renderer/src/settings/pages/DevicesPage.tsx b/src/renderer/src/settings/pages/DevicesPage.tsx index 419909a01..ba5026f62 100644 --- a/src/renderer/src/settings/pages/DevicesPage.tsx +++ b/src/renderer/src/settings/pages/DevicesPage.tsx @@ -1,11 +1,14 @@ -import { Laptop, Loader, MonitorSmartphone, WifiOff } from 'lucide-react' +import { Check, ChevronDown, Copy, Laptop, Loader, MonitorSmartphone, WifiOff } from 'lucide-react' import { useEffect, useState, type ReactNode } from 'react' import type { DeviceConnectionState, DeviceInvitationPreview, DeviceSummary } from '../../../../shared/domain/device-connection' import { Button } from '../../components/arc/button/button' +import { ActionIconButton, useTransientStatus } from '../../components/ui/ActionIconButton' +import { Collapsible, CollapsibleContent, CollapsibleIndicator, CollapsibleTrigger } from '../../components/ui/Collapsible' import { TextInput } from '../../components/ui/TextInput' -import { useI18n, type Translate } from '../../i18n' +import { useI18n, type Translate, type TranslationKey } from '../../i18n' import { cn } from '../../lib/cn' import { useDeviceStore } from '../../stores/device' +import { copyText } from '../../services/app' import { inspectDeviceInvitation } from '../../services/device' import { SettingGroup } from '../Row' @@ -20,6 +23,31 @@ const STATE_TONE = { const errorText = (error: unknown): string => error instanceof Error ? error.message : String(error) +/** + * 在一台新设备上把服务装起来的三步。 + * + * ★ 三条命令**原样可复制**。这一页真正会失败的地方不是点错按钮,而是照着文档敲错一条命令 —— + * 所以命令就写在这一页上,每条配一颗复制按钮,用户不必先去翻包里的 README。 + * ★ 文本与 `docs/device-service.md` 是同一份:界面上一处、文档里一处,不一致时用户会以界面上的为准。 + */ +const INSTALL_STEPS: ReadonlyArray<{ title: TranslationKey; note: TranslationKey; command: string }> = [ + { + title: 'devices.installStep1', + note: 'devices.installStep1Note', + command: 'npm i -g @aidotnet/nextcowork-device-service' + }, + { + title: 'devices.installStep2', + note: 'devices.installStep2Note', + command: 'ncw-device-service diagnose' + }, + { + title: 'devices.installStep3', + note: 'devices.installStep3Note', + command: 'ncw-device-service --data-dir ~/ncw-device --host 0.0.0.0 --port 7801 --pairing-port 7800 --invite' + } +] + /** 把指纹按 4 位一组展示,方便和设备上显示的逐位核对。 */ export const groupFingerprint = (fingerprint: string): string => fingerprint.replace(/(.{4})/g, '$1 ').trim() @@ -40,6 +68,12 @@ export function DevicesPage(): ReactNode { const load = useDeviceStore((s) => s.load) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + /* + 安装步骤的展开状态。`null` = 还没被用户点过,这时按「有没有设备」决定: + 一台都没有的人正要的就是这一段,而已经有设备的人多半是来粘贴邀请的。 + ★ 用户点过之后就以他的选择为准 —— 让清单的变化把刚展开的那段收回去,是最讨厌的一种动。 + */ + const [stepGuide, setStepGuide] = useState(null) useEffect(() => { void load() }, [load]) @@ -83,11 +117,14 @@ export function DevicesPage(): ReactNode {
  • {t('devices.empty')} + {/* 空的时候不能只留一句「还没有」—— 用户下一步该干什么,必须写在同一张卡上。 */} + {t('devices.emptyHint')}
  • )} - + {/* 一台都没有 = 打开这一段:它是这一页唯一的「从零开始」。 */} +
    ) } @@ -223,7 +260,13 @@ function DeviceRow({ device, busy, t, run }: { ) } -function AddDevice({ busy, t, run }: { busy: boolean; t: Translate; run: (action: () => Promise) => Promise }): ReactNode { +function AddDevice({ busy, t, run, guideOpen, onGuideOpenChange }: { + busy: boolean + t: Translate + run: (action: () => Promise) => Promise + guideOpen: boolean + onGuideOpenChange: (open: boolean) => void +}): ReactNode { const [text, setText] = useState('') const [preview, setPreview] = useState(null) const [label, setLabel] = useState('') @@ -232,7 +275,7 @@ function AddDevice({ busy, t, run }: { busy: boolean; t: Translate; run: (action return (

    {t('devices.addDescription')}

    -
    +
    )}
    + {/* + ★ 安装步骤放在粘贴框**下面**:已经有邀请的人先看到该粘的地方,而正要装一台新设备的人 + 看到的是展开着的一段。顺序反过来就成了「每次都先读一遍安装教程才能粘贴」。 + */} +
    + +
    ) } + +/** + * 「还没装过服务」的那一段。三步 + 三条可复制的命令 + 一句回到上面输入框的收尾。 + * + * ★ 它**不联网、也不探测**:界面判断不出你手上那台机器装没装、装的是哪个版本, + * 所以这里只写步骤,不写「已安装」这种界面猜出来的状态。 + */ +function DeviceInstallGuide({ open, onOpenChange, t }: { + open: boolean + onOpenChange: (open: boolean) => void + t: Translate +}): ReactNode { + return ( + + +
    +

    {t('devices.installTitle')}

    +

    {t('devices.installHint')}

    +
    + +
    + +
      + {INSTALL_STEPS.map((step, index) => ( +
    1. + {/* 编号只是「这是第几步」的视觉锚点,顺序本身由 `
        ` 给出。 */} + + {index + 1} + +
        +

        {t(step.title)}

        +

        {t(step.note)}

        + +
        + + ))} +
      +

      {t('devices.installFinish')}

      +

      {t('devices.installMore')}

      + + + ) +} + +/** + * 一条可复制的命令。 + * + * ★ 复制失败**不弹错误**:命令就在左边明晃晃地写着,手动选中即可 —— 为一次复制失败 + * 弹一条红字,只会让人以为这一步本身坏了(同 `ProviderPanel` 里那段)。 + */ +function CommandLine({ command, testId, t }: { command: string; testId: string; t: Translate }): ReactNode { + const [status, setStatus] = useTransientStatus() + return ( +
      + {/* 换行按词边界断(`break-words`),不是 `break-all`:命令是照着读/对照敲的, + `--pairing-port` 被切成 `--pairing-po` / `rt` 就白写了这一行。 */} + + {command} + + + { void copyText(command).then(() => setStatus('done')).catch(() => setStatus('failed')) }} + > + {status === 'done' ? : } + + +
      + ) +} diff --git a/src/renderer/src/settings/pages/GeneralPage.tsx b/src/renderer/src/settings/pages/GeneralPage.tsx index bc18d93f9..02debefdd 100644 --- a/src/renderer/src/settings/pages/GeneralPage.tsx +++ b/src/renderer/src/settings/pages/GeneralPage.tsx @@ -485,7 +485,7 @@ function RoleModelPickerComponent({ } // 视觉上照抄 `Select` 的触发器类名,这一屏其余控件才不会显得它是外来的。 triggerClassName={cn( - 'group flex h-7 w-full items-center gap-1.5 rounded-[7px] border border-border', + 'apple-field group flex h-7 w-full items-center gap-1.5 rounded-[8px] border border-border', 'bg-surface-field px-2 text-left text-[11.5px] text-fg outline-none', 'transition-[background-color,border-color,box-shadow] duration-150', 'hover:bg-tint focus-visible:border-fg-faint focus-visible:ring-2 focus-visible:ring-fg-faint/15' diff --git a/src/renderer/src/settings/pages/connection/McpServerDialog.tsx b/src/renderer/src/settings/pages/connection/McpServerDialog.tsx index 57bb3f521..327b3f6c3 100644 --- a/src/renderer/src/settings/pages/connection/McpServerDialog.tsx +++ b/src/renderer/src/settings/pages/connection/McpServerDialog.tsx @@ -338,7 +338,7 @@ export function McpServerDialog({ } onChange={(e) => set("secretsText", e.target.value)} className={cn( - "app-no-drag selectable w-full resize-none rounded-[8px] border border-hairline", + "apple-field app-no-drag selectable w-full resize-none rounded-[8px] border border-hairline", "bg-surface-field px-2.5 py-2 font-mono text-[12px] leading-[1.7] text-fg outline-none", "placeholder:text-fg-faint focus:border-fg-faint", )} diff --git a/src/renderer/src/settings/pages/connection/RemoteConnectionsPane.tsx b/src/renderer/src/settings/pages/connection/RemoteConnectionsPane.tsx index 1405818f3..30c6a579c 100644 --- a/src/renderer/src/settings/pages/connection/RemoteConnectionsPane.tsx +++ b/src/renderer/src/settings/pages/connection/RemoteConnectionsPane.tsx @@ -147,7 +147,7 @@ export function ServerEditor({ editing, hasPassword, onClose }: { editing: Conne {field(t('ssh.mode'),
      { setMode(value as 'manual' | 'config'); setConfirmed(false) }} />
      )} {field(t(mode === 'config' ? 'ssh.alias' : 'ssh.host'),
      - {mode === 'manual' && setPort(event.target.value)} className="selectable h-8 w-[72px] shrink-0 rounded-[8px] border border-border bg-surface-field px-2.5 text-[13px] text-fg outline-none focus:border-fg-faint aria-invalid:border-danger" />} + {mode === 'manual' && setPort(event.target.value)} className="apple-field selectable h-8 w-[72px] shrink-0 rounded-[8px] border border-border bg-surface-field px-2.5 text-[13px] text-fg outline-none focus:border-fg-faint aria-invalid:border-danger" />}
      , t(mode === 'config' ? 'ssh.configHint' : 'ssh.addressHint'))} {mode === 'config' && field(t('ssh.configFile'), filePicker(configFile, setConfigFile, t('ssh.configFile')))} @@ -166,7 +166,7 @@ export function ServerEditor({ editing, hasPassword, onClose }: { editing: Conne setPassword(event.target.value)} - className="selectable h-8 min-w-0 flex-1 rounded-[8px] border border-border bg-surface-field px-2.5 text-[13px] text-fg outline-none focus:border-fg-faint disabled:opacity-40" /> + className="apple-field selectable h-8 min-w-0 flex-1 rounded-[8px] border border-border bg-surface-field px-2.5 text-[13px] text-fg outline-none focus:border-fg-faint disabled:opacity-40" /> {hasPassword && !clearPassword && }

    {t(clearPassword ? 'ssh.passwordCleared' : 'ssh.passwordHint')}

    diff --git a/src/renderer/src/settings/pages/model/EnabledModelList.tsx b/src/renderer/src/settings/pages/model/EnabledModelList.tsx index eb027059f..9e82a035f 100644 --- a/src/renderer/src/settings/pages/model/EnabledModelList.tsx +++ b/src/renderer/src/settings/pages/model/EnabledModelList.tsx @@ -47,7 +47,7 @@ export function EnabledModelList({ onClick={onAdd} className={cn( "app-no-drag mt-0.5 flex size-6 shrink-0 items-center justify-center", - "rounded-[7px] text-icon transition-colors hover:bg-tint hover:text-fg", + "rounded-[8px] text-icon transition-colors hover:bg-tint hover:text-fg", )} > @@ -62,7 +62,7 @@ export function EnabledModelList({ onClick={() => onSelect(e.provider.id)} aria-current={e.provider.id === selectedId} className={cn( - "app-no-drag flex w-full items-center gap-2 rounded-[9px] px-1.5 py-2 text-left", + "app-no-drag flex w-full items-center gap-2 rounded-[8px] px-1.5 py-2 text-left", "transition-colors", e.provider.id === selectedId ? "bg-tint" : "hover:bg-tint/60", )} @@ -105,7 +105,7 @@ export function EnabledModelList({ type="button" onClick={onAdd} className={cn( - "app-no-drag mt-1 flex w-full items-center gap-1.5 rounded-[9px] px-1.5 py-2", + "app-no-drag mt-1 flex w-full items-center gap-1.5 rounded-[8px] px-1.5 py-2", "text-[12.5px] text-fg-muted transition-colors hover:bg-tint/60 hover:text-fg", )} > diff --git a/src/renderer/src/settings/pages/model/ImageModelPage.tsx b/src/renderer/src/settings/pages/model/ImageModelPage.tsx index d11355c45..ce257d6e9 100644 --- a/src/renderer/src/settings/pages/model/ImageModelPage.tsx +++ b/src/renderer/src/settings/pages/model/ImageModelPage.tsx @@ -96,7 +96,7 @@ export function ImageModelPage({ settings, patch }: Pick ) : (
    -
    +
    {t("models.imageEmptyHint")}
    @@ -223,7 +223,7 @@ function ImageGenModelRow({ } // 视觉上照抄 `Select` 的触发器类名(与 GeneralPage 的 RoleModelPicker 同款) triggerClassName={cn( - "group flex h-7 w-full items-center gap-1.5 rounded-[7px] border border-border", + "apple-field group flex h-7 w-full items-center gap-1.5 rounded-[8px] border border-border", "bg-surface-field px-2 text-left text-[11.5px] text-fg outline-none", "transition-[background-color,border-color,box-shadow] duration-150", "hover:bg-tint focus-visible:border-fg-faint focus-visible:ring-2 focus-visible:ring-fg-faint/15" @@ -250,12 +250,12 @@ function ImageProviderList({ entries, onAdd, selectedId, onSelect }: { entries:

    {t("models.imageProviders")}

    {t("models.firstIsDefault")}

    - +
      - {entries.map((entry) =>
    • )} + {entries.map((entry) =>
    • )}
    - +
    ); } @@ -287,8 +287,8 @@ function ImageProviderCatalog({ providers, onClose, onAdded }: { providers: read }; return (
    -
    {t("models.imageAddProvider")}
    - {custom ?
    setCustom(false)} onAdded={onAdded} />
    :
    {t("models.imageCatalogHint")}
    {error !== null &&

    {error}

    }
    } />
    {list.map((item) => { const added = providers.some((p) => p.id === item.id); return ; })}
    } +
    {t("models.imageAddProvider")}
    + {custom ?
    setCustom(false)} onAdded={onAdded} />
    :
    {t("models.imageCatalogHint")}
    {error !== null &&

    {error}

    }
    } />
    {list.map((item) => { const added = providers.some((p) => p.id === item.id); return ; })}
    }
    ); } diff --git a/src/renderer/src/settings/pages/model/ModelPage.tsx b/src/renderer/src/settings/pages/model/ModelPage.tsx index 8105f11c3..5446c1f0b 100644 --- a/src/renderer/src/settings/pages/model/ModelPage.tsx +++ b/src/renderer/src/settings/pages/model/ModelPage.tsx @@ -421,7 +421,7 @@ function ModelConsole(): ReactNode {
    {catalogError !== null && ( -
    +
    {t("chat.status.error")}: {catalogError}
    )} @@ -480,7 +480,7 @@ function ModelConsole(): ReactNode { onClick={() => setEditingModel({ model: blankCatalogModel(), isNew: true }) } - className="inline-flex h-8 items-center gap-1 rounded-[7px] bg-accent px-2.5 text-[11.5px] text-accent-fg" + className="inline-flex h-8 items-center gap-1 rounded-[8px] bg-accent px-2.5 text-[11.5px] text-accent-fg" > {t("models.addModel")} @@ -798,7 +798,7 @@ function EditorSelect({ } const editorInputClass = - "h-8 w-full rounded-[9px] border border-border bg-surface-field px-2.5 text-[11.5px] text-fg outline-none transition-[border-color,box-shadow] duration-200 focus:border-fg-faint focus:ring-2 focus:ring-fg-faint/15"; + "apple-field h-8 w-full text-[12px]"; function cloneCatalogDraft(model: ModelCatalogDefinition): CatalogDraft { return { @@ -1112,7 +1112,7 @@ function CatalogModelEditor({
    {error !== null && ( -
    +
    {t("chat.status.error")}: {error}
    )} @@ -1417,7 +1417,7 @@ function CatalogModelEditor({ thinking.mode === "unsupported" || thinking.mode === "always" } className={cn( - "flex h-8 items-center justify-end rounded-[7px] border border-border bg-surface-field px-2", + "flex h-8 items-center justify-end rounded-[8px] border border-border bg-surface-field px-2", (thinking.mode === "unsupported" || thinking.mode === "always") && "opacity-60", @@ -1587,7 +1587,7 @@ function CatalogModelEditor({ value={patchText} onChange={(event) => setPatchText(event.target.value)} spellCheck={false} - className="selectable mt-2 min-h-[112px] w-full resize-y rounded-[7px] border border-border bg-surface-field p-2 font-mono text-[10.5px] text-fg outline-none focus:border-fg-faint" + className="apple-field selectable mt-2 min-h-[112px] w-full resize-y rounded-[8px] border border-border bg-surface-field p-2 font-mono text-[10.5px] text-fg outline-none focus:border-fg-faint" />

    @@ -1657,7 +1657,7 @@ function CatalogModelEditor({ type="button" onClick={() => void bind()} disabled={busy !== null || bindingProviderId === ""} - className="rounded-[7px] border border-border px-3 text-[11px] text-fg-muted hover:bg-tint-hover disabled:opacity-50" + className="rounded-[8px] border border-border px-3 text-[11px] text-fg-muted hover:bg-tint-hover disabled:opacity-50" > {busy === "bind" ? t("models.binding") : t("models.bindAndSave")} @@ -1673,7 +1673,7 @@ function CatalogModelEditor({ type="button" onClick={() => void save()} disabled={busy !== null} - className="rounded-[7px] bg-accent px-4 py-1.5 text-[11.5px] text-accent-fg disabled:opacity-50" + className="rounded-[8px] bg-accent px-4 py-1.5 text-[11.5px] text-accent-fg disabled:opacity-50" > {busy === "save" ? t("models.binding") : t("models.saveCatalog")} diff --git a/src/renderer/src/settings/pages/model/ModelProtocol.tsx b/src/renderer/src/settings/pages/model/ModelProtocol.tsx index e4d8292db..a64ce4b94 100644 --- a/src/renderer/src/settings/pages/model/ModelProtocol.tsx +++ b/src/renderer/src/settings/pages/model/ModelProtocol.tsx @@ -139,7 +139,7 @@ function ProtocolBindingForm({ type="button" disabled={!changed || busy} onClick={() => void save()} - className="rounded-[7px] bg-accent px-3 py-1.5 text-[11px] text-accent-fg disabled:opacity-50" + className="rounded-[8px] bg-accent px-3 py-1.5 text-[11px] text-accent-fg disabled:opacity-50" > {busy ? t("models.protocolSaving") : t("models.protocolSave")} diff --git a/src/renderer/src/settings/pages/model/ProviderAvatar.tsx b/src/renderer/src/settings/pages/model/ProviderAvatar.tsx index b6a3ac0da..746d6dbc1 100644 --- a/src/renderer/src/settings/pages/model/ProviderAvatar.tsx +++ b/src/renderer/src/settings/pages/model/ProviderAvatar.tsx @@ -24,7 +24,7 @@ import { avatarInitial } from './enabled-models' /** 两种尺寸对应两处既有几何:目录卡片 20px、列表与面板抬头 24px */ const BOX = { sm: { box: 'size-5 rounded-[6px] text-[10px]', icon: 13 }, - md: { box: 'size-6 rounded-[7px] text-[11px]', icon: 15 } + md: { box: 'size-6 rounded-[8px] text-[11px]', icon: 15 } } as const export function ProviderAvatar({ diff --git a/src/renderer/src/settings/pages/model/ProviderCatalog.tsx b/src/renderer/src/settings/pages/model/ProviderCatalog.tsx index 4a01d3362..e10c82046 100644 --- a/src/renderer/src/settings/pages/model/ProviderCatalog.tsx +++ b/src/renderer/src/settings/pages/model/ProviderCatalog.tsx @@ -133,7 +133,7 @@ export function ProviderCatalog({ onClick={() => setView("catalog")} className={cn( "app-no-drag flex size-6 shrink-0 items-center justify-center", - "rounded-[7px] text-icon transition-colors hover:bg-tint hover:text-fg", + "rounded-[8px] text-icon transition-colors hover:bg-tint hover:text-fg", )} > @@ -152,7 +152,7 @@ export function ProviderCatalog({ onClick={onClose} className={cn( "app-no-drag flex size-6 shrink-0 items-center justify-center", - "rounded-[7px] text-icon transition-colors hover:bg-tint hover:text-fg", + "rounded-[8px] text-icon transition-colors hover:bg-tint hover:text-fg", )} > @@ -248,7 +248,7 @@ function CustomProviderCard({ onOpen }: { onOpen: () => void }): ReactNode { type="button" onClick={onOpen} className={cn( - "app-no-drag min-w-0 rounded-[9px] border border-border bg-canvas px-2.5 py-2 text-left", + "app-no-drag min-w-0 rounded-[8px] border border-border bg-canvas px-2.5 py-2 text-left", "transition-colors hover:bg-tint/60", )} > @@ -291,7 +291,7 @@ function ImportFromAppsCard({ onOpen }: { onOpen: () => void }): ReactNode { type="button" onClick={onOpen} className={cn( - "app-no-drag min-w-0 rounded-[9px] border border-border bg-canvas px-2.5 py-2 text-left", + "app-no-drag min-w-0 rounded-[8px] border border-border bg-canvas px-2.5 py-2 text-left", "transition-colors hover:bg-tint/60", )} > @@ -464,7 +464,7 @@ function ImportProvidersForm({ disabled={!selectable} onClick={() => toggle(p.sourceKey)} className={cn( - "app-no-drag flex w-full min-w-0 items-start gap-2.5 rounded-[9px] border px-2.5 py-2 text-left transition-colors", + "app-no-drag flex w-full min-w-0 items-start gap-2.5 rounded-[8px] border px-2.5 py-2 text-left transition-colors", selectable ? "border-border hover:bg-tint/60" : "border-hairline opacity-60", active && selectable && "border-accent bg-accent/5", )} @@ -745,7 +745,7 @@ function PresetCard({ return (

    diff --git a/src/renderer/src/settings/pages/model/ProviderPanel.tsx b/src/renderer/src/settings/pages/model/ProviderPanel.tsx index 30547d171..d0802f788 100644 --- a/src/renderer/src/settings/pages/model/ProviderPanel.tsx +++ b/src/renderer/src/settings/pages/model/ProviderPanel.tsx @@ -993,7 +993,7 @@ export function ProviderPanel({ aria-label={t("provider.modelIdLabel")} placeholder={t("provider.modelIdPlaceholder")} disabled={busy} - className="selectable min-w-0 flex-1 rounded-[7px] border border-border bg-canvas px-2 text-[12px] text-fg outline-none placeholder:text-fg-faint focus:border-fg-faint" + className="apple-field selectable min-w-0 flex-1 rounded-[8px] border border-border bg-canvas px-2 text-[12px] text-fg outline-none placeholder:text-fg-faint focus:border-fg-faint" /> @@ -544,7 +544,7 @@ function RequestLogs({ type="button" onClick={onPrevious} disabled={page === null || page.offset === 0 || loading} - className="rounded-[7px] border border-border px-2 py-1 text-fg-muted disabled:opacity-35" + className="rounded-[8px] border border-border px-2 py-1 text-fg-muted disabled:opacity-35" > {t('usage.previousPage')} @@ -552,7 +552,7 @@ function RequestLogs({ type="button" onClick={onNext} disabled={page === null || page.offset + page.items.length >= page.total || loading} - className="rounded-[7px] border border-border px-2 py-1 text-fg-muted disabled:opacity-35" + className="rounded-[8px] border border-border px-2 py-1 text-fg-muted disabled:opacity-35" > {t('usage.nextPage')} diff --git a/src/renderer/src/settings/pages/model/VideoModelPage.tsx b/src/renderer/src/settings/pages/model/VideoModelPage.tsx index e23bcec77..95cefdcbe 100644 --- a/src/renderer/src/settings/pages/model/VideoModelPage.tsx +++ b/src/renderer/src/settings/pages/model/VideoModelPage.tsx @@ -174,7 +174,7 @@ export function VideoModelPage({ settings, patch }: Pick ) : selected === null ? (
    -
    +
    {t('videoGen.catalogHint')}
    @@ -208,7 +208,7 @@ function ProviderList({ rows, models, disabled, selectedId, onAdd, onSelect }: {

    {t('videoGen.providers')}

    {t('videoGen.catalogHint')}

    -
    @@ -219,7 +219,7 @@ function ProviderList({ rows, models, disabled, selectedId, onAdd, onSelect }: { type="button" disabled={disabled} onClick={() => onSelect(row.id)} - className={cn('flex w-full items-center gap-2 rounded-[9px] px-1.5 py-2 text-left disabled:opacity-40', row.id === selectedId ? 'bg-tint' : 'hover:bg-tint/60')} + className={cn('flex w-full items-center gap-2 rounded-[8px] px-1.5 py-2 text-left disabled:opacity-40', row.id === selectedId ? 'bg-tint' : 'hover:bg-tint/60')} > @@ -415,7 +415,7 @@ function ProviderCatalog({ rows, busy, error, onClose, onAdd, onAdded, onBusyCha
    ) : (
    -
    +
    {t('videoGen.catalogHint')}
    @@ -656,7 +656,7 @@ function VideoProviderForm({ provider, preset, models, onCancel, onSaved, onBusy
    updateDraft(entry.key, { model })} ariaLabel={`${t('provider.modelIdLabel')} ${String(index + 1)}`} placeholder={compatiblePreset?.models.find((model) => model.profileId === entry.profileId)?.model ?? 'model-id'} disabled={saving} />
    - +

    {t('videoGen.profile')}

    @@ -751,7 +751,7 @@ function VideoGenModelRow({ settings, patch, models, providers, loaded }: { sett
    {triggerLabel}} - triggerClassName={cn('group flex h-7 w-full items-center gap-1.5 rounded-[7px] border border-border', 'bg-surface-field px-2 text-left text-[11.5px] text-fg outline-none', 'transition-[background-color,border-color,box-shadow] duration-150', 'hover:bg-tint focus-visible:border-fg-faint focus-visible:ring-2 focus-visible:ring-fg-faint/15')} + triggerClassName={cn('apple-field group flex h-7 w-full items-center gap-1.5 rounded-[8px] border border-border', 'bg-surface-field px-2 text-left text-[11.5px] text-fg outline-none', 'transition-[background-color,border-color,box-shadow] duration-150', 'hover:bg-tint focus-visible:border-fg-faint focus-visible:ring-2 focus-visible:ring-fg-faint/15')} className="w-full" align="end" width={280} diff --git a/src/renderer/src/settings/pages/usage/UsagePage.tsx b/src/renderer/src/settings/pages/usage/UsagePage.tsx index 4558989b0..4065d47cb 100644 --- a/src/renderer/src/settings/pages/usage/UsagePage.tsx +++ b/src/renderer/src/settings/pages/usage/UsagePage.tsx @@ -170,7 +170,7 @@ export function UsagePage(): ReactNode { @@ -475,7 +475,7 @@ function RequestLogs({ type="button" onClick={onPrevious} disabled={page === null || page.offset === 0 || loading} - className="rounded-[7px] border border-border px-2 py-1 text-fg-muted disabled:opacity-35" + className="rounded-[8px] border border-border px-2 py-1 text-fg-muted disabled:opacity-35" > {t('usage.previousPage')} @@ -483,7 +483,7 @@ function RequestLogs({ type="button" onClick={onNext} disabled={page === null || page.offset + page.items.length >= page.total || loading} - className="rounded-[7px] border border-border px-2 py-1 text-fg-muted disabled:opacity-35" + className="rounded-[8px] border border-border px-2 py-1 text-fg-muted disabled:opacity-35" > {t('usage.nextPage')} @@ -540,8 +540,11 @@ function RequestRow({ {record.httpStatus ?? diff --git a/src/renderer/src/settings/theme-studio/DesktopPreview.tsx b/src/renderer/src/settings/theme-studio/DesktopPreview.tsx index f8418ad8a..5c5b8e6b2 100644 --- a/src/renderer/src/settings/theme-studio/DesktopPreview.tsx +++ b/src/renderer/src/settings/theme-studio/DesktopPreview.tsx @@ -68,7 +68,7 @@ export function DesktopPreview({ profile, images }: { profile: ThemeProfile; ima

    theme.ts

    TypeScript · 4 KB

    {t('themeStudio.ready')}
    :
    - {content === 'editor' ?
    {'export const theme = {\n  canvas: "#1e2020",\n  accent: "#71b98c"\n}'}
    : <>
    https://nextcowork.local

    NextCoWork

    {t('themeStudio.previewReply')}

    } + {content === 'editor' ?
    {'export const theme = {\n  canvas: "#1c1c1e",\n  accent: "#0071e3"\n}'}
    : <>
    https://nextcowork.local

    NextCoWork

    {t('themeStudio.previewReply')}

    }
    }
    {bottom &&
    diff --git a/src/renderer/src/settings/theme-studio/ThemeStudioPane.tsx b/src/renderer/src/settings/theme-studio/ThemeStudioPane.tsx index ae9886786..ce700c77d 100644 --- a/src/renderer/src/settings/theme-studio/ThemeStudioPane.tsx +++ b/src/renderer/src/settings/theme-studio/ThemeStudioPane.tsx @@ -154,7 +154,7 @@ export function ThemeStudioPane({ settings, patch }: Omit{GROUPS.map((g) => )}
    {group === 'wallpaper' && <> - changeWallpaper(resolveImageTheme(e.target.value, images))}>{[...IMAGE_THEMES, ...images].map((i) => { const key = i.source.kind === 'builtin' ? builtinThemeNameKey('image', i.id) : null return })} diff --git a/src/renderer/src/shell/AccountMenu.tsx b/src/renderer/src/shell/AccountMenu.tsx index a1ce61774..bc9b2832a 100644 --- a/src/renderer/src/shell/AccountMenu.tsx +++ b/src/renderer/src/shell/AccountMenu.tsx @@ -262,12 +262,12 @@ function BalanceRow({ }): ReactNode { const { t } = useI18n() return ( -
    +
    diff --git a/src/renderer/src/shell/SearchPalette.tsx b/src/renderer/src/shell/SearchPalette.tsx index 69922f34b..21eb3e5da 100644 --- a/src/renderer/src/shell/SearchPalette.tsx +++ b/src/renderer/src/shell/SearchPalette.tsx @@ -174,14 +174,14 @@ export function SearchPalette({ aria-modal="true" aria-label={t('search.title')} > -
    +
    @@ -249,7 +249,7 @@ export function SearchPalette({ key={hit.messageId} value={hit.messageId} onSelect={() => select(hit)} - className="flex cursor-pointer items-start gap-2.5 rounded-[9px] px-2.5 py-2 aria-selected:bg-tint-hover" + className="flex cursor-pointer items-start gap-2.5 rounded-[8px] px-2.5 py-2 aria-selected:bg-accent/15" > diff --git a/src/renderer/src/shell/Sidebar.tsx b/src/renderer/src/shell/Sidebar.tsx index 86c0937af..78faaed7f 100644 --- a/src/renderer/src/shell/Sidebar.tsx +++ b/src/renderer/src/shell/Sidebar.tsx @@ -548,7 +548,7 @@ function SessionGroupBlock({ setMenu({ session, position: { x: event.clientX, y: event.clientY } }) }} className={cn( - 'flex w-full items-center gap-2 rounded-[7px] px-2.5 py-1.5 text-left text-[12.5px] transition-colors', + 'flex w-full items-center gap-2 rounded-[8px] px-2.5 py-1.5 text-left text-[12.5px] transition-colors', active ? 'bg-canvas text-fg' : 'text-fg-muted hover:bg-tint-hover hover:text-fg' )} > @@ -667,14 +667,14 @@ function SessionGroupBlock({ } > - setRenameDraft(event.target.value)} className="selectable mt-2 h-9 w-full rounded-[8px] border border-border bg-surface-field px-2.5 text-[13px] text-fg outline-none focus:border-fg-faint" /> + setRenameDraft(event.target.value)} className="apple-field selectable mt-2 h-9 w-full rounded-[8px] border border-border bg-surface-field px-2.5 text-[13px] text-fg outline-none focus:border-fg-faint" /> ) } function MenuAction({ icon, label, danger = false, onSelect }: { icon: ReactNode; label: string; danger?: boolean; onSelect: () => void }): ReactNode { - return + return } /** @@ -722,7 +722,7 @@ function ArchivedSessionItem({ setMenu({ x: event.clientX, y: event.clientY }) }} aria-current={active ? 'page' : undefined} - className={cn('flex w-full items-center gap-2 rounded-[7px] px-2.5 py-1.5 text-left text-[12.5px] transition-colors', + className={cn('flex w-full items-center gap-2 rounded-[8px] px-2.5 py-1.5 text-left text-[12.5px] transition-colors', active ? 'bg-canvas text-fg' : 'text-fg-muted hover:bg-tint-hover hover:text-fg')} > {session.title} diff --git a/src/renderer/src/shell/UpdateIndicator.tsx b/src/renderer/src/shell/UpdateIndicator.tsx index 3f1e1bf88..cc9367050 100644 --- a/src/renderer/src/shell/UpdateIndicator.tsx +++ b/src/renderer/src/shell/UpdateIndicator.tsx @@ -231,7 +231,7 @@ export function UpdateIndicator(): ReactNode { style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999, width: CARD_WIDTH }} // z-150:portal 到 body 的浮层那一档,见 theme.css 末尾 z 轴那段。 // 和 `Tooltip` 同档 —— 它俩不会同时出现在同一个位置上。 - className="app-no-drag fixed z-[150] rounded-card border border-stroke bg-surface-raised p-3 text-fg shadow-lg" + className="app-no-drag fixed z-[150] rounded-[14px] bg-surface-menu p-3 text-fg shadow-menu" >
    {t('about.updates.banner', { version: info.version })} diff --git a/src/renderer/src/styles/arc.css b/src/renderer/src/styles/arc.css index 3c98f551f..458faf725 100644 --- a/src/renderer/src/styles/arc.css +++ b/src/renderer/src/styles/arc.css @@ -11,16 +11,100 @@ 工具类 —— 往 Arc 组件上传 `h-7`、`px-3` 这类类名是不会生效的,要调尺寸就改下面的 token。 */ @import '../components/arc/foundation.css'; -@import '@fontsource-variable/inter/index.css'; -@import '@fontsource-variable/geist/index.css'; /* - 桌面端密度。Arc 默认是网页尺寸(36 / 44 / 50px 高、18px 圆角),而本应用的 - 标题栏、工具栏、设置行都是按 24–34px 排的。只改 token,不改组件内部样式。 + 桌面端密度,按 macOS 控件尺寸取:小号 22、常规 28、大号 32;圆角 6px(NSButton 的圆角)。 */ :root { - --control-height-sm: 1.75rem; - --control-height-md: 2rem; - --control-height-lg: 2.25rem; - --radius-control: 0.5rem; + --control-height-sm: 1.5rem; + --control-height-md: 1.75rem; + --control-height-lg: 2rem; + /* Liquid Glass:控件一律胶囊形 */ + --radius-control: 999px; + --radius-surface: 0.875rem; +} + +/* + ── 苹果风格:把 Arc 的中性灰 token 接到应用的主题 token 上 ── + + Arc 自带一套和应用无关的黑白灰(`--foreground` / `--surface` / `--accent` …),于是 + 按钮、开关、下拉一直是「另一套皮肤」,换颜色主题也不跟着走。这里让它们**全部从 + `--color-*` 取值** —— 那组变量由 `theme/apply.ts` 写成行内样式,深浅两套都会落到这里。 + + ★ 选择器特异性是 (0,3,0),而且排在 foundation.css 之后,所以压得住它的 + `:root[data-theme="dark"]` 和 `:root[data-theme="dark"][data-accent="…"]` —— + 不然深色下 Arc 会退回它自己的那套灰 / 预设强调色(index.html 曾经挂着 + `data-accent="green"`,深色下开关和主按钮因此一直是绿的)。 +*/ +:root:is([data-theme], :not([data-theme])):is([data-accent], :not([data-accent])) { + --background: var(--color-canvas); + /* 输入框、下拉、次要按钮的底:半透明玻璃填充 + 一圈浅色描边(见 --border-strong) */ + --surface: color-mix(in srgb, var(--color-surface-input) 62%, transparent); + --surface-raised: var(--color-surface-raised); + --surface-muted: var(--color-tint); + --foreground: var(--color-fg); + --text-secondary: var(--color-fg-muted); + --text-muted: var(--color-fg-faint); + --border: color-mix(in srgb, var(--color-fg) 12%, transparent); + --border-subtle: color-mix(in srgb, var(--color-fg) 7%, transparent); + --border-strong: color-mix(in srgb, var(--color-fg) 14%, transparent); + --accent: var(--color-accent); + --accent-strong: color-mix(in srgb, var(--color-accent), black 18%); + --accent-subtle: color-mix(in srgb, var(--color-accent) 14%, transparent); + --accent-foreground: var(--color-accent-fg); + --danger: var(--color-danger); + --warning: var(--color-warning); + --success: #34c759; + + /* 开关、单选、滑块:macOS 的「开」就是强调色,滑块恒为白色圆钮 */ + --control-on: var(--color-accent); + --control-glyph: #ffffff; + --control-track: color-mix(in srgb, var(--color-fg) 14%, transparent); + --control-track-hover: color-mix(in srgb, var(--color-fg) 20%, transparent); + --control-thumb: #ffffff; + --control-thumb-on: #ffffff; + --control-fill: var(--color-accent); + --control-thumb-shadow: 0 0 0 0.5px rgb(0 0 0 / 0.12), 0 1px 1px rgb(0 0 0 / 0.1), 0 2px 4px rgb(0 0 0 / 0.14); + + /* 键盘焦点:macOS 的焦点环 —— 强调色半透明外扩 3px */ + --focus-outline: color-mix(in srgb, var(--color-accent) 55%, transparent); + --focus-outline-width: 3px; + --focus-outline-offset: 0px; + + /* 阴影:贴近 NSWindow / NSPopover 的投影 —— 一圈 0.5px 发丝收边 + 大而淡的柔影 */ + --shadow-resting: 0 0 0 0.5px rgb(0 0 0 / 0.06), 0 1px 1.5px rgb(0 0 0 / 0.1); + --shadow-raised: 0 0 0 0.5px rgb(0 0 0 / 0.08), 0 4px 12px rgb(0 0 0 / 0.1); + --shadow-floating: 0 0 0 0.5px rgb(0 0 0 / 0.12), 0 12px 36px rgb(0 0 0 / 0.18), 0 3px 10px rgb(0 0 0 / 0.08); + + /* 字体:系统字体(SF Pro),不再加载 Inter / Geist */ + --font-display: var(--font-sans); + --font-body: var(--font-sans); + --tracking-display: -0.022em; + --tracking-body: -0.006em; + --leading-body: 1.38; + + /* 苹果的弹簧:快起、轻微回弹 */ + --ease-standard: cubic-bezier(0.25, 0.1, 0.25, 1); + --ease-enter: cubic-bezier(0.2, 0.9, 0.3, 1); + + /* 毛玻璃材质(菜单 / 弹窗 / 提示共用) */ + --material-menu: color-mix(in srgb, var(--color-surface-raised) 78%, transparent); + --material-sheet: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); + --material-blur: saturate(180%) blur(24px); +} + +:root[data-theme='dark']:is([data-accent], :not([data-accent])) { + --success: #30d158; + --border: color-mix(in srgb, var(--color-fg) 14%, transparent); + --border-strong: color-mix(in srgb, var(--color-fg) 16%, transparent); + --surface: color-mix(in srgb, var(--color-fg) 6%, transparent); + --control-track: color-mix(in srgb, var(--color-fg) 18%, transparent); + --control-track-hover: color-mix(in srgb, var(--color-fg) 24%, transparent); + --control-thumb-shadow: 0 0 0 0.5px rgb(0 0 0 / 0.3), 0 1px 2px rgb(0 0 0 / 0.35); + --shadow-resting: 0 0 0 0.5px rgb(0 0 0 / 0.4), 0 1px 1.5px rgb(0 0 0 / 0.3); + --shadow-raised: 0 0 0 0.5px rgb(0 0 0 / 0.5), 0 6px 16px rgb(0 0 0 / 0.35); + /* 深色浮层多一道内侧高光,像 macOS 深色菜单的顶边 */ + --shadow-floating: inset 0 0.5px 0 rgb(255 255 255 / 0.08), 0 0 0 0.5px rgb(0 0 0 / 0.6), + 0 16px 40px rgb(0 0 0 / 0.45), 0 3px 10px rgb(0 0 0 / 0.3); + --material-menu: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); } diff --git a/src/renderer/src/styles/theme.css b/src/renderer/src/styles/theme.css index c9ec49c8e..e5645f83c 100644 --- a/src/renderer/src/styles/theme.css +++ b/src/renderer/src/styles/theme.css @@ -3,6 +3,10 @@ @import 'tailwindcss'; /* + ★ 苹果风格分支(feat/apple-style-ui):下面 @theme / 浅色 / 首屏兜底三处的色值已换成 + macOS 系统色,权威来源是 `shared/domain/theme.ts` 的 `BASE`(那边有选值理由)。 + 本段以下关于「量自参考实现」的说明保留作历史,数值以 BASE 为准。 + 方案 §8 的 token 层。数值不是调出来的,是**从参考实现的截图上量出来的** —— 量法是区域直方图,不是取单像素: @@ -81,31 +85,32 @@ */ @theme { /* ── 结构:量自 docs/image-latest/dark.png,全部 100% 单色 ── */ - --color-app: #2a2d2b; /* 窗口底 —— 面板之间露出来的那条缝(实测 3px 宽) */ - --color-canvas: #1e2020; /* 内容面板 / 激活的外层 Tab / 内层 Tab 条 */ - --color-surface: #232726; /* 侧边栏 */ - --color-chrome: #343a37; /* 外层 Tab 条 —— 深色下**比侧边栏亮**,见上 §1 */ - --color-surface-raised: #2a2d2b; /* 侧边栏里的分区卡 —— 和窗口底同色,深浅都成立 */ - --color-surface-input: #2a2d2b; /* 画布上的输入框 —— 深色下并入 raised,浅色下是纯白 */ + --color-app: #252527; /* 窗口底 —— 面板之间露出来的那条缝(实测 3px 宽) */ + --color-canvas: #1c1c1e; /* 内容面板 / 激活的外层 Tab / 内层 Tab 条 */ + --color-surface: #202022; /* 侧边栏 */ + --color-chrome: #2c2c2e; /* 外层 Tab 条 —— 深色下**比侧边栏亮**,见上 §1 */ + --color-surface-raised: #2c2c2e; /* 侧边栏里的分区卡 —— 和窗口底同色,深浅都成立 */ + --color-surface-input: #2c2c2e; /* 画布上的输入框 —— 深色下并入 raised,浅色下是纯白 */ /* **浮层里的**输入框(设置浮层的搜索框、端口框、代理地址框)。这批截图里没有浮层, 按 v1.1.15 的步长推:raised 往上一级(+10)。深色下和 chrome 撞值,浅色下分开 —— 和 surface/chrome 那一对正好是镜像,同一个道理:两个 token 在一个主题里恰好相等, 不等于它们是同一个东西。 */ - --color-surface-field: #343a37; - --color-surface-sunken: #252b28; /* 按下 / 激活的图标按钮:从 chrome 往下挖(推的) */ + --color-surface-field: #3a3a3c; + --color-surface-sunken: #232325; /* 按下 / 激活的图标按钮:从 chrome 往下挖(推的) */ /* 菜单与子菜单以底色和阴影浮起,避免被 border 的对比度护栏提亮出一圈描边。 */ - --color-surface-menu: color-mix(in srgb, var(--color-surface-raised), var(--color-surface-field) 50%); - --shadow-menu: 0 10px 15px -3px rgb(0 0 0 / 0.2), 0 4px 6px -4px rgb(0 0 0 / 0.2); + /* 苹果风格:菜单是半透明材质,配合下面 `.bg-surface-menu` 的背景模糊读作毛玻璃(NSVisualEffectView) */ + --color-surface-menu: color-mix(in srgb, var(--color-surface-raised) 80%, transparent); + --shadow-menu: 0 0 0 0.5px rgb(0 0 0 / 0.12), 0 10px 30px rgb(0 0 0 / 0.18), 0 2px 8px rgb(0 0 0 / 0.08); /* ── 交互态 ── */ - --color-tint: #2b2e2d; /* 激活的内层 Tab(实测)/ 控件槽 / 用户气泡 */ - --color-tint-hover: #363b38; /* 悬停(推的:+11,带绿) */ - --color-tint-strong: #3c423e; /* 菜单行悬停 / 分类 chip(推的:+17) */ + --color-tint: #2c2c2e; /* 激活的内层 Tab(实测)/ 控件槽 / 用户气泡 */ + --color-tint-hover: #323234; /* 悬停(推的:+11,带绿) */ + --color-tint-strong: #3a3a3c; /* 菜单行悬停 / 分类 chip(推的:+17) */ - --color-border: #2b2e2d; /* 与 tint 同值 —— 沿用 v1.1.15 里两者相等的关系 */ + --color-border: #48484a; /* 与 tint 同值 —— 沿用 v1.1.15 里两者相等的关系 */ /* ★ **`stroke` 和 `border` 值相同,但不是同一个东西 —— 别合并。** @@ -124,18 +129,18 @@ stroke → 卡片 / 面板 / 区块的收边,**纯装饰**,永远是设计值 分隔线仍然走 `hairline`(卡片**内部**的横线),这三个别混。 */ - --color-stroke: #2b2e2d; - --color-hairline: #252727; /* 内容区里的行分隔线,比 canvas 只高一点点 */ - - --color-fg: #ececec; - --color-fg-muted: #959897; - --color-fg-faint: #7b7d7c; - - --color-icon: #cfcfcf; /* ★ 中性灰,**不是**强调色 —— 见上 §3。缩图下界 */ - --color-accent: #36d285; /* ★ 亮绿(激活芯片实心底 / 「完全访问」盾牌与文字) */ - --color-accent-fg: #0a2615; /* 压在 accent 上的文字 —— 深绿近黑 */ - --color-accent-soft: #5a8a72; /* 正文里的装饰性小图标(推的:accent 去饱和) */ - --color-danger: #d9614e; /* 未量到,沿用 v1.1.15 */ + --color-stroke: #2e2e30; + --color-hairline: #28282a; /* 内容区里的行分隔线,比 canvas 只高一点点 */ + + --color-fg: #f5f5f7; + --color-fg-muted: #aeaeb2; + --color-fg-faint: #8e8e93; + + --color-icon: #c7c7cc; /* ★ 中性灰,**不是**强调色 —— 见上 §3。缩图下界 */ + --color-accent: #0071e3; /* ★ 亮绿(激活芯片实心底 / 「完全访问」盾牌与文字) */ + --color-accent-fg: #ffffff; /* 压在 accent 上的文字 —— 深绿近黑 */ + --color-accent-soft: #4a6c99; /* 正文里的装饰性小图标(推的:accent 去饱和) */ + --color-danger: #ff453a; /* 未量到,沿用 v1.1.15 */ /* ★ **`warning` 之前根本不存在。** @@ -151,7 +156,8 @@ 色值取 accent 的补色方向(琥珀),在 canvas #1e2020 上对比度 ≈ 8.9:1。 */ - --color-warning: #e0a33a; + --color-warning: #ff9f0a; /* systemOrange(深色) */ + --color-success: #30d158; /* systemGreen(深色)—— 状态药丸「成功」用 */ /* ── 图表分类色(使用统计页:按模型着色)── @@ -174,15 +180,15 @@ `chart-other` 是「其他(合并的尾部)」专用的中性灰 —— 它不是一个模型, 不该抢一个分类色。 */ - --color-chart-1: #5b9cf5; - --color-chart-2: #f2b14c; - --color-chart-3: #a98bfa; - --color-chart-4: #2fc4a4; - --color-chart-5: #f07a6a; - --color-chart-6: #4cc3e0; - --color-chart-7: #ec78b4; - --color-chart-8: #a9c95a; - --color-chart-other: #6b706e; + --color-chart-1: #0a84ff; + --color-chart-2: #ff9f0a; + --color-chart-3: #bf5af2; + --color-chart-4: #30d158; + --color-chart-5: #ff6961; + --color-chart-6: #64d2ff; + --color-chart-7: #ff375f; + --color-chart-8: #ffd60a; + --color-chart-other: #636366; /* ── 文档页面上的墨色(办公画布的文字光标)── @@ -215,8 +221,8 @@ */ --ease-panel: cubic-bezier(0.32, 0.72, 0, 1); - --radius-panel: 12px; /* 浮动面板 —— 采样:侧边栏左上角圆角 ≈ 12px */ - --radius-card: 10px; + --radius-panel: 16px; /* 浮动面板 —— Liquid Glass 的面板圆角 */ + --radius-card: 14px; /* 卡片(工具卡、思考块、计划面板…)*/ --radius-pill: 999px; /* @@ -232,9 +238,9 @@ --spacing-window-controls: 128px; --font-sans: - -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', - 'Microsoft YaHei', sans-serif; - --font-mono: 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro', 'Helvetica Neue', 'PingFang SC', + 'Hiragino Sans GB', 'Segoe UI', 'Microsoft YaHei', sans-serif; + --font-mono: ui-monospace, 'SF Mono', Menlo, 'JetBrains Mono', Consolas, monospace; /* 品牌字标专用 —— 圆润几何感,和正文的 --font-sans 拉开区分度,只用在「NextCoWork」标题上 */ --font-brand: 'SF Pro Rounded', 'Avenir Next', 'PingFang SC', var(--font-sans); } @@ -248,49 +254,50 @@ 所以这一版改的只有深色,浅色原样保留。 */ :root[data-theme='light'] { - --color-app: #f2eee6; /* 窗口底 —— 面板之间的缝 */ - --color-canvas: #faf9f5; /* 内容面板 / 激活的外层 Tab / 选中的会话行 */ - --color-surface: #f6f4ef; /* 侧边栏 / 画布上的工具卡 */ - --color-chrome: #e8e4dd; /* 外层 Tab 条 —— 浅色下**比侧边栏暗**,见上 §1 */ - --color-surface-raised: #f2eee6; /* 侧边栏里的分区卡 / 菜单 —— 同窗口底 */ + --color-app: #ececf0; /* 窗口底 —— 面板之间的缝 */ + --color-canvas: #ffffff; /* 内容面板 / 激活的外层 Tab / 选中的会话行 */ + --color-surface: #f5f5f7; /* 侧边栏 / 画布上的工具卡 */ + --color-chrome: #e3e3e8; /* 外层 Tab 条 —— 浅色下**比侧边栏暗**,见上 §1 */ + --color-surface-raised: #f2f2f7; /* 侧边栏里的分区卡 / 菜单 —— 同窗口底 */ --color-surface-input: #ffffff; /* 输入框:整个界面唯一的纯白 */ --color-surface-field: #ffffff; /* 浮层里的输入框 —— 浅色下靠纯白+描边分出来 */ - --color-surface-sunken: #dbd8d1; /* 按下 / 激活的图标按钮(量自收起态的侧边栏按钮) */ + --color-surface-sunken: #dcdce1; /* 按下 / 激活的图标按钮(量自收起态的侧边栏按钮) */ - --color-tint: #efefec; /* 激活的内层 Tab / 时间戳药丸 / 发丝线,都是这一个值 */ - --color-tint-hover: #edeae6; /* Explore 芯片 / 头像圆 */ - --color-tint-strong: #e2ded7; + --color-tint: #ededf2; /* 激活的内层 Tab / 时间戳药丸 / 发丝线,都是这一个值 */ + --color-tint-hover: #e8e8ed; /* Explore 芯片 / 头像圆 */ + --color-tint-strong: #dedee3; - --color-border: #e6e6e2; /* 输入框描边 */ - --color-stroke: #e6e6e2; /* 卡片收边 —— 见深色那段:和 border 同值但不受护栏改写 */ - --color-hairline: #efefec; /* 内层 Tab 条下那一道 */ + --color-border: #c7c7cc; /* 输入框描边 */ + --color-stroke: #e5e5ea; /* 卡片收边 —— 见深色那段:和 border 同值但不受护栏改写 */ + --color-hairline: #ececf0; /* 内层 Tab 条下那一道 */ - --color-fg: #181c19; - --color-fg-muted: #72736f; - --color-fg-faint: #96958f; + --color-fg: #1d1d1f; + --color-fg-muted: #5f5f64; + --color-fg-faint: #8e8e93; - --color-icon: #7e7f7e; /* ★ 中性灰,不是强调色 —— 见上 §3 */ - --color-accent: #2d4739; /* ★ 墨绿 —— 深色是亮绿 #36d285,明暗相反 */ + --color-icon: #6e6e73; /* ★ 中性灰,不是强调色 —— 见上 §3 */ + --color-accent: #0071e3; /* ★ 墨绿 —— 深色是亮绿 #36d285,明暗相反 */ --color-accent-fg: #ffffff; - --color-accent-soft: #8e8f8d; - --color-danger: #c4412c; + --color-accent-soft: #86a8d6; + --color-danger: #e0352b; /* 浅色下压暗一档:#e0a33a 在近白底上只有 2.1:1,读不出来 */ - --color-warning: #9a6b12; + --color-warning: #b25000; /* systemOrange 压暗到浅底可读 */ + --color-success: #248a3d; /* systemGreen 压暗到浅底可读 */ /* 图表分类色:色相顺序同深色,明度压到中低档 —— 深色那套在近白底上 琥珀 / 橄榄远低于 3:1,色块边界会糊。这套在 surface #f6f4ef 上 3.3–4.9:1, 过非文本 3:1 线(推的 + 算的,理由见 @theme 里那段) */ - --color-chart-1: #2f6bd0; - --color-chart-2: #b87a10; - --color-chart-3: #7353cf; - --color-chart-4: #13866f; - --color-chart-5: #c94c3a; - --color-chart-6: #16859c; - --color-chart-7: #bd4486; - --color-chart-8: #6c8a1c; - --color-chart-other: #b3b1aa; + --color-chart-1: #0071e3; + --color-chart-2: #c46a00; + --color-chart-3: #8944ab; + --color-chart-4: #248a3d; + --color-chart-5: #d70015; + --color-chart-6: #0071a4; + --color-chart-7: #d30f45; + --color-chart-8: #a05a00; + --color-chart-other: #aeaeb2; - --color-scrim: #2b2a27; /* 暖近黑 —— 浅色下压暗要压暖,压蓝会发灰(无参考,按规律推) */ + --color-scrim: #000000; /* 苹果风格:两个外观都是纯黑遮罩 */ } /* @@ -313,14 +320,14 @@ */ @media (prefers-color-scheme: light) { :root:not([data-theme='dark']) { - --color-app: #f2eee6; - --color-canvas: #faf9f5; - --color-surface: #f6f4ef; - --color-chrome: #e8e4dd; - --color-tint: #efefec; - --color-border: #e6e6e2; - --color-stroke: #e6e6e2; - --color-fg: #181c19; + --color-app: #ececf0; + --color-canvas: #ffffff; + --color-surface: #f5f5f7; + --color-chrome: #e3e3e8; + --color-tint: #ededf2; + --color-border: #c7c7cc; + --color-stroke: #e5e5ea; + --color-fg: #1d1d1f; } } @@ -456,7 +463,7 @@ body { .theme-studio-layout { display: grid; grid-template-columns: 190px minmax(300px, 1fr) minmax(280px, 340px); gap: 16px; min-height: 0; flex: 1; } .theme-studio-library, .theme-studio-inspector { min-height: 0; overflow: auto; } .theme-studio-center { min-width: 0; display: flex; flex-direction: column; } -.studio-select { width: 100%; border: 1px solid var(--color-border); border-radius: 7px; background: var(--color-surface-field); color: var(--color-fg); padding: 7px 9px; font-size: 12px; } +.studio-select { width: 100%; padding-block: 6px; font-size: 12px; } .theme-preview-frame { position: relative; display: flex; min-height: 390px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-canvas); background-size: cover; isolation: isolate; } .theme-preview-sidebar { width: 22%; min-width: 82px; display: flex; flex-direction: column; gap: 13px; padding: 14px 10px; background: color-mix(in srgb, var(--color-surface) 90%, transparent); backdrop-filter: blur(16px); } .theme-preview-logo { width: 56px; height: 8px; border-radius: 999px; background: var(--color-accent); } @@ -533,7 +540,7 @@ body { */ * { scrollbar-width: thin; - scrollbar-color: var(--color-tint) transparent; + scrollbar-color: color-mix(in srgb, var(--color-fg) 22%, transparent) transparent; } *::-webkit-scrollbar { width: 8px; @@ -543,13 +550,14 @@ body { /* thumb 靠 border + background-clip 挤出内缩,而不是直接把 scrollbar 调窄: 8px 的命中区保留给鼠标,视觉上只有中间 4px 有颜色。改成 width:4px 会很难点中。 */ *::-webkit-scrollbar-thumb { - background: var(--color-tint); + /* 苹果风格:macOS 叠加式滚动条 —— 前景色半透明,深浅两个外观都自然 */ + background: color-mix(in srgb, var(--color-fg) 22%, transparent); border: 2px solid transparent; background-clip: content-box; border-radius: 999px; } *::-webkit-scrollbar-thumb:hover { - background: var(--color-tint-strong); + background: color-mix(in srgb, var(--color-fg) 38%, transparent); background-clip: content-box; } *::-webkit-scrollbar-corner, @@ -982,3 +990,221 @@ body { 照样能点,这一档就是为了让另外两个平台不比它差 —— 否则设置面板一开 就没法最小化/关窗。别往这一档里放第二样东西。 */ + +/* + ── 苹果风格的全局收尾(feat/apple-style-ui)────────────────────────────── + + · 菜单材质:所有 `bg-surface-menu` 的浮层(Menu / ContextMenu / Submenu / Select) + 都是半透明底,这里补上背景模糊和饱和度提升,读作 macOS 的毛玻璃。 + 系统里开了「降低透明度」就退回实底。 + · 文本选区用强调色(macOS 的 selectedTextBackgroundColor 是强调色的浅版)。 + · 字体渲染:关掉 Chromium 的合成字重,开启光学尺寸(SF Text / Display 自动切换)。 +*/ +.bg-surface-menu { + -webkit-backdrop-filter: saturate(180%) blur(24px); + backdrop-filter: saturate(180%) blur(24px); +} +@media (prefers-reduced-transparency: reduce) { + .bg-surface-menu { + background-color: var(--color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } +} + +::selection { + background: color-mix(in srgb, var(--color-accent) 30%, transparent); +} + +body { + font-synthesis-weight: none; + font-optical-sizing: auto; + text-rendering: optimizeLegibility; +} + +/* + ── Liquid Glass:半透明窗口 + 浮动玻璃面板(仅 macOS,`data-vibrancy`)────────── + + 窗口本身是系统毛玻璃(main/index.ts 的 `vibrancy: 'under-window'`),这里把各层 + 底色换成**同色的半透明版本**,让桌面壁纸的颜色透上来。 + + ★ 做法是在面板节点上**重定义 `--color-*` 变量**,而不是逐个改组件的 bg 类: + 主题 token 是写在 上的行内样式,面板节点自己声明的同名变量会遮住它, + 并一路继承给整棵子树 —— 于是面板里所有 `bg-canvas` / `bg-chrome` 一次性变成玻璃。 + `--glass-*` 在 :root 上先算好(var() 在声明它的元素上就地求值),面板里再引用, + 避免「--color-canvas 引用自己」的循环。 + ★ 菜单、弹窗 portal 到 body,不在面板子树里,保持原来的不透明度,读起来才清楚。 +*/ +:root[data-vibrancy] { + --glass-app: color-mix(in srgb, var(--color-app) 18%, transparent); + --glass-surface: color-mix(in srgb, var(--color-surface) 52%, transparent); + --glass-canvas: color-mix(in srgb, var(--color-canvas) 66%, transparent); + --glass-chrome: color-mix(in srgb, var(--color-canvas) 30%, transparent); + --glass-edge: rgb(255 255 255 / 0.55); + --glass-shadow: 0 10px 30px rgb(0 0 0 / 0.08), 0 1px 3px rgb(0 0 0 / 0.06); +} +:root[data-vibrancy][data-theme='dark'] { + --glass-surface: color-mix(in srgb, var(--color-surface) 48%, transparent); + --glass-canvas: color-mix(in srgb, var(--color-canvas) 62%, transparent); + --glass-edge: rgb(255 255 255 / 0.1); + --glass-shadow: 0 12px 36px rgb(0 0 0 / 0.35), 0 1px 3px rgb(0 0 0 / 0.3); +} +:root[data-vibrancy], +:root[data-vibrancy] body { + background: transparent; +} +:root[data-vibrancy] .app-ground { + --color-app: var(--glass-app); +} +:root[data-vibrancy] [data-theme-region='sidebar'], +:root[data-vibrancy] main.app-canvas { + border-radius: 16px; + box-shadow: inset 0 0 0 0.5px var(--glass-edge), var(--glass-shadow); +} +:root[data-vibrancy] [data-theme-region='sidebar'] { + --color-surface: var(--glass-surface); + --color-surface-raised: color-mix(in srgb, var(--color-fg) 6%, transparent); +} +:root[data-vibrancy] [data-theme-region='sidebar'] > aside { + border-radius: 16px; +} +:root[data-vibrancy] main.app-canvas { + --color-canvas: var(--glass-canvas); + --color-chrome: var(--glass-chrome); +} +/* 内容区里本来就要求不透明的视图(终端、代码编辑器)仍然挡住玻璃:它们用的是 surface,不受上面影响 */ + +/* + ── 苹果风格的共享控件类 ────────────────────────────────────────────────── + 给**不走 Arc 组件、自己手写 /