截图模块优化调整

This commit is contained in:
zhongluofeng
2026-08-03 17:36:07 +08:00
parent f51a7f894e
commit c913379e2b
19 changed files with 1744 additions and 300 deletions
-8
View File
@@ -159,14 +159,6 @@ onMounted(async () => {
handleModuleChange('settings')
})
)
// 托盘截图:直接触发截图流程(不切换模块,覆盖层独立窗口,进入后点选窗口或长按拖选区)
trayUnlisteners.push(
await listen('tray:start-screenshot', () => {
screenshotStore.startCapture().catch(err =>
console.error('[screenshot] 托盘触发截图失败:', err)
)
})
)
// 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史)
screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
})
+27 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Search, Settings, ChevronRight, ArrowUp } from '@lucide/vue'
import { Search, Settings, ChevronRight, ArrowUp, Check, Loader2 } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
@@ -261,6 +261,21 @@ const handleBlur = () => {
</div>
<div class="flex-1"></div>
<!-- 保存设置按钮当前模块在设置 tab 且注册了保存函数时显示从右向左弹出 -->
<Transition name="save-btn">
<button
v-if="tabsStore.saveVisible"
class="flex items-center gap-1.5 h-7 px-3 mr-1 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-60 disabled:pointer-events-none transition-colors pointer-events-auto"
:disabled="tabsStore.saving"
title="保存当前模块设置"
@click="tabsStore.runSave"
@mousedown.stop
>
<Loader2 v-if="tabsStore.saving" class="size-3.5 animate-spin" />
<Check v-else class="size-3.5" />
保存设置
</button>
</Transition>
<div class="relative max-w-xs mr-3 pointer-events-auto">
<Search
class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
@@ -388,4 +403,15 @@ const handleBlur = () => {
opacity: 0;
transform: translateX(-12px);
}
/* 保存设置按钮进出动画:淡入 + 从右侧滑入(从右向左弹出) */
.save-btn-enter-active,
.save-btn-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.save-btn-enter-from,
.save-btn-leave-to {
opacity: 0;
transform: translateX(12px);
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { SliderRootEmits, SliderRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SliderRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SliderRoot
v-slot="{ modelValue }"
data-slot="slider"
:class="cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
props.class,
)"
v-bind="forwarded"
>
<SliderTrack
data-slot="slider-track"
class="bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
>
<SliderRange
data-slot="slider-range"
class="bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
/>
</SliderTrack>
<SliderThumb
v-for="(_, key) in modelValue"
:key="key"
data-slot="slider-thumb"
class="bg-white border-primary ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
</SliderRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Slider } from "./Slider.vue"
+145 -37
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard,
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Keyboard,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent } from '@/components/ui/card'
@@ -29,6 +30,7 @@ import {
const store = useClipboardStore()
const activeTab = ref('history')
const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'history', label: '历史' },
{ value: 'pinned', label: '固定' },
@@ -114,6 +116,9 @@ const handleSaveSettings = async () => {
}
}
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
tabsStore.registerSave(handleSaveSettings)
const handleShortcutChange = async () => {
// 快捷键变化立即保存并注册(不等点击"保存设置")
try {
@@ -124,6 +129,86 @@ const handleShortcutChange = async () => {
}
}
// ===== 快捷键录入器 =====
const recording = ref(false)
const recorderRef = ref<HTMLDivElement | null>(null)
/** 把存储的快捷键字符串格式化为展示形式:alt+v → Alt + V */
function displayShortcut(s: string): string {
if (!s) return ''
return s
.split('+')
.map(p => {
const t = p.trim()
if (!t) return ''
if (t.length === 1) return t.toUpperCase()
return t.charAt(0).toUpperCase() + t.slice(1)
})
.join(' + ')
}
/** 把键盘事件转为 Tauri 快捷键字符串(小写,+ 分隔) */
function eventToShortcut(e: KeyboardEvent): string | null {
const mods: string[] = []
if (e.ctrlKey) mods.push('ctrl')
if (e.altKey) mods.push('alt')
if (e.shiftKey) mods.push('shift')
if (e.metaKey) mods.push('super')
let main = ''
const code = e.code || ''
if (/^Key[A-Z]$/.test(code)) main = code.slice(3).toLowerCase()
else if (/^Digit[0-9]$/.test(code)) main = code.slice(5)
else if (/^F([1-9]|1[0-2])$/.test(code)) main = code.toLowerCase()
else if (code === 'Space') main = 'space'
else if (code === 'PrintScreen') main = 'printscreen'
else if (code.startsWith('Numpad')) main = code.slice(6).toLowerCase()
else return null
// 必须至少一个修饰键(功能键 F1-F12 / PrintScreen 例外)
const isFunctionKey = /^f([1-9]|1[0-2])$/.test(main) || main === 'printscreen'
if (mods.length === 0 && !isFunctionKey) return null
return [...mods, main].join('+')
}
function onRecordKey(e: KeyboardEvent) {
if (!recording.value) return
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
recording.value = false
return
}
// 仅修饰键按下时不结束(等待主键)
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
const combo = eventToShortcut(e)
if (!combo) {
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
return
}
recording.value = false
void commitShortcut(combo)
}
async function commitShortcut(combo: string) {
form.value.shortcut = combo
await handleShortcutChange()
}
async function startRecord() {
recording.value = true
await nextTick()
recorderRef.value?.focus()
}
watch(recording, (on) => {
if (on) window.addEventListener('keydown', onRecordKey, true)
else window.removeEventListener('keydown', onRecordKey, true)
})
async function clearShortcut() {
form.value.shortcut = ''
await handleShortcutChange()
}
const handleTestPopup = async () => {
try {
await store.showPopup()
@@ -204,6 +289,7 @@ onMounted(async () => {
onUnmounted(() => {
store.dispose()
window.removeEventListener('keydown', onRecordKey, true)
})
</script>
@@ -245,33 +331,39 @@ onUnmounted(() => {
</Button>
</div>
<!-- 分页置于列表上方靠左显示 -->
<div v-if="totalPages > 1" class="flex items-center justify-start pb-2 shrink-0">
<Pagination
v-slot="{ page }"
:page="currentPage"
:total="store.historyTotal"
:items-per-page="PAGE_SIZE"
:sibling-count="1"
show-edges
@update:page="gotoPage"
class="justify-start"
>
<PaginationContent v-slot="{ items }" class="gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
size="icon"
class="size-7 text-xs"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else class="size-7" />
</template>
</PaginationContent>
</Pagination>
<!-- 分页 + 快捷键提示置于列表上方分页靠左快捷弹窗提示靠右 -->
<div class="flex items-center justify-between pb-2 shrink-0">
<template v-if="totalPages > 1">
<Pagination
v-slot="{ page }"
:page="currentPage"
:total="store.historyTotal"
:items-per-page="PAGE_SIZE"
:sibling-count="1"
show-edges
@update:page="gotoPage"
class="justify-start"
>
<PaginationContent v-slot="{ items }" class="gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
size="icon"
class="size-7 text-xs"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else class="size-7" />
</template>
</PaginationContent>
</Pagination>
</template>
<div v-else />
<span class="text-xs text-muted-foreground shrink-0">
快捷弹窗<span v-if="form.shortcut" class="font-medium text-foreground">{{ displayShortcut(form.shortcut) }}</span><span v-else>未设置</span>
</span>
</div>
<ScrollArea class="flex-1 min-h-0">
@@ -356,11 +448,10 @@ onUnmounted(() => {
<!-- 设置 -->
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
<div class="max-w-xl space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-base font-medium flex items-center gap-2">
<SettingsIcon class="h-4 w-4" />基本设置
</h3>
<Button @click="handleSaveSettings"><Check class="h-4 w-4 mr-1" />保存设置</Button>
</div>
<Card>
@@ -434,12 +525,29 @@ onUnmounted(() => {
</div>
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
</div>
<Input
v-model="form.shortcut"
placeholder="如 Alt+V / Ctrl+Shift+V"
@change="handleShortcutChange"
/>
<p class="text-xs text-muted-foreground">格式修饰键+主键 Ctrl+CAlt+VShift+F1修改后自动生效</p>
<div class="flex items-center gap-2 flex-wrap">
<div
ref="recorderRef"
class="hotkey-recorder"
:class="{ recording }"
tabindex="0"
@click="startRecord"
>
<template v-if="recording">按下快捷键Esc 取消</template>
<template v-else-if="form.shortcut">
{{ displayShortcut(form.shortcut) }}
</template>
<template v-else>未设置点击录入</template>
</div>
<Button
v-if="form.shortcut"
variant="ghost"
size="sm"
class="h-8 text-muted-foreground"
@click="clearShortcut"
>清除</Button>
</div>
<p class="text-xs text-muted-foreground">点击方框录入快捷键需至少一个修饰键 + 字母/数字/功能键默认 Alt+V</p>
</CardContent>
</Card>
</div>
+5 -8
View File
@@ -13,6 +13,7 @@ import { invoke } from '@tauri-apps/api/core'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { pendingNewDownload } from '@/lib/trayEvents'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -42,6 +43,7 @@ const logger = createLogger('downloader')
// ===== 主 Tab 状态 =====
const activeTab = ref('tasks')
const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'tasks', label: '下载任务' },
{ value: 'settings', label: '设置' },
@@ -483,6 +485,9 @@ const handleSaveSettings = async (): Promise<boolean> => {
}
}
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
tabsStore.registerSave(handleSaveSettings)
// Dialog 保存:成功后关闭弹窗
const handleDialogSave = async () => {
const ok = await handleSaveSettings()
@@ -963,14 +968,6 @@ const toggleSortOrder = () => {
<TabsContent value="settings" class="flex-1 mt-4 min-h-0 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
<!-- 顶部保存按钮 -->
<div class="flex justify-end">
<Button @click="handleSaveSettings">
<Check class="h-4 w-4" />
保存设置
</Button>
</div>
<!-- 下载设置 -->
<Card>
<CardHeader>
+9 -14
View File
@@ -11,6 +11,7 @@ import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -71,6 +72,7 @@ const onConfirmOpenChange = (open: boolean) => {
const activeTab = ref('overview')
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'proxies', label: '节点' },
@@ -856,10 +858,8 @@ const syncLocalSettings = () => {
watch(() => store.settings, syncLocalSettings, { immediate: true })
const savingSettings = ref(false)
const saveSettingsForm = async () => {
if (!store.settings) return
savingSettings.value = true
try {
await store.saveSettings({
...store.settings,
@@ -868,10 +868,11 @@ const saveSettingsForm = async () => {
toast.success('设置已保存')
} catch (e) {
toast.error('保存失败', { description: String(e) })
} finally {
savingSettings.value = false
}
}
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
tabsStore.registerSave(saveSettingsForm)
</script>
<template>
@@ -1513,16 +1514,10 @@ const saveSettingsForm = async () => {
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<!-- 保存设置顶部醒目位置 -->
<div class="flex items-center gap-3 rounded-md border border-primary/30 bg-primary/5 p-3">
<Button size="sm" :disabled="savingSettings" @click="saveSettingsForm">
<Loader2 v-if="savingSettings" key="saving" class="size-3.5 animate-spin" />
<Check v-else key="saved-icon" class="size-3.5" />保存设置
</Button>
<p class="text-xs text-muted-foreground flex-1">
修改端口/接口/密钥/模式后需重启 mihomo 生效DNS规则等高级配置请直接编辑订阅文件
</p>
</div>
<!-- 说明文字保存按钮已移至顶部标签栏 -->
<p class="text-xs text-muted-foreground rounded-md border border-primary/30 bg-primary/5 p-3">
修改端口/接口/密钥/模式后需重启 mihomo 生效DNS规则等高级配置请直接编辑订阅文件
</p>
<Separator />
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
+13 -14
View File
@@ -160,24 +160,22 @@ onUnmounted(() => {
</script>
<template>
<div class="h-full overflow-hidden">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef" class="px-4 pt-3 pb-2 shrink-0">
<TabsList>
<TabsTrigger value="settings">设置</TabsTrigger>
<TabsTrigger value="history">
历史
<span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground">
({{ store.recent.length }})
</span>
<div class="h-full p-6 overflow-hidden flex flex-col">
<Tabs v-model="activeTab" class="flex-1 min-h-0 flex flex-col">
<div ref="tabsListRef" class="shrink-0">
<TabsList class="grid w-full max-w-md grid-cols-2 !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="settings" class="gap-1.5"><Settings class="size-3.5" />设置</TabsTrigger>
<TabsTrigger value="history" class="gap-1.5">
<ImageIcon class="size-3.5" />历史
<span v-if="store.recent.length" class="text-xs text-muted-foreground">({{ store.recent.length }})</span>
</TabsTrigger>
</TabsList>
</div>
<!-- 截图设置 -->
<TabsContent value="settings" class="flex-1 min-h-0 mt-0">
<TabsContent value="settings" class="flex-1 min-h-0 mt-4">
<ScrollArea class="h-full">
<div class="p-4 pt-0 space-y-4 max-w-3xl mx-auto">
<div class="space-y-4 max-w-3xl">
<!-- 快捷键 -->
<Card>
<CardHeader>
@@ -230,7 +228,8 @@ onUnmounted(() => {
<div class="mt-3 pt-3 border-t text-sm text-muted-foreground space-y-1.5">
<p>· 进入截图后 <span class="text-foreground">移动鼠标</span> 自动识别窗口<span class="text-foreground">点击</span> 选中窗口</p>
<p>· <span class="text-foreground">长按拖动</span> 自由选择区域选区可拖动 / 缩放手柄调整大小</p>
<p>· 选区右下方编辑栏可标注矩形 / 椭圆 / 箭头 / 序号 / 画笔 / 文字 / 马赛克 / 高亮</p>
<p>· 编辑栏支持矩形 / 椭圆 / 箭头 / 序号 / 画笔 / 文字 / 马赛克 / 高亮颜色与粗细可随时调整</p>
<p>· 点击标注可选中移动文字标注点击内容可重新编辑</p>
<p>
· <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Enter</kbd> / 双击复制并完成
<kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 逐级取消
@@ -329,7 +328,7 @@ onUnmounted(() => {
<!-- 历史记录 -->
<TabsContent value="history" class="flex-1 min-h-0 mt-0">
<ScrollArea class="h-full">
<div class="p-4 pt-0">
<div>
<!-- 空状态 -->
<div
v-if="store.recent.length === 0"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@ export const moduleConfig: ModuleConfig = {
id: 'screenshot',
name: '截图',
icon: 'screenshot',
description: '区域截图、窗口截图与图片编辑',
description: '截图、标注与图片编辑',
category: 'media',
defaultEnabled: true,
loader: () => import('./ScreenshotModule.vue'),
+1 -5
View File
@@ -6,7 +6,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { Effect, EffectState } from '@tauri-apps/api/window'
import {
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
Settings, LogOut, Camera,
Settings, LogOut,
} from '@lucide/vue'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
@@ -526,10 +526,6 @@ onUnmounted(() => {
<Download class="h-4 w-4" />
<span>新建下载</span>
</button>
<button class="tray-item" @click="handleAction('screenshot_region')">
<Camera class="h-4 w-4" />
<span>区域截图</span>
</button>
<div class="tray-separator"></div>
+33 -2
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
/**
* 模块标签栏跨组件状态。
@@ -28,6 +28,13 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
/** 是否显示浮动切换器(TabsList 滚出可视区时为 true */
const floatingVisible = ref<boolean>(false)
/** 当前模块注册的保存处理函数(null 表示无保存按钮,如自动保存模块) */
const saveHandler = ref<(() => unknown) | null>(null)
/** 保存中状态(驱动按钮 disabled + loading 图标) */
const saving = ref<boolean>(false)
/** 是否显示保存按钮(当前在设置 tab 且注册了保存处理函数) */
const saveVisible = computed(() => saveHandler.value !== null && activeTab.value === 'settings')
/** 模块注册标签(onMounted 时调用) */
const registerTabs = (tabList: ModuleTab[], current: string) => {
tabs.value = tabList
@@ -40,6 +47,8 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
tabs.value = []
activeTab.value = ''
floatingVisible.value = false
saveHandler.value = null
saving.value = false
}
/** 设置浮动切换器可见性 */
@@ -63,13 +72,35 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
activeTab.value = value
}
/** 模块注册保存处理函数(setup 中定义保存方法后调用) */
const registerSave = (handler: (() => unknown) | null) => {
saveHandler.value = handler
saving.value = false
}
/** 执行保存(TitleBar 按钮点击时调用) */
const runSave = async () => {
if (!saveHandler.value || saving.value) return
saving.value = true
try {
await saveHandler.value()
} finally {
saving.value = false
}
}
return {
tabs,
activeTab,
floatingVisible,
saveHandler,
saving,
saveVisible,
registerTabs,
unregisterTabs,
setFloatingVisible,
setActiveTab
setActiveTab,
registerSave,
runSave
}
})
+35
View File
@@ -198,4 +198,39 @@
opacity: 1;
transform: translateY(0);
}
}
/* 快捷键录入器(全局,截图/剪切板等模块共用) */
.hotkey-recorder {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120px;
height: 36px;
padding: 0 12px;
border-radius: var(--radius);
border: 1px solid hsl(var(--input));
background-color: hsl(var(--background));
font-size: 0.875rem;
color: hsl(var(--foreground));
cursor: pointer;
user-select: none;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.hotkey-recorder:hover {
border-color: hsl(var(--ring));
}
.hotkey-recorder:focus-visible {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.4);
}
.hotkey-recorder.recording {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3);
animation: recordPulse 1.2s ease-in-out infinite;
}
@keyframes recordPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}