webview 无法模糊透明窗口背后的桌面(Tauri 上游限制 #2827/#10064/#6876),原 backdrop-filter 在 qa / capsule 透明浮窗里是死代码。改用纯静态 CSS 的「假毛玻璃」:半透明白底 + 分形噪点颗粒 + 内描边高光 + 柔和阴影,三平台像素一致、零运行时开销。 - tokens.css: 新增 --ol-frost-grain(不透明灰度分形噪点 data URI) - global.css: 新增 .ol-frost 类,::before 叠噪点颗粒层 - QaPanel / Capsule: 外壳套用 .ol-frost,移除这两个面上死的 backdrop-filter
489 lines
19 KiB
TypeScript
489 lines
19 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { detectOS, type OS } from './WindowChrome';
|
||
import {
|
||
getCapsuleHostMetrics,
|
||
getCapsuleMessageLayout,
|
||
getCapsulePillMetrics,
|
||
} from '../lib/capsuleLayout';
|
||
import { invokeOrMock, isTauri } from '../lib/ipc';
|
||
import type { CapsulePayload, CapsuleState } from '../lib/types';
|
||
|
||
interface AudioBarsProps {
|
||
level: number;
|
||
}
|
||
|
||
function AudioBars({ level }: AudioBarsProps) {
|
||
const envelope = [0.55, 0.85, 1.0, 0.85, 0.55];
|
||
const base = 2;
|
||
const max = 24;
|
||
const voice = Math.min(1, Math.max(0, level));
|
||
const silenceGate = 0.012;
|
||
const responseCeiling = 0.34;
|
||
const gatedVoice = Math.min(1, Math.max(0, (voice - silenceGate) / (responseCeiling - silenceGate)));
|
||
const easedVoice = gatedVoice * gatedVoice * (3 - 2 * gatedVoice);
|
||
const visualVoice = Math.pow(easedVoice, 0.42);
|
||
return (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 3,
|
||
width: 42,
|
||
height: max,
|
||
}}
|
||
>
|
||
{envelope.map((env, i) => (
|
||
<span
|
||
key={i}
|
||
style={{
|
||
display: 'inline-block',
|
||
width: 3,
|
||
height: base + (max - base) * visualVoice * env,
|
||
borderRadius: 999,
|
||
background: 'var(--ol-blue)',
|
||
opacity: 0.82,
|
||
transformOrigin: 'center',
|
||
// 0.08s 在 60Hz audio-level 更新下太快,每次 re-render 都重启 transition,
|
||
// 视觉上是阶梯式跳变。延长到 0.18s 让多次 update 在曲线内平滑混合,
|
||
// easeOutExpo-like 缓动让圆点→长条的形变自然顺滑(用户原话"圆形跳成矩形")。
|
||
transition: 'height 0.18s cubic-bezier(0.22, 1, 0.36, 1)',
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface CenterTextProps {
|
||
os: OS;
|
||
kind: 'default' | 'processing' | 'error';
|
||
text: string;
|
||
color?: string;
|
||
}
|
||
|
||
function CenterText({ os, kind, text, color = 'var(--ol-ink-3)' }: CenterTextProps) {
|
||
const metrics = getCapsulePillMetrics(os);
|
||
const layout = getCapsuleMessageLayout(os, kind);
|
||
return (
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
color,
|
||
width: '100%',
|
||
maxWidth: metrics.textWidth,
|
||
minWidth: 0,
|
||
textAlign: 'center',
|
||
lineHeight: layout.allowWrap ? 1.2 : 1,
|
||
whiteSpace: layout.allowWrap ? 'normal' : 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
display: '-webkit-box',
|
||
WebkitBoxOrient: 'vertical',
|
||
WebkitLineClamp: layout.lineClamp,
|
||
}}
|
||
>
|
||
{text}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
interface CircleButtonProps {
|
||
variant: 'cancel' | 'confirm';
|
||
enabled: boolean;
|
||
onClick: () => void;
|
||
}
|
||
|
||
function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
|
||
const { t } = useTranslation();
|
||
const isCancel = variant === 'cancel';
|
||
// confirm 是主操作锚点,纯白;cancel 半透 + 自带 backdrop blur 跟 pill 拉开层级。
|
||
const useBackdrop = isCancel;
|
||
return (
|
||
<button
|
||
onClick={enabled ? onClick : undefined}
|
||
aria-label={isCancel ? t('common.cancel') : t('settings.shortcuts.confirm')}
|
||
disabled={!enabled}
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 999,
|
||
background: isCancel ? 'rgba(255, 255, 255, 0.55)' : 'rgba(255, 255, 255, 0.92)',
|
||
backdropFilter: useBackdrop ? 'blur(12px) saturate(160%)' : 'none',
|
||
WebkitBackdropFilter: useBackdrop ? 'blur(12px) saturate(160%)' : 'none',
|
||
color: 'var(--ol-ink)',
|
||
border: '0.8px solid rgba(0, 0, 0, 0.08)',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: enabled ? 'default' : 'not-allowed',
|
||
opacity: enabled ? 1 : 0.42,
|
||
visibility: 'visible',
|
||
flexShrink: 0,
|
||
padding: 0,
|
||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
|
||
transition: 'opacity 0.18s var(--ol-motion-soft), background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
|
||
}}
|
||
>
|
||
{isCancel ? (
|
||
<svg width="11" height="11" viewBox="0 0 11 11">
|
||
<path d="M1.5 1.5l8 8M9.5 1.5l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||
</svg>
|
||
) : (
|
||
<svg width="13" height="13" viewBox="0 0 13 13">
|
||
<path d="M2 6.5l3.2 3.5L11 3.5" stroke="currentColor" strokeWidth="1.7" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
interface PillProps {
|
||
os: OS;
|
||
state: CapsuleState;
|
||
level: number;
|
||
insertedChars: number;
|
||
message?: string;
|
||
onCancel: () => void;
|
||
onConfirm: () => void;
|
||
}
|
||
|
||
function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: PillProps) {
|
||
const { t } = useTranslation();
|
||
const metrics = getCapsulePillMetrics(os);
|
||
const processingLayout = getCapsuleMessageLayout(os, 'processing');
|
||
const enabled = state === 'recording';
|
||
|
||
// "thinking" 扫光速度:进入 transcribing/polishing 的头 2 秒走快速(0.9s/cycle,提示
|
||
// 「流式刚开始」),之后切回慢速(2.4s)作为稳态。切回 idle / done / 其他 state 也复位
|
||
// 为 fast,下次进入时从头开始 burst。
|
||
const [shineFast, setShineFast] = useState(true);
|
||
useEffect(() => {
|
||
if (state === 'transcribing' || state === 'polishing') {
|
||
setShineFast(true);
|
||
const t = setTimeout(() => setShineFast(false), 2000);
|
||
return () => clearTimeout(t);
|
||
}
|
||
setShineFast(true);
|
||
return undefined;
|
||
}, [state]);
|
||
|
||
let center: JSX.Element;
|
||
switch (state) {
|
||
case 'recording':
|
||
center = <AudioBars level={level} />;
|
||
break;
|
||
case 'transcribing':
|
||
case 'polishing':
|
||
center = (
|
||
<div
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
// 左右 4px 内边距 + 外层 gap 已经让 "thinking" ↔ ✗/✓ 视觉间距落在 ~4-5px。
|
||
padding: '0 4px',
|
||
width: '100%',
|
||
maxWidth: metrics.textWidth,
|
||
minWidth: 0,
|
||
justifyContent: 'center',
|
||
// state 进入动画 —— 用户从 recording 切到 polishing 时多一道淡入提示,
|
||
// 比纯切换 center 内容更容易被感知。
|
||
animation: 'cap-state-enter 220ms var(--ol-motion-soft) both',
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
// v1.3.1-7 用户拍板:黑色底字 + 蓝色扫光(亮黄太显眼,黑底更稳)。
|
||
// 字号保持 17,字重 700 → 600 稍细一些。
|
||
fontSize: 17,
|
||
fontWeight: 600,
|
||
letterSpacing: 0.3,
|
||
// line-height: 1 下 g/y/p 等下伸字符会被 clip,给 padding 留 descender 空间。
|
||
paddingBlock: 1,
|
||
color: 'var(--ol-ink-2)',
|
||
backgroundImage:
|
||
'linear-gradient(100deg, var(--ol-ink) 0%, var(--ol-ink) 35%, var(--ol-blue) 50%, var(--ol-ink) 65%, var(--ol-ink) 100%)',
|
||
backgroundSize: '220% auto',
|
||
WebkitBackgroundClip: 'text',
|
||
backgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
// 进入流式的头 ~2 秒用 0.9s 高速扫光(视觉提示「刚开始」),之后 React 副作用
|
||
// 切到 2.4s 慢速。duration 变化时浏览器不重启动画,会平滑减速。
|
||
animation: `cap-shine ${shineFast ? '0.9s' : '2.4s'} linear infinite`,
|
||
minWidth: 0,
|
||
textAlign: 'center',
|
||
lineHeight: processingLayout.allowWrap ? 1.3 : 1.25,
|
||
whiteSpace: processingLayout.allowWrap ? 'normal' : 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
display: '-webkit-box',
|
||
WebkitBoxOrient: 'vertical',
|
||
WebkitLineClamp: processingLayout.lineClamp,
|
||
}}
|
||
>
|
||
{t('capsule.thinking')}
|
||
</span>
|
||
</div>
|
||
);
|
||
break;
|
||
case 'done':
|
||
center = <CenterText os={os} kind="default" text={message || t('capsule.inserted', { count: insertedChars })} />;
|
||
break;
|
||
case 'cancelled':
|
||
center = <CenterText os={os} kind="default" text={t('capsule.cancelled')} />;
|
||
break;
|
||
case 'error':
|
||
center = <CenterText os={os} kind="error" text={message || t('capsule.error')} color="var(--ol-err)" />;
|
||
break;
|
||
default:
|
||
center = <AudioBars level={0} />;
|
||
}
|
||
|
||
const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0;
|
||
const scale = os === 'win' ? 1 : 1 + ambient * 0.018;
|
||
const shadowAlpha = 0.20 + ambient * 0.10;
|
||
|
||
return (
|
||
// 假毛玻璃:半透明白底 + .ol-frost 噪点纹理 + 内描边高光 + 柔和阴影。
|
||
// 不写 backdrop-filter —— webview 模糊不了透明窗口背后的桌面(Tauri 上游限制)。
|
||
<div
|
||
className="ol-frost"
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 4,
|
||
padding: '0 8px',
|
||
width: metrics.width,
|
||
height: metrics.height,
|
||
boxSizing: metrics.boxSizing,
|
||
borderRadius: 999,
|
||
border: '1px solid rgba(255, 255, 255, 0.55)',
|
||
boxShadow: os === 'win'
|
||
? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.8)`
|
||
: `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.8)`,
|
||
color: 'var(--ol-ink)',
|
||
fontFamily: 'var(--ol-font-sans)',
|
||
transform: `scale(${scale.toFixed(4)})`,
|
||
transformOrigin: 'center',
|
||
transition: 'transform 0.08s var(--ol-motion-quick), box-shadow 0.08s var(--ol-motion-quick)',
|
||
willChange: 'transform, box-shadow',
|
||
}}
|
||
>
|
||
<CircleButton variant="cancel" enabled={enabled} onClick={onCancel} />
|
||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
{center}
|
||
</div>
|
||
<CircleButton variant="confirm" enabled={enabled} onClick={onConfirm} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 与 @keyframes capsule-out 的 0.36s 时长一致——必须同步,否则定时器先于
|
||
// 动画结束就 unmount → 用户看到半截动画被截断。
|
||
// v1.3.1-6: 从 240ms 加到 360ms 让用户看清退出动画(240ms 太快感知不到)。
|
||
const EXIT_ANIM_MS = 360;
|
||
// 初始可见 state:Tauri 内运行从 idle 开始(等后端 capsule:state 事件),
|
||
// 浏览器 dev 模式从 recording 开始以便直接看到胶囊。
|
||
const INITIAL_VISIBLE_STATE: CapsuleState = isTauri ? 'idle' : 'recording';
|
||
|
||
export function Capsule() {
|
||
const { t } = useTranslation();
|
||
const os = detectOS();
|
||
const metrics = getCapsulePillMetrics(os);
|
||
const [state, setState] = useState<CapsuleState>(INITIAL_VISIBLE_STATE);
|
||
const [level, setLevel] = useState<number>(isTauri ? 0 : 0.6);
|
||
const [insertedChars, setInsertedChars] = useState<number>(0);
|
||
const [message, setMessage] = useState<string | undefined>();
|
||
const [translation, setTranslation] = useState<boolean>(false);
|
||
// `leaving` 与 `lastVisibleState` 协同实现「退出动画」:
|
||
// - 当 state 从非 idle 变成 idle 时,不立即卸载,而是把 leaving 置为 true 并保留
|
||
// 最后一帧的可见 state(lastVisibleState),让胶囊用 capsule-out 动画收缩淡出。
|
||
// - 动画结束(EXIT_ANIM_MS)后再把 leaving 置回 false,组件回到「真正未挂载」分支。
|
||
// - 若期间 state 又切回非 idle(例如用户连按热键),立刻中止 leaving 并恢复显示。
|
||
const [leaving, setLeaving] = useState<boolean>(false);
|
||
const [lastVisibleState, setLastVisibleState] = useState<CapsuleState>(INITIAL_VISIBLE_STATE);
|
||
// Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。
|
||
const hostMetrics = getCapsuleHostMetrics(os, translation);
|
||
|
||
useEffect(() => {
|
||
if (!isTauri) return;
|
||
let unlisten: (() => void) | undefined;
|
||
let cancelled = false;
|
||
(async () => {
|
||
const { listen } = await import('@tauri-apps/api/event');
|
||
const handle = await listen<CapsulePayload>('capsule:state', event => {
|
||
const p = event.payload;
|
||
setState(p.state);
|
||
setLevel(p.level ?? 0);
|
||
setMessage(p.message ?? undefined);
|
||
if (p.insertedChars != null) setInsertedChars(p.insertedChars);
|
||
setTranslation(p.translation === true);
|
||
});
|
||
if (cancelled) handle();
|
||
else unlisten = handle;
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
if (unlisten) unlisten();
|
||
};
|
||
}, []);
|
||
|
||
// 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放 EXIT_ANIM_MS,再卸载。
|
||
// 设计要点:
|
||
// 1. 进入非 idle:清掉 leaving,记录最新可见 state;
|
||
// 2. 进入 idle 且之前可见:开启 leaving 并启动定时器;
|
||
// 3. 期间又被打回非 idle:cleanup 直接 clearTimeout,定时器不会触发,
|
||
// 新一轮 effect 会立即恢复可见态,避免错误地把可见状态切到 idle。
|
||
useEffect(() => {
|
||
if (state !== 'idle') {
|
||
// 立即恢复可见,并取消上一轮可能挂着的离场。
|
||
if (leaving) setLeaving(false);
|
||
setLastVisibleState(state);
|
||
return undefined;
|
||
}
|
||
// state === 'idle':判断是不是从可见态过渡过来。
|
||
if (lastVisibleState === 'idle') return undefined;
|
||
setLeaving(true);
|
||
const timer = setTimeout(() => {
|
||
setLeaving(false);
|
||
setLastVisibleState('idle');
|
||
}, EXIT_ANIM_MS);
|
||
return () => clearTimeout(timer);
|
||
// 故意只依赖 state —— lastVisibleState / leaving 是内部派生量,
|
||
// 把它们加进依赖会让定时器被反复重建。
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [state]);
|
||
|
||
const onCancel = () => {
|
||
void invokeOrMock<void>('cancel_dictation', undefined, () => undefined);
|
||
};
|
||
|
||
const onConfirm = () => {
|
||
void invokeOrMock<void>('stop_dictation', undefined, () => undefined);
|
||
};
|
||
|
||
// 真正卸载:state 已是 idle,且不在离场动画中。
|
||
if (state === 'idle' && !leaving) {
|
||
return <div style={{ width: 0, height: 0 }} />;
|
||
}
|
||
|
||
// 离场时用 lastVisibleState 渲染最后一帧内容,避免把 idle 当作 fallback 走到 AudioBars(0)。
|
||
const renderedState: CapsuleState = state === 'idle' ? lastVisibleState : state;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
position: 'relative',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingLeft: hostMetrics.horizontalInset,
|
||
paddingRight: hostMetrics.horizontalInset,
|
||
boxSizing: hostMetrics.boxSizing,
|
||
paddingTop: os === 'win'
|
||
? Math.max(0, hostMetrics.height - metrics.height - hostMetrics.bottomInset)
|
||
: 0,
|
||
paddingBottom: os === 'win' ? hostMetrics.bottomInset : 0,
|
||
background: 'transparent',
|
||
// 入场:中央 scaleX 由 0.18 长到 1(视觉上像从中心向两端展开)+ 淡入。
|
||
// 离场:scaleX 由 1 收缩回 0.18 + 向下偏移 8px + 淡出。
|
||
// 三平台一致 —— 旧版 Windows 走 animation:'none' 的分支已删除。
|
||
// transformOrigin 默认就是 50% 50%,所以 scaleX 天然以中央为锚点。
|
||
animation: leaving
|
||
// v1.3.1-6 调整:
|
||
// - 入场 .26s → .38s,cubic-bezier 加强 spring overshoot(更曲线感)
|
||
// - 出场 .24s → .36s(前面 EXIT_ANIM_MS 也同步到 360),曲线改成 ease-in-out 平滑
|
||
// 收缩 + 下移 + 淡出三段同步进行
|
||
? 'capsule-out .36s cubic-bezier(.55,.06,.68,.19) forwards'
|
||
: 'capsule-in .38s cubic-bezier(.16,.86,.32,1.18) both',
|
||
transformOrigin: 'center',
|
||
willChange: 'transform, opacity',
|
||
}}
|
||
>
|
||
{/* "正在翻译" 徽章 — 嵌套两层:
|
||
外层只负责"绝对定位 + 水平居中(translateX(-50%))",不参与动画;
|
||
内层只负责"垂直位移 + 渐变透明度"——这样不会跟 translateX(-50%) 冲突,
|
||
也不存在 keyframe 与 inline transform 互相覆盖导致的视觉跳变。 */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: '50%',
|
||
// macOS / Linux:胶囊窗口 220×110、pill 居中,badge 锚到 pill 中线上方 21+8。
|
||
// Windows:host 比 pill 多出左右 12px / 底部 12px 的阴影空间,pill 仍保持居中。
|
||
bottom: os === 'win'
|
||
? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`
|
||
: 'calc(50% + 21px + 8px)',
|
||
transform: 'translateX(-50%)',
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 5,
|
||
padding: '3px 10px',
|
||
borderRadius: 999,
|
||
fontSize: 10.5,
|
||
fontWeight: 600,
|
||
color: 'var(--ol-blue)',
|
||
background: 'rgba(255, 255, 255, 0.78)',
|
||
backdropFilter: 'blur(20px) saturate(180%)',
|
||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||
border: '0.5px solid rgba(37, 99, 235, 0.25)',
|
||
boxShadow: '0 4px 12px -4px rgba(37, 99, 235, 0.25), 0 0 0 0.5px rgba(0,0,0,0.04)',
|
||
letterSpacing: '0.02em',
|
||
whiteSpace: 'nowrap',
|
||
// 隐藏:从 pill 中线偏下出发;显示:归位到 wrapper(pill 上方 25px)
|
||
opacity: translation ? 1 : 0,
|
||
transform: translation ? 'translateY(0) scale(1)' : 'translateY(40px) scale(.88)',
|
||
transformOrigin: 'center bottom',
|
||
transition: 'opacity .24s ease-out, transform .34s cubic-bezier(.2,.9,.3,1.1)',
|
||
willChange: 'opacity, transform',
|
||
}}
|
||
>
|
||
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--ol-blue)' }} />
|
||
{t('capsule.translating')}
|
||
</div>
|
||
</div>
|
||
<Pill
|
||
os={os}
|
||
state={renderedState}
|
||
level={leaving ? 0 : level}
|
||
insertedChars={insertedChars}
|
||
message={message}
|
||
onCancel={onCancel}
|
||
onConfirm={onConfirm}
|
||
/>
|
||
<style>{`
|
||
/* 入场:从中央很窄的一小条(scaleX 0.18)+ 略压扁(scaleY 0.95)+ 透明,
|
||
长出到 scaleX 1 / scaleY 1 / 不透明。配合 wrapper 的 transformOrigin:center,
|
||
视觉上是「从中心向左右展开」。 */
|
||
@keyframes capsule-in {
|
||
from { opacity: 0; transform: scaleX(.18) scaleY(.95); }
|
||
to { opacity: 1; transform: scaleX(1) scaleY(1); }
|
||
}
|
||
/* 离场:scaleX 由 1 收回 0.18 + 整体向下偏移 8px + 淡出。
|
||
forwards 让最终帧(opacity:0、scaleX:.18)保持到组件被卸载。 */
|
||
@keyframes capsule-out {
|
||
from { opacity: 1; transform: scaleX(1) translateY(0); }
|
||
to { opacity: 0; transform: scaleX(.18) translateY(8px); }
|
||
}
|
||
@keyframes cap-shine {
|
||
0% { background-position: 200% center; }
|
||
100% { background-position: -200% center; }
|
||
}
|
||
@keyframes cap-state-enter {
|
||
from { opacity: 0; transform: translateY(2px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|