Compare commits

...
8 Commits
Author SHA1 Message Date
tiennm99 15f1120132 chore: bump version to 0.8.1 2026-10-27 15:57:33 +07:00
tiennm99 5462df1e7e feat: raise covered bubbles the moment another app's window appears
A WinEvent hook on window shows and z-order changes re-raises a bubble that another process's always-on-top window covers, instead of waiting for the 1.5 s check, which stays as a backstop. Raises are capped at 20 a second so an app that also fights for the top cannot keep both busy.
2026-10-27 15:56:32 +07:00
tiennm99 dfff4ebe9f style: make the bubble background 80% opaque 2026-10-27 15:56:32 +07:00
tiennm99 4e2ecbf588 fix: keep the bubble visible when click-through is toggled
On Windows, tao's set_ignore_cursor_events re-applies all its window flags. Bubbles are shown through ShowWindow, not tao, so tao believes them hidden: it hid the bubble and rewrote the extended style, dropping its no-activate and tool-window bits. The transparent and layered bits are now set directly on the main thread.
2026-10-27 15:56:32 +07:00
tiennm99 fd5c2ba910 chore: bump version to 0.8.0 2026-10-27 15:16:57 +07:00
tiennm99 cf058a4a38 feat: install found updates without waiting for a click
Where the app can replace itself (the portable exe and the AppImage), a check that finds a release now installs it and relaunches straight away. The .deb still only reports the update.
2026-10-27 15:15:27 +07:00
tiennm99 3aaf963841 fix: keep the see-through bubble readable on light backgrounds
Solid dark tracks looked like black holes and the muted text faded out when the 60% background sat over a light window. Tracks are now tints over the background, a faint edge traces the outline, and text gets a soft halo.
2026-10-27 15:15:27 +07:00
tiennm99 c7af9ed0ac fix: keep bubbles above other apps' always-on-top windows
Telegram toasts and similar topmost windows could sit over a bubble until the next minute's bring-back. On Windows, every fullscreen check now re-raises a shown bubble that another process's visible topmost window overlaps. The app's own panel and menus are ignored, and bubbles hidden for a fullscreen app stay hidden.
2026-10-27 15:15:27 +07:00
10 changed files with 296 additions and 35 deletions

No files matched your search

