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 }; +}