【免费下载链接】pomotroid
:tomato: Simple and visually-pleasing Pomodoro timer
导读
Pomotroid 的计时器窗口大量使用纯图标按钮(如 Restart Round、Skip、Reset、Mute),设置窗口中的开关行也只有简短标签,用户往往无法仅凭界面判断这些控件的具体行为或平台差异。本文以仓库内openspec/changes/archive/2026-03-26-ui-tooltips/变更包为主线,完整讲解 Pomotroid 如何通过自研的Tooltip.svelte与TooltipInfo.svelte组件、两档延迟交互模型、视口边缘翻转定位以及全 8 语言 i18n 接入,为计时器窗口与设置窗口的每个目标控件补齐"即时上下文"。读完本文,你将掌握该组件的 props 契约、定位算法、无障碍接线方式,以及从 spec 到真实源码的完整落地路径。
一、为什么需要 Tooltip:问题背景与设计目标
Pomotroid 的计时器窗口(主窗口)为了保持界面简洁,多数控制按钮是纯图标形态,仅靠aria-label提供可访问性描述;设置窗口的开关行则使用简短标签,诸如"Countdown Dial""Verbose Logging"等术语对新手并不直观。变更提案 proposal.md 指出:Tooltip 的价值是在用户需要的那一刻、在 UI 原位上提供上下文,从而降低新用户的学习摩擦,并能在最相关的时刻呈现平台注意事项(例如 GNOME 下托盘图标需要 AppIndicator 扩展这一关键提示)。
本次变更的边界非常清晰(proposal 的 Impact 一节):
- 前端:新增
Tooltip.svelte组件,修改计时器页面与设置分区组件; - i18n:在全部 8 个语言文件(
src/messages/*.json)中新增所有 tooltip 字符串,并需要重新生成 Paraglide 消息; - 无后端改动:Tooltip 纯属前端关注点;
- 无新增依赖:行为完全用 CSS + Svelte 实现,不引入 Floating UI、Popper.js 等第三方库。
设计文档 design.md 明确了目标与非目标:
| 目标 | 非目标 |
|---|---|
可在两个窗口任意位置复用的Tooltip.svelte | Tooltip 内支持富内容(HTML、图片)——仅纯文本 |
| 两种交互模式:延迟(按钮/开关)与即时(info 图标) | 触屏/移动端支持——Pomotroid 是纯桌面应用 |
| CSS 驱动定位 + JS 兜底翻转(近视口边缘时上下翻转) | 引入第三方 Tooltip 库——保持依赖面平坦 |
| 全部 tooltip 字符串走 Paraglide 完整国际化 | — |
用 info 图标 + 即时 tooltip 替换 Linux 下 GNOME 托盘的静态.note段落 | — |
二、核心组件契约:Tooltip.svelte
任务清单 tasks.md 第 1 节定义了组件基础能力,而 spec 文件 specs/tooltips/spec.md 给出了精确的 props 契约。
2.1 Props 接口
组件接受以下 props(src/lib/components/Tooltip.svelte):
interface Props { text: string; // tooltip 文本,由调用方通过 Paraglide 翻译 delay?: number; // 悬停后出现 tooltip 的毫秒数,默认 600 placement?: 'above' | 'below'; // 首选放置方向,默认 'above' children: Snippet; // 触发器元素,通过默认 slot 传入 }关键行为(对应 spec 中的场景):
- 延迟出现:鼠标悬停触发器至少
delay毫秒后显示 tooltip(spec 场景 "Tooltip appears after delay"); - 立即消失:鼠标移出触发器时 tooltip 立即隐藏(场景 "Tooltip disappears on mouse leave");
- 即时显示:设置
delay={0}时悬停即显(场景 "Instant tooltip with delay=0"); - 边缘翻转:触发器位置导致 tooltip 在首选方向溢出视口时,改向反方向渲染(场景 "Tooltip flips placement near viewport edge")。
2.2 事件模型与延迟计时器
源码中的显示逻辑使用show()/hide()两个函数:
async function show() { if (visible || timer !== undefined) return; const run = async () => { timer = undefined; visible = true; await tick(); updatePosition(); }; if (delay === 0) { await run(); } else { timer = setTimeout(run, delay); } } function hide() { clearTimeout(timer); timer = undefined; visible = false; positioned = false; flipped = false; tooltipStyle = ''; }- 触发器同时绑定
onpointerenter与onpointermove(后者让鼠标在触发区内小幅移动时不至于因pointerenter未触发而丢失显示),离开时onpointerleave立即隐藏; - 隐藏时会清理延迟计时器并复位
positioned/flipped/tooltipStyle,确保下次显示重新计算定位; - tooltip 的 DOM id 用
Math.random().toString(36).slice(2, 9)生成,格式为tooltip-xxxxxxx,供aria-describedby引用。
2.3 视口边缘检测与翻转算法
updatePosition()是定位核心,对应任务 1.2("on show, checkgetBoundingClientRect()and flip placement if tooltip would overflow"):
function updatePosition() { if (!wrapper || !tooltipEl) return; const wRect = wrapper.getBoundingClientRect(); const tRect = tooltipEl.getBoundingClientRect(); const centerX = wRect.left + wRect.width / 2; const pad = 8; // 垂直方向:若触发器距顶部过近且首选 above,则翻转到下方 flipped = wRect.top < 70 && placement === 'above'; let top: number; if (actualPlacement === 'below') { top = wRect.bottom + 8; } else { top = wRect.top - 8 - tRect.height; } // 水平方向:水平居中于触发器,并夹在视口内 let left = centerX - tRect.width / 2; if (left < pad) left = pad; if (left + tRect.width > window.innerWidth - pad) { left = window.innerWidth - pad - tRect.width; } // 箭头偏移:始终指向触发器的水平中心 const arrowLeft = centerX - left; tooltipStyle = `top:${top}px;left:${left}px;--arrow-left:${arrowLeft}px`; positioned = true; }算法要点:
- 垂直翻转阈值:当触发器的
getBoundingClientRect().top < 70px且首选方向为above时,翻转为below(70px大致对应标题栏高度加上 tooltip 自身高度余量);这对应验证任务 6.2"把窗口调小、确认靠近顶部的 tooltip 翻转到下方"; - 水平夹取:tooltip 相对触发器水平居中,但左右各保留
8px视口边距,超宽时贴边显示; - 箭头跟随:通过 CSS 自定义属性
--arrow-left把箭头(::after伪元素)始终对准触发器的水平中心; - 防闪烁:tooltip 默认
visibility: hidden,只有 JS 完成定位(.positioned类)后才可见,避免首帧闪现在左上角(源码注释明确说明这一设计)。
2.4 渲染结构与 ARIA 接线
模板部分(对应任务 1.3"Wirearia-describedby"):
<span class="tooltip-wrapper" bind:this={wrapper} onpointerenter={show} onpointermove={show} onpointerleave={hide} aria-describedby={visible ? tooltipId : undefined} > {@render children()} {#if visible} <span bind:this={tooltipEl} class="tooltip" class:below={actualPlacement === 'below'} class:positioned style={tooltipStyle} id={tooltipId} role="tooltip">{text}</span > {/if} </span>无障碍契约(spec "Accessibility" 一节):
- tooltip可见时,触发器元素获得
aria-describedby指向 tooltip 元素的id,屏幕阅读器会把 tooltip 文本与控件标签一并朗读(场景 "Screen reader announces tooltip text"); - tooltip隐藏时,
aria-describedby属性被移除(源码中visible ? tooltipId : undefined),确保读屏不读到已隐藏的内容; - tooltip 元素本身带
role="tooltip"。
2.5 样式:主题感知与箭头
样式契约(spec "Tooltip styling" 一节)与实现对照:
| spec 要求 | 源码实现(Tooltip.svelte的<style>块) |
|---|---|
背景--color-background-light(主题未定义时用深色叠加) | --tooltip-bg: var(--color-background-light, color-mix(in oklch, var(--color-foreground) 10%, var(--color-background))) |
文字色--color-foreground | color: var(--color-foreground) |
字号0.75rem | 实现为0.72rem、line-height: 1.4(实现较 spec 微调) |
圆角4px | border-radius: 4px |
最大宽度240px、文字换行 | max-width: 240px; white-space: normal; text-align: center |
| 轻微阴影 | box-shadow: 0 2px 8px color-mix(in oklch, black 30%, transparent),外加1px前景色 12% 透明描边 |
| 指向触发器的小箭头 | .tooltip::after使用 5px 边框三角;.tooltip.below::after反向 |
主题适配通过 CSS 自定义属性完成:--color-*由 src/lib/stores/theme.ts 的applyTheme()在切换主题时写入:root,因此任何可见的 tooltip 都会立即反映新主题(spec 场景 "Tooltip uses active theme colors")。全仓 40+ 套主题 JSON(见 static/themes)都定义这些颜色变量。
三、信息图标组件:TooltipInfo.svelte
任务 2.1 要求创建TooltipInfo.svelte作为Tooltip的薄封装,spec 定义了其契约:渲染一个样式化的ⓘ图标,悬停时立即显示 tooltip(delay={0}),接受单一textprop,用于设置窗口开关行旁展示上下文备注而不增加布局负担。
源码实现(src/lib/components/TooltipInfo.svelte):
<Tooltip {text} delay={0}> <span class="info-icon" role="img" aria-label="More information" onclick={(e) => e.stopPropagation()} > <svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"> <circle cx="6.5" cy="6.5" r="5.75" stroke="currentColor" stroke-width="1.5" /> <circle cx="6.5" cy="4" r="0.9" fill="currentColor" /> <line x1="6.5" y1="6" x2="6.5" y2="10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /> </svg> </span> </Tooltip>值得注意的实现细节:
- 必须是
<span>而非<button>:因为TooltipInfo经常被渲染在<button class="row">(开关整行)内部,<span>是<button>中合法的 phrasing content(源码注释明确说明); onclick必须stopPropagation():防止点击 info 图标时触发父级按钮的onclick而意外切换开关;- 视觉层级:rest 状态
opacity: 0.55、color: var(--color-foreground),hover 时opacity: 1——对应 spec 场景"Info icon is visually distinct but unobtrusive"(spec 原文以--color-foreground-darker描述 rest 状态,实现上通过降低透明度达到"次要于相邻标签"的同等效果)。
四、i18n:14 个 tooltip 键与 8 语言覆盖
4.1 键与英文文案
本地化 spec specs/localization/spec.md 列出全部 14 个新增键。以下英文值均可在 src/messages/en.json 中核实:
| i18n 键 | 英文值 |
|---|---|
tooltip_settings | Open Settings |
tooltip_statistics | Open Statistics |
tooltip_restart_round | Restart the current round from the beginning. |
tooltip_skip | Skip to the next round. |
tooltip_reset | Reset the timer to the first work round. Current session progress will be cleared. |
tooltip_mute | Mute alert sounds |
tooltip_unmute | Unmute alert sounds |
tooltip_verbose_logging | Enables detailed debug logging. Use when reporting issues. Log files are accessible via Open Log Folder in Advanced settings. |
tooltip_dial_countdown | When enabled, the progress arc counts down to zero instead of filling up. |
tooltip_auto_start_work | Automatically start the next work round when a break ends. |
tooltip_auto_start_break | Automatically start the break when a work round completes. |
tooltip_websocket | Enables a local WebSocket server for external integrations such as stream overlays. Disabled by default. |
tooltip_round_counter | Current work round out of the total rounds before a long break. |
tooltip_round_counter_session | Continuous session round count. Resets only when the timer is reset. |
补充说明(spec 原文明确):system_tray_gnome_hint是已有键,被 Linux 专属的 System Tray 开关 TooltipInfo 直接复用,无需新增。
4.2 多语言落地
- 任务 3.1/3.2 要求:en 之外,
fr.json、de.json、es.json、pt.json、zh.json、ja.json、tr.json全部补齐 14 个键; - 仓库搜索确认全部 8 个语言文件都包含
tooltip_websocket、tooltip_auto_start_work、tooltip_dial_countdown等键(每个文件命中 3 处); - 中文示例可核对 src/messages/zh.json:如
"tooltip_reset": "将计时器重置到第一个工作轮次。当前会话进度将被清除。"、"tooltip_websocket": "启用本地 WebSocket 服务器,用于直播覆盖层等外部集成。默认禁用。"; - spec 注明非英语翻译初始版本可以机器翻译,后续由社区改进;
- 调用方式统一为 Paraglide 函数:
m.tooltip_restart_round()等,消息编译命令为npm run paraglide:compile(见 package.json 的 scripts),完整类型检查见下文第七节。
五、计时器窗口:7 个 tooltip 目标的落地
spec "Tooltip inventory — timer window" 给出了完整清单(全部 600 ms 延迟),与源码逐一对应:
| 元素 | 位置 | i18n 键 | 源码位置 |
|---|---|---|---|
| Settings 按钮 | Titlebar | tooltip_settings | Titlebar.svelte 的settingsBtn()snippet |
| Statistics 按钮 | Titlebar | tooltip_statistics | Titlebar.svelte 的statsBtn()snippet |
| Restart Round 按钮 | Timer controls | tooltip_restart_round | Timer.svelte 的"Back: restart current round" |
| Skip 按钮 | Timer controls | tooltip_skip | Timer.svelte 的"Skip: advance to next round" |
| Reset 按钮 | Footer | tooltip_reset | TimerFooter.svelte |
| Mute/Unmute 按钮 | Footer | tooltip_mute/tooltip_unmute | TimerFooter.svelte |
| Round indicator | Footer | tooltip_round_counter/tooltip_round_counter_session | TimerFooter.svelte |
5.1 Titlebar:Settings 与 Statistics
Titlebar.svelte用 Svelte 5 snippet 组织两组带 tooltip 的图标按钮:
{#snippet settingsBtn()} <Tooltip text={m.tooltip_settings()}> <button class="btn-icon" onclick={openSettings} aria-label="Settings"> <!-- 齿轮 SVG --> </button> </Tooltip> {/snippet}注意平台差异:非 macOS 时这两个按钮渲染在标题栏左侧,macOS 时渲染在右侧(绕过系统红绿灯区域),但 tooltip 包装完全一致。
5.2 Timer:Restart Round 与 Skip
Timer.svelte中 Restart Round(回到当前轮起点)与 Skip(跳到下一轮)按钮都被<Tooltip>包裹,分别对应 spec 场景:
- "Reset tooltip communicates consequence":悬停 Reset 600 ms 后显示
tooltip_reset,说明"计时器回到第一个工作轮次并清除会话进度"; - "Skip tooltip describes the action":显示
tooltip_skip,说明"跳过当前轮并开始下一轮"。
5.3 TimerFooter:Reset、Mute/Unmute、Round indicator
TimerFooter.svelte是状态相关 tooltip 最密集的组件:
Reset(任务 4.3):
<Tooltip text={m.tooltip_reset()}> <button class="btn-text" onclick={timerReset} aria-label={m.timer_reset()}> {m.timer_reset()} </button> </Tooltip>Mute/Unmute(任务 4.4):tooltip 文本跟随静音状态实时切换,对应 spec 场景 "Mute tooltip reflects current state":
<Tooltip text={localVolume === 0 ? m.tooltip_unmute() : m.tooltip_mute()}> <button class="btn-icon" onclick={toggleMute} aria-label={localVolume === 0 ? 'Unmute' : 'Mute'}> <!-- 音量图标,静音态画叉 --> </button> </Tooltip>toggleMute()还会记住静音前的音量(premuteVolume),取消静音时恢复。
Round indicator(任务 4.5):按长休息是否启用切换文案,对应 spec 场景 "Round indicator tooltip reflects long-break mode":
<Tooltip text={$settings.long_breaks_enabled ? m.tooltip_round_counter() : m.tooltip_round_counter_session()} > <span class="rounds"> {#if $settings.long_breaks_enabled} {snap.work_round_number} | {snap.work_rounds_total} {:else} {m.timer_session_round({ n: snap.session_work_count })} {/if} </span> </Tooltip>即:开启长休息时显示"当前工作轮 / 总轮数"(如2 | 4),并解释这是长休息前的第几轮;关闭长休息时显示"Round 3"式的连续会话计数,并说明只有重置计时器才会清零。
六、设置窗口:6 个 TooltipInfo 的落地
spec "Tooltip inventory — settings window" 清单(全部即时显示):
| 设置项 | 分区 | i18n 键 | 源码位置 |
|---|---|---|---|
| Show in System Tray(仅 Linux) | System | system_tray_gnome_hint(已有键复用) | SystemSection.svelte |
| Verbose Logging | System(Logging) | tooltip_verbose_logging | SystemSection.svelte |
| Dial Countdown | Timer | tooltip_dial_countdown | TimerSection.svelte |
| Auto-start Work | Timer | tooltip_auto_start_work | TimerSection.svelte |
| Auto-start Break | Timer | tooltip_auto_start_break | TimerSection.svelte |
| WebSocket Server | System(Integrations) | tooltip_websocket | SystemSection.svelte |
6.1 SettingsToggle:统一的 tooltip 注入点
这些开关全部经由SettingsToggle.svelte渲染,该组件新增了可选tooltip?: stringprop,有值时在标签旁渲染TooltipInfo:
<button class="row" {onclick}> <span class="text"> <span class="label" >{label}{#if tooltip}<TooltipInfo text={tooltip} />{/if}</span > {#if description} <span class="desc">{description}</span> {/if} </span> <span class="toggle" class:on={checked} aria-checked={checked} role="switch"></span> </button>这也解释了TooltipInfo内层必须用<span>且stopPropagation()的原因——它嵌套在整行<button>内。
6.2 GNOME 提示:Linux 专属 info 图标
任务 5.1 是本变更最特别的点:此前 GNOME AppIndicator 提示以静态.note段落显示在所有平台上(design.md 的 Context 明确批评了这一点),本次改为仅 Linux 渲染的 info 图标 + 即时 tooltip,并移除.note段落。
<SettingsToggle label={m.system_toggle_show_tray()} description={m.system_toggle_show_tray_desc()} tooltip={isLinux ? m.system_tray_gnome_hint() : undefined} checked={$settings.tray_icon_enabled} onclick={() => toggle('tray_icon_enabled', $settings.tray_icon_enabled)} />isLinux来自 src/lib/utils/platform.ts:/Linux/.test(navigator.userAgent);- spec 场景 "GNOME hint shown only on Linux":Linux 上显示该 info 图标,macOS/Windows 上该开关不附带任何 info 图标;
- 英文文案提示用户:GNOME 上托盘图标需要 AppIndicator 扩展(如
gnome-shell-extension-appindicator),安装后需注销并重新登录; - 另外,整个托盘分组还被
trayAvailable门控——Linux 下运行时探测libayatana-appindicator3,库缺失时整个分组隐藏(SystemSection.svelte的onMount)。
6.3 其余设置项
- Verbose Logging(
tooltip_verbose_logging):解释启用详细调试日志用于问题上报,日志文件通过 Settings → About 的 Open Log Folder 访问; - WebSocket Server(
tooltip_websocket):解释启用本地 WebSocket 服务器供外部集成(如直播覆盖层)使用、默认禁用。SystemSection.svelte中启用后还会显示端口输入行与ws://127.0.0.1:{port}/ws的.note说明(这部分是 WebSocket 功能原有文案,不属于本次 tooltip 变更); - Dial Countdown(
tooltip_dial_countdown):解释进度弧从满倒计至零而非从零填充; - Auto-start Work / Auto-start Break(
tooltip_auto_start_work/tooltip_auto_start_break):分别解释休息结束后自动开始下一工作轮、工作轮结束后自动开始休息。
任务 5.4–5.6 中 Auto-start 与 Dial Countdown 的注入点在 TimerSection.svelte。由于仓库中TimerSection.svelte的这些开关通过SettingsToggle渲染,tooltipprop 的传入位置在组件调用处。
七、验证流程与质量保障
任务清单第 6 节定义了验证标准,其中部分可以从源码直接佐证:
- 6.1 两窗口内 tooltip 正确显示与消失:由
show()/hide()的事件模型保证(onpointerenter/onpointermove/onpointerleave); - 6.2 视口边缘翻转:将窗口调小、确认靠近顶部的 tooltip 翻转到下方——由
updatePosition()中wRect.top < 70 && placement === 'above'的逻辑保证;CHANGELOG(CHANGELOG.md 第 71 行附近)也记载了实现最终采用position: fixed以避免窗口边缘裁剪; - 6.3 GNOME 提示仅 Linux 出现:由
tooltip={isLinux ? m.system_tray_gnome_hint() : undefined}保证; - 6.4 类型检查:执行
npm run check(paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json),确保新增消息键编译后无 TypeScript 错误。
另外,design.md 记录了三个明确的权衡,理解它们有助于在扩展 tooltip 时避免踩坑:
- 溢出裁剪风险:若某父容器
overflow: hidden,tooltip 可能被裁剪。缓解手段是审计父容器、必要时改为overflow: visible,或对该实例改用 portal 方案; - 紧凑模式(compact mode):
Timer.svelte中isCompact时隐藏 footer 与 controls(渲染MiniControls),这些元素上的 tooltip 自然永远不会触发——无需处理,元素不存在即可; - 翻译质量:7 个非英语语言的 tooltip 字符串初始版本允许机器翻译,后续由社区打磨。
还有一个未决问题:Play/Pause 按钮是否加 tooltip?design.md 的 Open Questions 倾向不加——它是纯图标但功能普遍易懂,避免对显而易见的控件过度 tooltip,留待用户反馈再定。
八、小结:一套零依赖、可复用的即时上下文方案
从 tasks.md 的 6 组任务到 specs/tooltips/spec.md 的完整契约,Pomotroid 的 tooltip 体系最终落地为:
Tooltip.svelte——slot 包裹式复用组件,text/delay/placement三 props,600 ms 默认延迟、delay={0}即时模式、getBoundingClientRect()视口翻转、aria-describedby无障碍接线、CSS 自定义属性主题适配;TooltipInfo.svelte——delay={0}的即时ⓘ图标封装,专用于设置开关行;- 7 个计时器窗口目标 + 6 个设置窗口目标——状态条件文本(Mute/Unmute、Round counter)与平台条件渲染(Linux GNOME 提示)是两处最具代表性的用法;
- 14 个新 i18n 键 × 8 语言——Paraglide 全量接入,
npm run check把关。
该方案未引入任何第三方依赖,纯 CSS/Svelte 实现,符合 Pomotroid 保持依赖面平坦的一贯风格。对于希望在自家 Tauri/Svelte 应用中实现轻量 tooltip 的开发者,上述组件契约、定位算法与无障碍模式都可以直接作为参考蓝图。
延伸阅读(均位于本仓库):变更总览见 proposal.md 与 design.md;组件源码见 Tooltip.svelte 与 TooltipInfo.svelte;消息文件见 src/messages。
【免费下载链接】pomotroid
:tomato: Simple and visually-pleasing Pomodoro timer
相关推荐
Material Design Lite 组件全指南:Badges 到 Tooltips 的 MDL 组件体系详解
Material Design Lite 组件全指南:Badges 到 Tooltips 的 MDL 组件体系详解 Material Design Lite(M
前端UI组件Naive UI Drawer 抽屉组件完全指南:从 Props 到源码实现
Naive UI Drawer 抽屉组件完全指南:从 Props 到源码实现 抽屉(Drawer)是 Naive UI 中与 Modal( 对话框 https:
前端UI组件cube-ui ActionSheet 组件完全指南:从 API 调用到源码实现
cube ui ActionSheet 组件完全指南:从 API 调用到源码实现 本篇指南围绕 cube ui(基于 Vue 的移动端 UI 组件库)中的 Ac
前端UI组件移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考