Generated
+1 -1
View File
@@ -4693,7 +4693,7 @@ dependencies = [
[[package]]
name = "usage-monitor"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"auto-launch",
"dbus",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "usage-monitor"
version = "0.7.0"
version = "0.8.1"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage"
@@ -56,6 +56,7 @@ windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_UI_Accessibility",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_Graphics_Dwm",
+7 -4
View File
@@ -45,8 +45,10 @@ self-updater are all written from scratch against the same public APIs
expires and after an update was applied
- Auto-hide when a fullscreen app is in the foreground (games, video,
presentations) — reappears when you leave fullscreen
- Every minute, shown bubbles are put back on top, so another
always-on-top window can't keep them covered or hidden
- Bubbles stay above other always-on-top windows, such as chat
notifications: on Windows a bubble goes back on top the moment another
app shows such a window over it, and on every platform shown bubbles are
put back on top every minute, so nothing can keep them covered or hidden
## Who this is for
@@ -100,7 +102,8 @@ Grab `usage-monitor.exe` from the
Put it in a folder of its own that you can write to (e.g.
`%LOCALAPPDATA%\UsageMonitor\`) and run it. The app is portable: it keeps
`settings.json` next to the exe, so moving the folder keeps your settings. It
self-updates from the same Releases feed.
updates itself from the same Releases feed: when a check finds a new
release, it installs it and restarts on its own, with no click needed.
**First-run note:** the binary is unsigned, so SmartScreen will show
"Windows protected your PC". Click **More info** → **Run anyway**. Code
@@ -115,7 +118,7 @@ pick one:
- `usage-monitor_X.Y.Z_amd64.AppImage`: make it executable
(`chmod +x`) and run it. Like the Windows exe it is portable and keeps
`settings.json` next to the AppImage file, so give it a folder you can
write to. The AppImage self-updates in place. If you turned on "Start
write to. The AppImage updates itself in place, the same way. If you turned on "Start
at login" in v0.6.0 or earlier and the AppImage path contains a space,
turn it off and on again once: those releases wrote the path unquoted,
so the app never started at login.
+101 -7
View File
@@ -15,9 +15,7 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
#[cfg(not(windows))]
use std::time::Instant;
use std::time::{Duration, Instant};
use tauri::{
AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
@@ -351,6 +349,8 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
set_position(&window, placement.pos);
}
set_size(&window, placement.size);
// Windows applies it on the main thread, in `prepare_native`.
#[cfg(not(windows))]
if click_through {
set_ignore_cursor(&window, true);
}
@@ -363,7 +363,7 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
placement.dpi
);
prepare_native(app, &window, provider);
prepare_native(app, &window, provider, click_through);
#[cfg(target_os = "linux")]
if let Some(is_dark) = platform::linux::portal_prefers_dark() {
runtime.set_dark(is_dark);
@@ -399,13 +399,22 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
/// move-end, setting-change and display-change messages. Both must run on
/// the window's own (main) thread before the first show.
#[cfg(windows)]
fn prepare_native(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
fn prepare_native(
app: &AppHandle,
window: &WebviewWindow,
provider: ProviderId,
click_through: bool,
) {
let window = window.clone();
let hook_app = app.clone();
let queued = app.run_on_main_thread(move || {
if let Err(err) = platform::windows::apply_no_activate(&window) {
log::warn!("{} may take focus: {err}", window.label());
}
if click_through {
set_ignore_cursor(&window, true);
}
watch_z_order(&hook_app);
let hooked = platform::windows::install_bubble_hook(&window, move |event| {
on_hook_event(&hook_app, provider, event);
});
@@ -419,7 +428,13 @@ fn prepare_native(app: &AppHandle, window: &WebviewWindow, provider: ProviderId)
}
#[cfg(not(windows))]
fn prepare_native(_app: &AppHandle, _window: &WebviewWindow, _provider: ProviderId) {}
fn prepare_native(
_app: &AppHandle,
_window: &WebviewWindow,
_provider: ProviderId,
_click_through: bool,
) {
}
#[cfg(windows)]
fn on_hook_event(app: &AppHandle, provider: ProviderId, event: platform::windows::BubbleHookEvent) {
@@ -514,6 +529,75 @@ pub fn bring_back(app: &AppHandle) {
}
}
/// Put back on top every shown bubble that another app's always-on-top
/// window, such as a chat toast, now covers. Cheap enough to run on every
/// fullscreen check; bubbles hidden for a fullscreen app are left alone.
#[cfg(windows)]
pub fn keep_on_top(app: &AppHandle) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || raise_covered(&handle));
if let Err(err) = queued {
log::warn!("bubble on-top check could not reach the main thread: {err}");
}
}
/// Most raises in [`RAISE_WINDOW`] before raising pauses until it ends, so
/// another app that also fights for the top cannot keep both busy; the
/// fullscreen check carries on at its own pace.
#[cfg(windows)]
const MAX_RAISES: u32 = 20;
#[cfg(windows)]
const RAISE_WINDOW: Duration = Duration::from_secs(1);
/// Body of [`keep_on_top`]; runs on the main thread.
#[cfg(windows)]
fn raise_covered(app: &AppHandle) {
static RAISES: Mutex<Option<(Instant, u32)>> = Mutex::new(None);
let shows = runtime_of(app).is_some_and(|r| r.with_state(|s| s.visibility.shows()));
if !shows {
return;
}
for (_, window) in windows(app) {
match platform::windows::covered_by_other_topmost(&window) {
Ok(true) => {}
Ok(false) => continue,
Err(err) => {
log::warn!("{} on-top check failed: {err}", window.label());
continue;
}
}
let allowed = {
let mut raises = RAISES.lock().unwrap_or_else(PoisonError::into_inner);
let now = Instant::now();
let (start, count) = raises.get_or_insert((now, 0));
if now.duration_since(*start) >= RAISE_WINDOW {
(*start, *count) = (now, 0);
}
*count += 1;
if *count == MAX_RAISES + 1 {
log::warn!("bubble raises paused: another window keeps taking the top");
}
*count <= MAX_RAISES
};
if allowed {
raise(&window);
}
}
}
/// Raise covered bubbles the moment another app shows a window or changes
/// the z-order. Installed once, on the main thread.
#[cfg(windows)]
fn watch_z_order(app: &AppHandle) {
static WATCHING: std::sync::Once = std::sync::Once::new();
WATCHING.call_once(|| {
let handle = app.clone();
if let Err(err) = platform::windows::watch_z_order(move || raise_covered(&handle)) {
log::warn!("z-order watch unavailable, relying on the periodic check: {err}");
}
});
}
fn raise(window: &WebviewWindow) {
#[cfg(windows)]
let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string());
@@ -541,8 +625,18 @@ pub fn set_click_through(app: &AppHandle, ignore: bool) {
}
}
/// On Windows the style bits are set directly: tao's
/// `set_ignore_cursor_events` re-applies all its window flags, and since
/// bubbles are shown through [`show`], not tao, it believes them hidden and
/// hides them, also dropping their no-activate and tool-window styles.
fn set_ignore_cursor(window: &WebviewWindow, ignore: bool) {
if let Err(err) = window.set_ignore_cursor_events(ignore) {
#[cfg(windows)]
let result = platform::windows::set_click_through(window, ignore).map_err(|e| e.to_string());
#[cfg(not(windows))]
let result = window
.set_ignore_cursor_events(ignore)
.map_err(|e| e.to_string());
if let Err(err) = result {
log::warn!("{} click-through not set: {err}", window.label());
}
}
+6 -1
View File
@@ -4,7 +4,10 @@
//! unless the user hid them in the meantime.
//!
//! The same thread brings the bubbles back every minute while they should
//! show, in case another window covered or hid them.
//! show, in case another window covered or hid them. On Windows it also
//! re-raises, on every check, a bubble that another app's always-on-top
//! window (a chat toast, say) covers, as a backstop for the z-order watch
//! in `bubble.rs`.
use std::io;
use std::sync::Arc;
@@ -28,6 +31,8 @@ pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
let mut last_bring_back = Instant::now();
while !runtime.is_exiting() {
tick(&runtime);
#[cfg(windows)]
runtime.keep_bubbles_on_top();
if last_bring_back.elapsed() >= BRING_BACK_INTERVAL {
last_bring_back = Instant::now();
runtime.bring_back_bubbles();
+133 -9
View File
@@ -1,5 +1,6 @@
//! Windows-only helpers. This is the only module that talks to Win32 APIs.
use std::cell::RefCell;
use std::io;
use std::path::Path;
use std::time::Duration;
@@ -20,18 +21,22 @@ use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
use windows::Win32::UI::Accessibility::{SetWinEventHook, HWINEVENTHOOK};
use windows::Win32::UI::Shell::{
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage,
SetCurrentProcessExplicitAppUserModelID, SetWindowSubclass, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT,
ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId,
IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos,
ShowWindow, GWL_EXSTYLE, GWL_STYLE, HWND_TOPMOST, MA_NOACTIVATE, MB_ICONERROR, MB_OK,
MB_SETFOREGROUND, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER,
SW_HIDE, SW_SHOWNOACTIVATE, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY,
WM_SETTINGCHANGE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW,
GetClassNameW, GetForegroundWindow, GetWindow, GetWindowLongPtrW, GetWindowRect,
GetWindowThreadProcessId, IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow,
SetWindowLongPtrW, SetWindowPos, ShowWindow, CHILDID_SELF, EVENT_OBJECT_REORDER,
EVENT_OBJECT_SHOW, GWL_EXSTYLE, GWL_STYLE, GW_HWNDPREV, HWND_TOPMOST, MA_NOACTIVATE,
MB_ICONERROR, MB_OK, MB_SETFOREGROUND, OBJID_WINDOW, SWP_FRAMECHANGED, SWP_NOACTIVATE,
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE, SW_SHOWNOACTIVATE, WINEVENT_OUTOFCONTEXT,
WINEVENT_SKIPOWNPROCESS, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY,
WM_SETTINGCHANGE, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST,
WS_EX_TRANSPARENT,
};
use crate::core::geom::{self, Edge, Rect};
@@ -202,19 +207,34 @@ pub fn apply_toolwindow(window: &tauri::WebviewWindow) -> io::Result<()> {
add_ex_style(window, WS_EX_TOOLWINDOW.0)
}
/// Let mouse input pass through a bubble to the windows below, or take it
/// again. These are the bits tao's `set_ignore_cursor_events` sets, without
/// its re-applying every window flag, which hides a bubble shown through
/// [`show_no_activate`]. Run it on the window's own (main) thread.
pub fn set_click_through(window: &tauri::WebviewWindow, ignore: bool) -> io::Result<()> {
set_ex_style(window, (WS_EX_TRANSPARENT | WS_EX_LAYERED).0, ignore)
}
/// OR `bits` into a window's extended style and apply the frame change.
fn add_ex_style(window: &tauri::WebviewWindow, bits: u32) -> io::Result<()> {
set_ex_style(window, bits, true)
}
/// Set or clear `bits` in a window's extended style and apply the frame
/// change.
fn set_ex_style(window: &tauri::WebviewWindow, bits: u32, on: bool) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
let wanted = bits as isize;
let bits = bits as isize;
// SAFETY: `hwnd` is a live top-level window of this process.
let current = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) };
if current & wanted == wanted {
let wanted = if on { current | bits } else { current & !bits };
if wanted == current {
return Ok(());
}
// SAFETY: as above. A zero return is only an error when the last error
// is set, so it is cleared first.
unsafe { SetLastError(WIN32_ERROR(0)) };
let previous = unsafe { SetWindowLongPtrW(hwnd, GWL_EXSTYLE, current | wanted) };
let previous = unsafe { SetWindowLongPtrW(hwnd, GWL_EXSTYLE, wanted) };
if previous == 0 {
let err = io::Error::last_os_error();
if err.raw_os_error().unwrap_or(0) != 0 {
@@ -313,6 +333,110 @@ pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> {
.map_err(io::Error::from)
}
/// Whether a visible always-on-top window of another process sits above
/// `window` in the z-order and overlaps it, such as a chat app's toast.
/// Our own windows (the panel, a context menu) never count, so raising a
/// bubble over them is never asked for.
pub fn covered_by_other_topmost(window: &tauri::WebviewWindow) -> io::Result<bool> {
let hwnd = hwnd_of(window)?;
let Some((ours, _)) = visible_window_bounds(hwnd) else {
return Ok(false);
};
let own_pid = std::process::id();
// SAFETY: `hwnd` is a live window of this process; each step only reads
// the z-order, and a window closing mid-walk ends it with an error.
let mut above = unsafe { GetWindow(hwnd, GW_HWNDPREV) };
// Topmost windows sit at the head of the z-order, so the walk is short;
// the cap guards against a z-order that keeps changing under it.
for _ in 0..MAX_Z_ORDER_WALK {
let Ok(other) = above else {
return Ok(false);
};
if covers(other, &ours, own_pid) {
return Ok(true);
}
// SAFETY: as above.
above = unsafe { GetWindow(other, GW_HWNDPREV) };
}
Ok(false)
}
const MAX_Z_ORDER_WALK: usize = 1024;
fn covers(other: HWND, ours: &Rect, own_pid: u32) -> bool {
// SAFETY: read-only queries; a stale handle yields 0 / false.
let ex_style = unsafe { GetWindowLongPtrW(other, GWL_EXSTYLE) } as u32;
if ex_style & WS_EX_TOPMOST.0 == 0
|| unsafe { !IsWindowVisible(other).as_bool() }
|| is_dwm_cloaked(other)
|| window_process_id(other).is_none_or(|pid| pid == own_pid)
{
return false;
}
visible_window_bounds(other).is_some_and(|(theirs, _)| theirs.intersects(ours))
}
thread_local! {
/// The callback of [`watch_z_order`], on the thread that installed it.
static Z_ORDER_CALLBACK: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
}
/// Call `callback` as soon as a window of another process is shown or the
/// z-order changes, so a bubble can go back on top of a new always-on-top
/// window (a chat toast, say) at once instead of at the next poll. The
/// events arrive through this thread's message loop, so install it on the
/// main thread; the hook lives as long as the process.
pub fn watch_z_order(callback: impl Fn() + 'static) -> io::Result<()> {
Z_ORDER_CALLBACK.with(|slot| *slot.borrow_mut() = Some(Box::new(callback)));
// SAFETY: an out-of-context hook needs no module handle; the procedure
// is a plain function that lives for the whole process.
let hook = unsafe {
SetWinEventHook(
EVENT_OBJECT_SHOW,
EVENT_OBJECT_REORDER,
None,
Some(z_order_event_proc),
0,
0,
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS,
)
};
if hook.is_invalid() {
Z_ORDER_CALLBACK.with(|slot| slot.borrow_mut().take());
return Err(io::Error::other("SetWinEventHook failed"));
}
Ok(())
}
unsafe extern "system" fn z_order_event_proc(
_hook: HWINEVENTHOOK,
event: u32,
_hwnd: HWND,
id_object: i32,
id_child: i32,
_thread: u32,
_time: u32,
) {
// Only whole windows count: a shown caret or cursor is an object too,
// and a hidden window cannot cover anything.
let relevant = match event {
EVENT_OBJECT_SHOW => id_object == OBJID_WINDOW.0 && id_child == CHILDID_SELF as i32,
EVENT_OBJECT_REORDER => true,
_ => false,
};
if relevant {
// `try_borrow` skips an event raised from inside the callback.
Z_ORDER_CALLBACK.with(|slot| {
let Ok(callback) = slot.try_borrow() else {
return;
};
if let Some(callback) = callback.as_ref() {
callback();
}
});
}
}
/// Hide a bubble (see [`show_no_activate`]).
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
+16 -2
View File
@@ -512,6 +512,12 @@ impl Runtime {
bubble::bring_back(&self.ui.app);
}
/// Raise shown bubbles that another app's always-on-top window covers.
#[cfg(windows)]
pub fn keep_bubbles_on_top(&self) {
bubble::keep_on_top(&self.ui.app);
}
/// Apply a visibility event to every bubble. A user show or hide is
/// also saved as `widget_visible`.
pub fn apply_visibility(&self, event: visibility::Event) {
@@ -651,8 +657,9 @@ impl Runtime {
/// Blocking body of [`Self::check_for_updates`]. Only a check that got
/// an answer is stamped as the last check; a failed one leaves the
/// stamp alone and has the scheduler retry sooner.
fn run_update_check(&self) {
/// stamp alone and has the scheduler retry sooner. A release found where
/// the app can replace itself is installed straight away.
fn run_update_check(self: &Arc<Self>) {
let proxy = update::proxy_url(net::env_proxy().as_ref(), self.http.system_proxy());
let outcome = update::check(&self.ui.app, proxy);
let succeeded = outcome.is_ok();
@@ -694,6 +701,13 @@ impl Runtime {
succeeded,
});
self.refresh_shell(false);
let auto_apply = self.with_state(|s| {
s.update_status == UpdateStatus::Available && s.update_channel.can_apply()
});
if auto_apply {
log::info!("update found; installing it without waiting for a click");
self.apply_update();
}
}
/// Download and install the release the last check found on a worker
+22 -10
View File
@@ -1,21 +1,27 @@
/* Bubble palette. Accent and fill colours come from the payload. */
:root[data-theme='dark'] {
/* 60% opaque, so the desktop shows through; text and bars stay solid. */
--bg: rgb(31 31 31 / 60%);
--track: #2c2c2c;
--time-track: #404040;
/* 80% opaque, so the desktop shows through. Tracks and the edge are
tints over it, so they keep the same contrast step on a light or dark
desktop; the text halo keeps text readable on either. */
--bg: rgb(31 31 31 / 80%);
--track: rgb(0 0 0 / 28%);
--time-track: rgb(255 255 255 / 14%);
--time-fill: #b0b0b0;
--edge: rgb(255 255 255 / 22%);
--text: #eaeaea;
--muted: #a8a8a8;
--muted: #c4c4c4;
--halo: 0 0 2px rgb(0 0 0 / 75%);
}
:root[data-theme='light'] {
--bg: rgb(243 243 243 / 60%);
--track: #e2e2e2;
--time-track: #e0e0e0;
--bg: rgb(243 243 243 / 80%);
--track: rgb(0 0 0 / 10%);
--time-track: rgb(0 0 0 / 8%);
--time-fill: #666666;
--edge: rgb(0 0 0 / 18%);
--text: #1f1f1f;
--muted: #5e5e5e;
--muted: #4a4a4a;
--halo: 0 0 2px rgb(255 255 255 / 85%);
}
html,
@@ -56,10 +62,15 @@ body {
height: 100%;
}
.rings circle {
.rings circle,
.edge {
fill: none;
}
.edge {
stroke: var(--edge);
}
.ring-track {
stroke: var(--track);
}
@@ -82,6 +93,7 @@ body {
margin: 0;
white-space: nowrap;
color: var(--text);
text-shadow: var(--halo);
}
.head-label {
+1
View File
@@ -9,6 +9,7 @@
<body>
<div class="pill" id="pill">
<svg class="rings" id="rings" aria-hidden="true">
<path class="edge" id="edge"></path>
<circle class="ring-track" id="ring-track"></circle>
<circle class="ring-arc" id="ring-arc"></circle>
<circle class="time-ring-track" id="time-ring-track"></circle>
+7
View File
@@ -59,6 +59,8 @@ const FONT_STACK = '"Segoe UI", system-ui, sans-serif';
/** Weight text is measured at when the caller names none (regular). */
const MEASURE_WEIGHT = 400;
const HEAD_LABEL_WEIGHT = 600;
/** Outline stroke in CSS px; the clip keeps the inner half. */
const EDGE_STROKE = 2;
const slug = windowLabel.replace(/^bubble-/, '');
@@ -80,6 +82,8 @@ function byId(id) {
const pill = byId('pill');
/** @type {SVGSVGElement} */
const rings = byId('rings');
/** @type {SVGPathElement} */
const edge = byId('edge');
/** @type {SVGCircleElement} */
const ringTrack = byId('ring-track');
/** @type {SVGCircleElement} */
@@ -221,6 +225,9 @@ function relayout(p) {
const outline = outlinePath(cssPx(p.canvasW, p.dpi), cssPx(p.canvasH, p.dpi));
pill.style.clipPath = `path('${outline}')`;
rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`);
// Half the stroke falls outside the clip, leaving a 1 px edge.
edge.setAttribute('d', outlinePath(p.canvasW, p.canvasH));
edge.setAttribute('stroke-width', String((EDGE_STROKE * p.dpi) / BASE_DPI));
placeCircle(ringTrack, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
placeCircle(ringArc, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
placeCircle(timeRingTrack, head.ringCx, head.ringCy, head.timeRingRadius, head.timeRingStroke);