diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs
new file mode 100644
index 00000000..ad98ca64
--- /dev/null
+++ b/openless-all/app/scripts/windows-ui-config.test.mjs
@@ -0,0 +1,17 @@
+import { readFile } from 'node:fs/promises';
+
+function assertEqual(actual, expected, name) {
+ if (actual !== expected) {
+ throw new Error(`${name}: expected ${expected}, got ${actual}`);
+ }
+}
+
+const raw = await readFile(new URL('../src-tauri/tauri.conf.json', import.meta.url), 'utf-8');
+const config = JSON.parse(raw);
+const mainWindow = config.app.windows.find((window) => window.label === 'main');
+
+if (!mainWindow) {
+ throw new Error('main window config missing');
+}
+
+assertEqual(mainWindow.decorations, false, 'windows main window should use only custom titlebar');
diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json
index a45652ea..1f2be52a 100644
--- a/openless-all/app/src-tauri/tauri.conf.json
+++ b/openless-all/app/src-tauri/tauri.conf.json
@@ -21,7 +21,7 @@
"minWidth": 980,
"minHeight": 640,
"resizable": true,
- "decorations": true,
+ "decorations": false,
"transparent": true,
"shadow": true,
"hiddenTitle": true,
diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx
index 43780554..7b0eac95 100644
--- a/openless-all/app/src/components/Capsule.tsx
+++ b/openless-all/app/src/components/Capsule.tsx
@@ -1,27 +1,10 @@
-// Capsule.tsx — 1:1 移植 `Sources/OpenLessUI/CapsuleView.swift`。
-//
-// Swift 原版用的是 macOS 的 `.ultraThinMaterial` + 白色描边(macOS 26 用 Liquid Glass),
-// **不是**深色 pill —— design_handoff_openless/capsule.jsx 那个 dark pill 是早期设计稿,
-// Swift 实际产品迁到了系统磨砂材质上。
-//
-// 视觉规格(与 Swift 同步):
-// - 总尺寸 176×42 pill
-// - 浅色磨砂背景 (white 0.62 alpha + backdrop blur 28px) + 白色 1px 边框 (alpha 0.34)
-// - 左/右 28×28 圆形按钮:cancel 用半透明 thinMaterial 风、confirm 用 white 0.92
-// - 中间 84pt 宽 slot:根据状态切换 audio bars / dots+text / 状态文字
-//
-// 状态语义对齐 Swift CapsuleState:
-// listening → 5 根 audio bars [.55,.85,1,.85,.55] base 4pt + level*14pt
-// processing → 3 个跳动的圆点 + "正在思考中"
-// inserted → "已插入"
-// cancelled → "已取消"
-// copied → "已复制 ⌘V"
-// error(msg) → 红字
-//
-// 控件可用性:仅 listening 时 cancel/confirm 才能点(与 Swift `isControlEnabled` 一致)。
-
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { detectOS, type OS } from './WindowChrome';
+import {
+ getCapsuleMessageLayout,
+ getCapsulePillMetrics,
+} from '../lib/capsuleLayout';
import { invokeOrMock, isTauri } from '../lib/ipc';
import type { CapsulePayload, CapsuleState } from '../lib/types';
@@ -29,7 +12,6 @@ interface AudioBarsProps {
level: number;
}
-/// 5 根 envelope 条;level=0 时全收到 4pt 基线 → 视觉静止;level↑ → 中间最高条往上拔。
function AudioBars({ level }: AudioBarsProps) {
const envelope = [0.55, 0.85, 1.0, 0.85, 0.55];
const base = 4;
@@ -56,8 +38,6 @@ function AudioBars({ level }: AudioBarsProps) {
borderRadius: 999,
background: 'var(--ol-blue)',
opacity: 0.82,
- // 后端节流到 ~30 Hz(33ms),CSS 用 0.06s linear 让每次 emit 在下一次到达前
- // 已经完整跳到目标高度 → 视觉上是 30 帧/s 的"跳动"动画,不会被叠加平均成静止。
transition: 'height 0.06s linear',
}}
/>
@@ -66,7 +46,6 @@ function AudioBars({ level }: AudioBarsProps) {
);
}
-/// 3 个圆点错相位脉动;总宽 20pt,与 Swift ProgressDots 一致。
function ProcessingDots() {
return (
@@ -88,23 +67,30 @@ function ProcessingDots() {
}
interface CenterTextProps {
+ os: OS;
+ kind: 'default' | 'processing' | 'error';
text: string;
color?: string;
}
-function CenterText({ text, color = 'var(--ol-ink-3)' }: CenterTextProps) {
+function CenterText({ os, kind, text, color = 'var(--ol-ink-3)' }: CenterTextProps) {
+ const metrics = getCapsulePillMetrics(os);
+ const layout = getCapsuleMessageLayout(os, kind);
return (
{text}
@@ -130,7 +116,6 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
width: 28,
height: 28,
borderRadius: 999,
- // Swift cancel: .thinMaterial; confirm: white(0.92)
background: isCancel ? 'rgba(255, 255, 255, 0.55)' : 'rgba(255, 255, 255, 0.92)',
backdropFilter: isCancel ? 'blur(12px) saturate(160%)' : 'none',
WebkitBackdropFilter: isCancel ? 'blur(12px) saturate(160%)' : 'none',
@@ -148,12 +133,10 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
}}
>
{isCancel ? (
- // SF Symbol "xmark" 等价
) : (
- // SF Symbol "checkmark" 等价
@@ -163,6 +146,7 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
}
interface PillProps {
+ os: OS;
state: CapsuleState;
level: number;
insertedChars: number;
@@ -171,9 +155,10 @@ interface PillProps {
onConfirm: () => void;
}
-function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: PillProps) {
+function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: PillProps) {
const { t } = useTranslation();
- // 与 Swift `isControlEnabled` 同语义:只有 listening 时 cancel/confirm 才可点。
+ const metrics = getCapsulePillMetrics(os);
+ const processingLayout = getCapsuleMessageLayout(os, 'processing');
const enabled = state === 'recording';
let center: JSX.Element;
@@ -184,32 +169,56 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
case 'transcribing':
case 'polishing':
center = (
-
+
-
+
{t('capsule.thinking')}
);
break;
case 'done':
- center =
;
+ center =
;
break;
case 'cancelled':
- center =
;
+ center =
;
break;
case 'error':
- center =
;
+ center =
;
break;
default:
center =
;
}
- // 玻璃整体随电平做微缩放 + 投影增强,让"说话时整个胶囊在呼吸"。
- // 仅在录音态联动;其他态保持静止。系数控制在 ≤2%,不破坏 176×42 的视觉规格。
const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0;
- const scale = 1 + ambient * 0.018;
+ const scale = os === 'win' ? 1 : 1 + ambient * 0.018;
const shadowAlpha = 0.20 + ambient * 0.10;
+ const dropShadow = os === 'win'
+ ? `drop-shadow(0 12px 24px rgba(0, 0, 0, ${(0.15 + ambient * 0.06).toFixed(3)}))`
+ : 'none';
return (
@@ -249,7 +257,7 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
}
export function Capsule() {
- // 浏览器 dev 默认显示 listening;Tauri 进来后由后端 idle 覆盖。
+ const os = detectOS();
const [state, setState] = useState
(isTauri ? 'idle' : 'recording');
const [level, setLevel] = useState(isTauri ? 0 : 0.6);
const [insertedChars, setInsertedChars] = useState(0);
@@ -280,11 +288,11 @@ export function Capsule() {
const onCancel = () => {
void invokeOrMock('cancel_dictation', undefined, () => undefined);
};
+
const onConfirm = () => {
void invokeOrMock('stop_dictation', undefined, () => undefined);
};
- // idle 状态视觉上隐藏(panel 也会被后端 hide);保留容器避免 React 卸载抖动。
if (state === 'idle') {
return ;
}
@@ -298,10 +306,11 @@ export function Capsule() {
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
- animation: 'capsule-in .22s cubic-bezier(.2,.9,.3,1.1)',
+ animation: os === 'win' ? 'none' : 'capsule-in .22s cubic-bezier(.2,.9,.3,1.1)',
}}
>
(actual: T, expected: T, name: string) {
+ if (actual !== expected) {
+ throw new Error(`${name}: expected ${expected}, got ${actual}`);
+ }
+}
+
+const winMetrics = getCapsulePillMetrics('win');
+assertEqual(winMetrics.width, 196, 'windows capsule widens pill');
+assertEqual(winMetrics.height, 52, 'windows capsule increases pill height');
+assertEqual(winMetrics.textWidth, 118, 'windows capsule widens text slot');
+
+const macMetrics = getCapsulePillMetrics('mac');
+assertEqual(macMetrics.width, 176, 'mac capsule keeps existing pill width');
+assertEqual(macMetrics.height, 42, 'mac capsule keeps existing pill height');
+assertEqual(macMetrics.textWidth, 84, 'mac capsule keeps existing text slot');
+
+const winErrorLayout = getCapsuleMessageLayout('win', 'error');
+assertEqual(winErrorLayout.lineClamp, 2, 'windows error message allows two lines');
+assertEqual(winErrorLayout.allowWrap, true, 'windows error message wraps');
+
+const winProcessingLayout = getCapsuleMessageLayout('win', 'processing');
+assertEqual(winProcessingLayout.lineClamp, 2, 'windows processing label allows two lines');
+assertEqual(winProcessingLayout.allowWrap, true, 'windows processing label wraps');
+
+const macErrorLayout = getCapsuleMessageLayout('mac', 'error');
+assertEqual(macErrorLayout.lineClamp, 1, 'mac error message stays single-line');
+assertEqual(macErrorLayout.allowWrap, false, 'mac error message stays nowrap');
diff --git a/openless-all/app/src/lib/capsuleLayout.ts b/openless-all/app/src/lib/capsuleLayout.ts
new file mode 100644
index 00000000..7a614c3f
--- /dev/null
+++ b/openless-all/app/src/lib/capsuleLayout.ts
@@ -0,0 +1,33 @@
+import type { OS } from '../components/WindowChrome';
+
+export type CapsuleMessageKind = 'default' | 'processing' | 'error';
+
+export interface CapsulePillMetrics {
+ width: number;
+ height: number;
+ textWidth: number;
+}
+
+export interface CapsuleMessageLayout {
+ allowWrap: boolean;
+ lineClamp: number;
+}
+
+export function getCapsulePillMetrics(os: OS): CapsulePillMetrics {
+ if (os === 'win') {
+ return { width: 196, height: 52, textWidth: 118 };
+ }
+
+ return { width: 176, height: 42, textWidth: 84 };
+}
+
+export function getCapsuleMessageLayout(
+ os: OS,
+ kind: CapsuleMessageKind,
+): CapsuleMessageLayout {
+ if (os === 'win' && (kind === 'error' || kind === 'processing')) {
+ return { allowWrap: true, lineClamp: 2 };
+ }
+
+ return { allowWrap: false, lineClamp: 1 };
+}