feat(capsule): typeless 放大到原版 1/5 面积、黑底白波纹;Siri 预览改用真实光效舞台

- 窗口 174×48 → 206×57,CSS zoom 0.378 → 0.447(面积 = 原版 460×128 的 1/5)
- typeless 配色改为黑色胶囊 + 白色波纹(深色主题同调)
- VoiceOrbStage 提取为独立组件;设置页 Siri 预览直接渲染缩放 0.5 的真实
  SiriGL 光条,与实际胶囊样式一致
This commit is contained in:
sim committed 2026-09-10 13:15:49 +08:00
1 parent 105fb99dc0
commit 7204c282f1
7 files changed
+162 -156

No files matched your search

+5 -5
View File
@@ -3332,16 +3332,16 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds {
fn capsule_window_bounds_for_style(style: types::CapsuleStyle) -> CapsuleWindowBounds {
CapsuleWindowBounds {
// typeless 窗口面积是旧尺寸(460×128)的 1/7;前端用 CSS zoom 同步缩放内容,
// typeless 窗口面积是原尺寸(460×128)的 1/5;前端用 CSS zoom 同步缩放内容,
// 见 CapsuleStyles.css 与 src/lib/capsuleLayout.ts。
width: match style {
types::CapsuleStyle::Typeless => 174.0,
types::CapsuleStyle::Typeless => 206.0,
types::CapsuleStyle::Siri | types::CapsuleStyle::Classic => 460.0,
},
height: match style {
types::CapsuleStyle::Siri => 180.0,
types::CapsuleStyle::Classic => 100.0,
types::CapsuleStyle::Typeless => 48.0,
types::CapsuleStyle::Typeless => 57.0,
},
bottom_inset: 0.0,
}
@@ -3599,9 +3599,9 @@ mod tests {
}
#[test]
fn typeless_capsule_window_is_one_seventh_of_the_old_area() {
fn typeless_capsule_window_is_one_fifth_of_the_old_area() {
let bounds = capsule_window_bounds_for_style(CapsuleStyle::Typeless);
assert_eq!((bounds.width, bounds.height), (174.0, 48.0));
assert_eq!((bounds.width, bounds.height), (206.0, 57.0));
}
#[test]
+2 -119
View File
@@ -6,12 +6,12 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { detectOS, type OS } from './WindowChrome';
import { SiriGL, warmUpSiriShaders } from './SiriGL';
import { warmUpSiriShaders } from './SiriGL';
import { VoiceOrbStage } from './VoiceOrbStage';
import { TypelessCapsule } from './TypelessCapsule';
import { getSettings } from '../lib/ipc/settings';
import { cancelDictation, stopDictation } from '../lib/ipc/dictation';
@@ -79,123 +79,6 @@ if (typeof document !== 'undefined' && !document.getElementById('capsule-keyfram
document.head.appendChild(tag);
}
interface VoiceOrbStageProps {
os: OS;
state: CapsuleState;
level: number;
/** 预备态:录音光条渲染成「待命」呼吸形态,不接真实电平。见 CapsulePayload.warming。 */
warming?: boolean;
/** 预备→就绪的平均耗时(ms),驱动展开动画的预测节奏。见 SiriGL warmupMs。 */
warmupMs?: number;
message?: string;
}
/**
* 纯光效舞台(siri-glsl 完整克隆,无壳无按钮无底):
* - recording:彩虹光谱声波横贯舞台,振幅随真实麦克风电平起伏;
* - transcribing / polishing:波形从两端向中间收缩汇聚,流体圆点环淡入加速转动;
* - done / cancelled:转速回落标准,六点合并成中央一颗圆,由外层 capsule-out 淡出;
* - error:冻结光效 + 浮一行发光红字说明原因(唯一保留的文字信息)。
* 刻意没有任何垫底/暗晕(用户拍板):白底界面上宁可对比度弱,也不要黑色遮挡。
*/
function VoiceOrbStage({ os, state, level, warming, warmupMs, message }: VoiceOrbStageProps) {
const { t } = useTranslation();
const metrics = useMemo(() => getCapsulePillMetrics(os), [os]);
// done / cancelled / error 冻结最后形态淡出,不再切换 phase。
const lastPhaseRef = useRef<'wave' | 'orb'>('wave');
let phase = lastPhaseRef.current;
if (state === 'recording') phase = 'wave';
else if (state === 'transcribing' || state === 'polishing') phase = 'orb';
lastPhaseRef.current = phase;
const isOrb = phase === 'orb';
// 性能:波形淡出彻底结束(.55s delay + .6s duration)后卸载它的绘制循环 ——
// 思考期间不再为一块不可见的 canvas 每帧跑 fragment。回到录音态立即重挂
//(shader 编译已被驱动缓存,重建近零耗时)。
const [waveAlive, setWaveAlive] = useState(true);
useEffect(() => {
if (!isOrb) {
setWaveAlive(true);
return undefined;
}
const timer = setTimeout(() => setWaveAlive(false), 1300);
return () => clearTimeout(timer);
}, [isOrb]);
return (
<div
style={{
width: metrics.width,
height: metrics.height,
boxSizing: metrics.boxSizing,
fontFamily: 'var(--ol-font-sans)',
position: 'relative',
pointerEvents: 'none',
}}
>
{waveAlive && (
<SiriGL
mode="wave"
level={level}
resolved={!isOrb}
warming={warming}
warmupMs={warmupMs}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
// 收缩汇聚进行时波形保持可见,收成中央光点后再淡出,与圆点环的淡入交叠。
opacity: isOrb ? 0 : 1,
transition: isOrb ? 'opacity .6s ease-out .55s' : 'opacity .25s ease-out',
}}
/>
)}
{isOrb && (
<SiriGL
mode="orb"
// 思考中(LLM 接收)加速转动;插入/取消/出错时回落到标准速度,
// 同时六点合并成中央一颗圆,随外层淡出一起消失。
speed={state === 'transcribing' || state === 'polishing' ? 1.5 : 1.0}
merging={state !== 'transcribing' && state !== 'polishing'}
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: 170,
height: 170,
marginLeft: -85,
marginTop: -85,
animation: 'siri-orb-in .7s ease-out .3s both',
}}
/>
)}
{state === 'error' && <span style={errorGlowTextStyle}>{message || t('capsule.error')}</span>}
</div>
);
}
const errorGlowTextStyle: CSSProperties = {
position: 'absolute',
bottom: 24,
left: '50%',
transform: 'translateX(-50%)',
maxWidth: 400,
fontSize: 12,
fontWeight: 600,
lineHeight: 1.4,
textAlign: 'center',
color: 'var(--ol-err)',
padding: '6px 12px',
background: 'var(--ol-capsule-pill-bg)',
border: '1px solid var(--ol-capsule-pill-border)',
borderRadius: 12,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
interface SelectionPolishNoticeProps {
state: CapsuleState;
message?: string;
@@ -1,16 +1,21 @@
.ol-typeless-capsule-wrap {
--ol-typeless-bg: #fafafa;
--ol-typeless-border: #d4d4d8;
--ol-typeless-ink: #18181b;
--ol-typeless-button-bg: #e4e4e7;
--ol-typeless-button-hover: #d4d4d8;
--ol-typeless-confirm-bg: #27272a;
--ol-typeless-confirm-ink: #ffffff;
--ol-typeless-bg: #18181b;
--ol-typeless-border: #52525b;
--ol-typeless-ink: #fafafa;
--ol-typeless-button-bg: #3f3f46;
--ol-typeless-button-hover: #52525b;
--ol-typeless-confirm-bg: #fafafa;
--ol-typeless-confirm-ink: #18181b;
position: relative;
font-family: var(--ol-font-sans);
/* 整体缩到 1/7 面积(线性 1/√7≈0.378),与 174×48 的胶囊窗口配套;
/* 整体缩放使药丸面积为原尺寸的 1/5(线性 1/√5≈0.447),与 206×57 的胶囊窗口配套;
尺寸基准见 src-tauri/src/lib.rs 的 capsule_window_bounds_for_style。 */
zoom: 0.378;
zoom: 0.447;
}
/* 黑色胶囊配白色波纹。 */
.ol-typeless-capsule .ol-capsule-waveform > span {
background: #ffffff;
}
.ol-typeless-capsule {
@@ -212,28 +217,22 @@
width: 60px;
transform: scale(0.7);
}
.ol-siri-capsule-preview .ol-capsule-waveform {
width: 160px;
gap: 5px;
filter: drop-shadow(0 0 6px #8b5cf680);
/* Siri 预览 = 缩小的真实光效舞台(460×180 → ×0.5 = 230×90,正好放进 256×96 预览框)。 */
.ol-siri-capsule-preview {
display: flex;
align-items: center;
justify-content: center;
}
.ol-siri-capsule-preview .ol-capsule-waveform > span {
width: 8px;
background: #a580ff;
}
.ol-siri-capsule-preview .ol-capsule-waveform > span:nth-child(-n + 4) {
background: #65a5ff;
}
.ol-siri-capsule-preview .ol-capsule-waveform > span:nth-child(n + 8) {
background: #fa84b3;
.ol-siri-capsule-preview > div {
transform: scale(0.5);
}
[data-ol-theme='dark'] .ol-typeless-capsule-wrap {
--ol-typeless-bg: #202024;
--ol-typeless-border: #505056;
--ol-typeless-bg: #101014;
--ol-typeless-border: #3f3f46;
--ol-typeless-ink: #fafafa;
--ol-typeless-button-bg: #414148;
--ol-typeless-button-hover: #55555e;
--ol-typeless-button-bg: #27272a;
--ol-typeless-button-hover: #3f3f46;
--ol-typeless-confirm-bg: #fafafa;
--ol-typeless-confirm-ink: #18181b;
}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { cancelDictation, stopDictation } from '../lib/ipc/dictation';
import type { CapsuleState, CapsuleStyle } from '../lib/types';
import { Icon } from './Icon';
import { VoiceOrbStage } from './VoiceOrbStage';
import './CapsuleStyles.css';
interface TypelessCapsuleProps {
@@ -122,7 +123,7 @@ export function CapsuleStylePreview({ style }: { style: CapsuleStyle }) {
</div>
) : (
<div className="ol-siri-capsule-preview">
<CapsuleWaveform level={0.2} />
<VoiceOrbStage os="mac" state="recording" level={0.2} />
</div>
)}
</div>
@@ -0,0 +1,123 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { SiriGL } from './SiriGL';
import { getCapsulePillMetrics } from '../lib/capsuleLayout';
import type { CapsuleState } from '../lib/types';
import type { OS } from './WindowChrome';
export interface VoiceOrbStageProps {
os: OS;
state: CapsuleState;
level: number;
/** 预备态:录音光条渲染成「待命」呼吸形态,不接真实电平。见 CapsulePayload.warming。 */
warming?: boolean;
/** 预备→就绪的平均耗时(ms),驱动展开动画的预测节奏。见 SiriGL warmupMs。 */
warmupMs?: number;
message?: string;
}
/**
* 纯光效舞台(siri-glsl 完整克隆,无壳无按钮无底):
* - recording:彩虹光谱声波横贯舞台,振幅随真实麦克风电平起伏;
* - transcribing / polishing:波形从两端向中间收缩汇聚,流体圆点环淡入加速转动;
* - done / cancelled:转速回落标准,六点合并成中央一颗圆,由外层 capsule-out 淡出;
* - error:冻结光效 + 浮一行发光红字说明原因(唯一保留的文字信息)。
* 刻意没有任何垫底/暗晕(用户拍板):白底界面上宁可对比度弱,也不要黑色遮挡。
*/
export function VoiceOrbStage({ os, state, level, warming, warmupMs, message }: VoiceOrbStageProps) {
const { t } = useTranslation();
const metrics = useMemo(() => getCapsulePillMetrics(os), [os]);
// done / cancelled / error 冻结最后形态淡出,不再切换 phase。
const lastPhaseRef = useRef<'wave' | 'orb'>('wave');
let phase = lastPhaseRef.current;
if (state === 'recording') phase = 'wave';
else if (state === 'transcribing' || state === 'polishing') phase = 'orb';
lastPhaseRef.current = phase;
const isOrb = phase === 'orb';
// 性能:波形淡出彻底结束(.55s delay + .6s duration)后卸载它的绘制循环 ——
// 思考期间不再为一块不可见的 canvas 每帧跑 fragment。回到录音态立即重挂
//(shader 编译已被驱动缓存,重建近零耗时)。
const [waveAlive, setWaveAlive] = useState(true);
useEffect(() => {
if (!isOrb) {
setWaveAlive(true);
return undefined;
}
const timer = setTimeout(() => setWaveAlive(false), 1300);
return () => clearTimeout(timer);
}, [isOrb]);
return (
<div
style={{
width: metrics.width,
height: metrics.height,
boxSizing: metrics.boxSizing,
fontFamily: 'var(--ol-font-sans)',
position: 'relative',
pointerEvents: 'none',
}}
>
{waveAlive && (
<SiriGL
mode="wave"
level={level}
resolved={!isOrb}
warming={warming}
warmupMs={warmupMs}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
// 收缩汇聚进行时波形保持可见,收成中央光点后再淡出,与圆点环的淡入交叠。
opacity: isOrb ? 0 : 1,
transition: isOrb ? 'opacity .6s ease-out .55s' : 'opacity .25s ease-out',
}}
/>
)}
{isOrb && (
<SiriGL
mode="orb"
// 思考中(LLM 接收)加速转动;插入/取消/出错时回落到标准速度,
// 同时六点合并成中央一颗圆,随外层淡出一起消失。
speed={state === 'transcribing' || state === 'polishing' ? 1.5 : 1.0}
merging={state !== 'transcribing' && state !== 'polishing'}
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: 170,
height: 170,
marginLeft: -85,
marginTop: -85,
animation: 'siri-orb-in .7s ease-out .3s both',
}}
/>
)}
{state === 'error' && <span style={errorGlowTextStyle}>{message || t('capsule.error')}</span>}
</div>
);
}
const errorGlowTextStyle: CSSProperties = {
position: 'absolute',
bottom: 24,
left: '50%',
transform: 'translateX(-50%)',
maxWidth: 400,
fontSize: 12,
fontWeight: 600,
lineHeight: 1.4,
textAlign: 'center',
color: 'var(--ol-err)',
padding: '6px 12px',
background: 'var(--ol-capsule-pill-bg)',
border: '1px solid var(--ol-capsule-pill-border)',
borderRadius: 12,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
@@ -86,8 +86,8 @@ for (const os of ['mac', 'win', 'linux'] as const) {
const classic = getCapsuleHostMetrics(os, false, 'classic');
const typeless = getCapsuleHostMetrics(os, true, 'typeless');
assertEqual(classic.height, 100, `${os}: classic uses the compact native window`);
assertEqual(typeless.height, 48, `${os}: typeless window is 1/7 of the old 460x128 area`);
assertEqual(typeless.width, 174, `${os}: typeless window keeps the 1/7 stage width`);
assertEqual(typeless.height, 57, `${os}: typeless window is 1/5 of the old 460x128 area`);
assertEqual(typeless.width, 206, `${os}: typeless window keeps the 1/5 stage width`);
assertEqual(
typeless.bottomInset,
0,
+3 -3
View File
@@ -31,10 +31,10 @@ const VOICE_ORB_STAGE_WIDTH = 460;
const VOICE_ORB_STAGE_HEIGHT = 180;
const VOICE_ORB_TEXT_WIDTH = 400;
// typeless 窗口面积是旧尺寸(460×128)的 1/7;内容由 CapsuleStyles.css 的 zoom 缩放,
// typeless 窗口面积是原尺寸(460×128)的 1/5;内容由 CapsuleStyles.css 的 zoom 缩放,
// 与 src-tauri/src/lib.rs 的 capsule_window_bounds_for_style 保持一致。
const TYPELESS_STAGE_WIDTH = 174;
const TYPELESS_STAGE_HEIGHT = 48;
const TYPELESS_STAGE_WIDTH = 206;
const TYPELESS_STAGE_HEIGHT = 57;
export function parseCapsuleStyle(value: unknown): CapsuleStyle | undefined {
return value === 'siri' || value === 'classic' || value === 'typeless' ? value : undefined;