Inner ring's consumed wedge now grows clockwise from 12 o'clock (mirroring
a clock-hand countdown) and the tail remaining-time bar shrinks toward the
right edge instead of the left. Usage ring and weekly usage bar unchanged.
- Lift inner time ring above WCAG 1.4.11 (track #303030 -> #404040,
stroke floor 1 -> 2 logical, ring gap 3 -> 4).
- Breathe tail bar/text with bar_text_gap=8 (was pad=6); right inset
12 -> 14 logical so text clears the stadium end-cap.
- Reweight typography: head percent FW_BOLD, "5H" tag and tail percent
FW_SEMIBOLD, tail countdown stays normal but takes muted color so
the percent reads as the headline.
- Tone down usage track (#3A3A3A/#D6D6D6 -> #2C2C2C/#E2E2E2) so fill
dominates at low percentages.
- Differentiate lane mass: usage bar 9%/5..12 -> 10%/6..12, time bar
5%/3..7 -> 4%/3..6, lane gap 5 -> 6. Time bar now reads as supporting
context, not a competing quota.
- Min-fill guard on weekly bar: sub-cap fills floor at one cap-diameter
so 1% renders as a recognizable dot.
- head_pad 4 -> 5; big-font ratio 26% -> 24% of head diameter (BOLD
compensates for the size cut).
v0.3.0 introduced a tail 7d% reading but the layout-collapse guard
fired at every common bubble configuration — at the default 200-logical
size on both 100% and 125% DPI, after reserving the CJK worst-case
countdown column ("999시간") and the "100%" text rect, the tail had
less than 20 logical of bar room left. The guard collapsed the % rect
to zero width and the paint code's `if rect.right > rect.left` skip
ran on every frame, so the feature was effectively dead on arrival
for the majority of users.
The 20-logical bar minimum was the pre-feature bar floor, used to
guarantee a readable bar at very small bubble sizes. It does not need
to apply when the % is shown — the % is the actual data and the bar
becomes secondary visual context. Split into two thresholds:
- `bar_min_with_pct = 8 logical` decides whether the % can fit. With
8 logical of bar room the bar still renders as a short pill.
- `bar_min = 20 logical` only applies on the fallback (140-logical
minimum bubble) path where the % has been dropped — preserving
the pre-feature readable-bar behavior at the smallest size.
The bar's render floor now follows the active path (`bar_render_min`)
so a thin bar in the pct-active case does not overlap the countdown.
The stadium bubble previously dropped the 5h reset countdown (only the
ring + percent were visible in the head) and never showed the 7d percent
as a number (only the tail bar fill suggested it). Two more glanceable
data points now live on the bubble face without reopening the panel.
Head: the small "5h" tag is replaced by the live 5h countdown (e.g.
"2h14m"). Falls back to the literal "5h" when no countdown is available
yet (cold start) or when the localized string would overflow the rect —
DT_NOCLIP would otherwise leak wide CJK glyphs ("4시간 32분") onto the
ring stroke at the 140-logical minimum width.
Tail: a new "X%" reading sits between the "7d" label and the bar
(layout reads "7d 62% ▰▰▰▰▰▱▱▱ 6d4h"). Foreground text color —
not the bar accent — because Codex teal #10A37F on the light theme
background only hits ~3.2:1 contrast, below WCAG AA for small text;
adjacency to the bar carries the visual grouping without hue. The text
brightens in sync with the bar fill when weekly_pct >= 95%.
compute_bubble_layout reserves room for a "100%"-sized rect between
label and bar; if that would push the bar below the 20-logical
minimum, the % rect collapses to zero width and the layout falls back
to the original label→bar→countdown geometry, so the 140-logical
bubble keeps its bar.
No new graphics dependencies; tiny-skia + GDI hybrid render path
unchanged. session_text plumbing in src/app.rs was already wired but
unused in the render — now consumed.
cargo check: clean. cargo test: 2/2. cargo clippy: 13 warnings
(unchanged baseline).
User report on v0.1.14: text appears semi-transparent, desktop wallpaper
bleeds through glyph pixels.
Root cause: GDI's DrawTextW writes only RGB into 32bpp BI_RGB DIBs — the
"reserved" alpha byte (byte 3) is not preserved per the BITMAPINFOHEADER
contract. When UpdateLayeredWindow later composites with AC_SRC_ALPHA, it
reads alpha=0 at every glyph pixel and shows them as fully transparent.
The pre-v0.1.13 pipeline worked around this with an apply_alpha_mask
post-pass that OR'd 0xFF000000 into every pixel inside the rounded rect.
The stadium-shape rewrite (526786b) removed it on the false assumption
that tiny-skia's per-pixel alpha would "stick" through subsequent GDI
writes — but GDI runs *after* tiny-skia in the pipeline, so any pixel
GDI text writes to loses the alpha that tiny-skia set.
Fix: re-stamp the alpha channel from the original Pixmap after the GDI
text overlay. This restores tiny-skia's exact alpha values (255 in the
stadium interior, partial on the AA curved perimeter, 0 outside),
including the AA fade at the stadium's rounded ends.
Implementation:
- new helper `restore_alpha_from_pixmap(pixmap, dst)` next to the
existing `copy_pixmap_to_dib`
- hoist `pixmap` out of the if-let arm in render() so it survives until
after `paint_bubble_text`
- call `restore_alpha_from_pixmap` post-text
Two parallel reviewers (debugger + code-reviewer) converged on the same
diagnosis; the debugger preferred this approach for its simplicity and
because it's robust to any GDI behavior (whether alpha is zeroed,
untouched, or scribbled on, we overwrite with the known-good value).
Build clean.
User feedback on v0.1.13: design works, but the 5h percent glyph in the
head crowds the ring at small bubble sizes (the "100%" string was wider
than the ring's inner clear at MIN_BUBBLE_SIZE).
Two parallel UI/UX reviewers converged on:
- big_font_px ratio: head_diameter × 26/100 (was 35/100), floor 11
- small_font_px ratio: big × 55/100 (was 45/100), floor 9
- head_pad: 4 logical px (was 6) — recovers 4px
of inner clear at small sizes
- ring_stroke_w: clamped to [2, 4] (was floor 2 only)
- label/glyph gap: big × 15/100, floor 2 (was implicit 0)
- tail_bar_h: 5 logical px (was 6) — restores
proportion against the 3-px head ring stroke
Worked example at MIN_BUBBLE_SIZE=140 (head_diameter=47):
before: "100%" glyph ≈ 32px wide vs 28px ring inner — overflow
after: "100%" glyph ≈ 23px wide vs 32px ring inner — comfortable
Worked example at MAX_BUBBLE_SIZE=360 (head_diameter=138):
glyph ≈ 70px wide in 124px inner clear (~57%) — confident not crowding
Deliberately not applying:
- drop "7d" label (one reviewer wanted it): rejected — symmetry with
"5h" matters for self-explanation at a glance, and the ~14px cost is
acceptable
- head_diameter bump to canvas_h × 1.08: rejected — only useful coupled
with the label drop
Build clean.
Phase 2 lite. Replaces the horizontal pill (two stacked progress bars)
with a stadium-shaped bubble: a circle "head" on the left showing the
5h percentage as a big glyph surrounded by a stroked progress ring,
plus a "tail" extending right with the 7d label, a thin progress bar,
and the 7d countdown.
The bubble's primary metric (5h window) is now glanceable from across
the room — a thick ring sweeping around a big number reads at a much
greater distance than two thin horizontal bars. The 7d window remains
visible as supporting context. The expanded panel (left-click) still
shows both windows in full.
Implementation notes:
- Hybrid render: tiny-skia (already a Cargo dep for tray badge) paints
the AA shape into a Pixmap. The pixmap is copied byte-for-byte into
the 32bpp BI_RGB DIB; GDI overlays ClearType text on top;
UpdateLayeredWindow blits with per-pixel alpha as before.
- Stadium outline: corner_radius = height/2 so point_in_rounded_rect
exactly approximates the capsule shape for hit-test.
- Pulse animation on ≥95% applies to both the ring sweep (5h) and the
tail bar fill (7d) independently.
- Codex teal #10A37F and Claude orange #D97757 carry across the ring,
the tail bar, and the tray badge sweep via crate::usage_color.
Removed (dead after pipeline swap):
- per-pixel paint_background / paint_accent_stripe / paint_bars /
paint_one_bar / apply_alpha_mask / row_band / rgb_to_dib / blend
- BarLayout struct + compute_layout
- old paint_text_layer / draw_label / draw_percent / draw_countdown
- Breakpoint struct + breakpoint_for_width_logical (font sizes now
derive from head_diameter directly)
- luminance / use_dark_text_over (text was over bar fills; new tail
bar carries no overlaid text)
- constants ACCENT_STRIPE_W_LOGICAL, LABEL_PAD_LOGICAL,
PERCENT_TEMPLATE
Build: cargo build --release clean. Clippy 13 warnings (was 11); the
2 new ones are field-assign-after-Default::default() on tiny-skia
Stroke setup, matching the existing pattern in src/tray/badge.rs.
Known follow-up: BubbleState.session_text + BubbleConfig.session_text
plumbing is now unused (head shows percent only, no 5h countdown on
the bubble). Removing it is a multi-file chain through app.rs and
panel.rs; deferred.
Whole-project review pass over the entire crate. No new features.
All function definitions preserved; layout and visibility reorganised.
Bug fixes:
- bubble: ExtractIconExW HICON pair was leaked per bubble toggle.
Extract once into a process-wide OnceLock, reuse forever (bounded).
- usage/anthropic: parse_iso8601 was stripping the UTC offset without
applying it — negative-offset users saw countdowns up to 14h wrong.
Now parses signed minutes and computes utc_secs = local - off*60.
Also rejects y<1970, mo∉[1,12], d∉[1,max_day(mo,y)] up front so
malformed API responses can't index DAYS_IN_MONTH out of bounds.
- usage: clamp utilization to [0,100] at all four Window construction
sites so a misbehaving server can't render "121%".
- bubble: GetDC and CreateCompatibleDC results weren't checked. Guard
both; release the screen DC on the CreateCompatibleDC failure path.
Refactor:
- Drop type TrayIconKind = ProviderId aliases (5 sites); use ProviderId
directly everywhere. Inline the identity-function kind_to_provider.
- Delete panel::bar_color_for shim (was just argument-reorder glue).
- Replace local scale_to_dpi fns in bubble.rs and panel.rs with
use crate::os::dpi::scale as scale_to_dpi (brings os::dpi into the
live import graph; was unused before).
- Delete dead PCWSTR import + #[allow(dead_code)] sentinel in
tray/badge.rs; fold the trailing `use BOOL` into the top imports.
- Inline app::primary_dpi() to crate::os::dpi::for_system().
app.rs:
- Add update_settings(|s: &mut AppState|) helper that locks state,
runs the closure, snapshots Settings, drops the lock, then saves
to disk. Convert four pure-mutate-then-save callsites.
Layering: bubble.rs no longer reaches upward into crate::app::.
Introduce bubble::Callbacks (fn-pointers), OnceLock<Callbacks>, and
bubble::install_callbacks(). The wnd_proc dispatches the six prior
upward calls via a private dispatch() helper. app::run installs
callbacks once at startup; the six on_bubble_* / recheck_theme fns
are demoted from pub fn to fn.
Resource-warning logs added: dispatch() warns on uninstalled
callbacks; app_icons() warns when ExtractIconExW returns nulls.
Build: cargo build --release clean; cargo clippy reports zero new
warnings (11 pre-existing, all in untouched code).
Phase 0 of UI/UX polish pass. Surgical changes, no substrate migration yet.
- Extract bar_fill_color + accent_color_for into new src/usage_color.rs so
the bubble, panel, and tray badge agree on a single 4-band usage ramp.
- Panel: color each bar from its own percent (was using max(5h, 7d) for
both rows, so a healthy 5h bar turned red whenever 7d was full).
- Light-mode amber #B47A20 (was #E0A040, failed WCAG AA at 2.4:1).
- Codex identity: switch from white/charcoal to OpenAI teal #10A37F
across bubble, panel stripe, and tray sweep so the surfaces share one
brand color and the tray badge stops reading as "loading spinner".
- Panel: drop WS_BORDER, add DwmSetWindowAttribute(DWMWCP_ROUND) for
Win11 rounded corners. Idempotent re-apply on every show() so the
attribute survives any future destroy/recreate path. Silently no-ops
on Win10.
Drop nl/es/fr/de locales (no native-speaker maintenance) and add
Vietnamese. The supported set is now the languages with active
users we can support: English, Japanese, Korean, Vietnamese, and
Traditional Chinese.
Both the in-app restart and the auto-update install previously
shelled out to cmd.exe so the new instance could wait for the old
one to release the singleton mutex and the locked exe file. On
some Windows configurations the `start ""` inside `cmd /c ...` can
flash a console window despite CREATE_NO_WINDOW + DETACHED_PROCESS
flags. The replacement spawns the child binary directly via
CreateProcessW; since the main exe is built with
windows_subsystem = "windows", no console is ever allocated.
- New `src/update/handoff.rs` exposes `spawn_detached`,
`wait_for_parent_exit`, and `cleanup_stale_old_exes`.
- New CLI flags `--wait-pid <pid>` and `--updated-to <version>`
parsed early in `main`; the child waits up to 5s on the parent
PID via OpenProcess+WaitForSingleObject before falling through
to a 3s mutex-acquisition retry.
- `restart_app` and `install::begin` both spawn detached children
using the new helper.
- Update install now uses MoveFileExW twice (rename running exe
sideways, then move staged exe into place with
MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED so portable
installs on non-system drives still work). Rollback restores
the backup if either the swap OR the post-swap detached spawn
fails, and a MessageBoxW modal surfaces the backup path if the
rollback itself fails.
- First launch after an auto-update shows a blue-info tray
balloon "Updated to vX.Y.Z" via a new `tray::notify_info` (the
existing `tray::notify` is split into `notify_warning` +
`notify_info` sharing a `notify_inner`).
- Startup sweeps stale `<exe>.old.<pid>` siblings left by past
in-place updates.
- Three new `LocaleStrings` fields translated across all 8
supported locales (en/nl/es/fr/de/ja/ko/zh-TW).
reset_positions() destroys and recreates the bubbles, which leaves them
displaying the spawn_bubble "…" placeholder until the next 5-minute
TIMER_POLL fires. Push the cached snapshot via propagate_to_ui() so the
last-known values appear immediately, and kick spawn_poll_thread() (idempotent
via POLL_IN_FLIGHT gate) so fresh data follows shortly after.
One-click relaunch of the running binary via a detached cmd.exe handoff:
timeout /t 1 /nobreak >/dev/null & start "" "<exe>" — the 1s wait outlives the
parent so the relaunched instance acquires Global\ClaudeCodeUsageBubble
without ERROR_ALREADY_EXISTS.
- New IDM_RESTART (33) wired into show_context_menu + on_menu_command.
- Match arm placed above the IDM_LANG_BASE guard so future ids in the
static band can't be swallowed by the dynamic-language catch-all.
- Settings flushed defensively before quit (clone-then-save to avoid
blocking the UI thread on disk I/O while holding lock_state).
- Rejects current_exe paths containing '%' (same defense as
update::install — cmd.exe expands %var% inside quotes).
- New 'restart' string in LocaleStrings + translation in all 8 locales.
Saved bubble_positions could land on a secondary monitor that was later
disconnected, leaving the bubble created off-screen with no visual feedback
on toggle-show.
- settings::load now drops any position whose 140px probe rect intersects
no connected monitor (MonitorFromRect + MONITOR_DEFAULTTONULL).
- bubble::create calls clamp_into_work_area before the first render as a
defense-in-depth catch for partial overflows or load/create monitor races.
- clamp_into_work_area preserves the Codex-above-Claude stagger from
default_position when both bubbles get clamped to the same corner.
- Added info/warn log lines on create + clamp paths so future visibility
bugs are diagnosable via --diagnose.
Appends ' · v{CARGO_PKG_VERSION}' to every state of the version-action
menu item so users can see what they are running without opening an
About dialog. Reads as 'Check for updates · v0.1.7', 'Up to date ·
v0.1.7', etc. Winget channel decoration is preserved as a trailing
parenthetical.
Bumps version to 0.1.7.
The legacy poller.rs and updater.rs modules they served were deleted
in the clean-room rewrite; everything now goes through net::winhttp.
Grep confirms no source references to either crate. Removing them
trims the dep graph noticeably.
Bumps version to 0.1.6.
- Threshold balloons fire the cycle utilization crosses 80% or 95%
on either provider, with the title showing "{Provider} · {N}%" and
body translated per shipped locale. Reuses the existing
BALLOON_COOLDOWN so notifications stay calm.
- Dark/light auto-follow: bubble's WM_SETTINGCHANGE handler now calls
app::recheck_theme(), which re-reads HKCU\…\Personalize, updates
state.is_dark if changed, and triggers a UI repaint + tray refresh.
Windows posts WM_SETTINGCHANGE to every top-level window when the
user toggles light/dark in Settings, so the bubble repaints in
near-real-time.
- Adds 2 new i18n keys (threshold_80_body, threshold_95_body) across
all eight shipped locales.
Bumps version to 0.1.5.
Advisory artifacts produced by the three agents spawned to audit the
project. Code-reviewer found the P0/P1 set fixed in 1ef1bfa;
brainstormer ranked feature ideas; researcher surveyed the
self-update / code-signing / distribution space.
- P0: pull blocking HTTPS out from under the global mutex. AppState's
http and registry now live behind Arc<Client> and Arc<Mutex<Registry>>;
do_poll, attempt_refresh, and version_action's Apply branch clone
these out, drop lock_state, then do their I/O. Apply now spawns a
worker thread that posts WM_APP_UPDATE_APPLIED back to the message-
only window when the cmd handoff is launched, so the UI no longer
freezes for the duration of the download.
- P1: bubble.rs paint_text_layer saves and restores the DC's previous
HFONT before DeleteObject. The old code's DeleteObject on a still-
selected HFONT silently failed and leaked one handle per paint frame
(up to ~12/s under the ≥95% pulse animation).
- P1: replace 5x CreatePopupMenu().unwrap() with let-else early returns
that destroy any half-built menus and log. GDI exhaustion no longer
panics the UI thread.
- P1: at-most-one-in-flight gate (static AtomicBool) on the poll thread
so rapid Refresh clicks don't stack concurrent HTTPS calls.
- P1: token-expired balloon now picks the title/body for the provider
that actually failed, instead of always falling back to Claude when
show_claude_code is on.
- P1: panel place_near honors SM_XVIRTUALSCREEN / SM_YVIRTUALSCREEN so
multi-monitor setups with a secondary display left of the primary
no longer mis-clamp the panel position.
- P1: COUNTDOWN_TEMPLATE bumped from "999d" to "999시간" — Korean has
the widest suffix among shipped locales and was overflowing the
countdown column.
Bumps version to 0.1.4.
GitHub's Releases API exposes a `digest: "sha256:..."` field on every
asset since 2024. We now parse it, hash the downloaded bytes locally,
and abort with ChecksumMismatch if they disagree. Releases that predate
the field (none currently exist for this repo) skip verification rather
than fail, so v0.1.0 / v0.1.1 / v0.1.2 still update normally.
cmd.exe expands `%var%` even inside double-quoted arguments, which
would let a path like `C:\Users\%PATHEXT%\bubble.exe` substitute the
expansion. Real Windows paths with `%` are vanishingly rare, so we
fail fast with UnsafePath rather than ship a bespoke cmd-escape
implementation.
Bumps version to 0.1.3.
Rust's std::process::Command escapes inner double quotes as \" when
wrapping the args(["/c", &cmd]) array. cmd.exe does not understand the
\" escape, so the swap-and-restart command got mangled: `start ""
"PATH"` arrived as `start \"\" \"PATH\"`, which the cmd parser
collapsed into `start \ PATH` — producing the "Windows cannot find
'\'" dialog and aborting the update.
Switching to raw_arg lets us hand cmd.exe the literal command line it
expects. The two quote characters cmd needs to keep are the outer pair
wrapping the whole /c argument; cmd's "more than two quotes, special
chars present" branch then preserves the inner path quotes intact.
Bumps version to 0.1.2 since this is the first updater fix that ships
through the updater itself for any future v0.1.2+ user.
Adds a "Settings > Auto-update check" submenu with Disabled / Hourly /
Daily / Weekly. Hourly is the default; existing settings files pick it
up automatically via serde default. Manual "Check for updates" is
unchanged and still fires when auto is disabled.
The 24-hour hardcoded interval is replaced by reading
Settings.update_check_interval_secs in both the startup scheduler and
the post-check rearm path. None means auto is disabled and no timer is
armed.
Adds five new i18n keys across all eight locales.
Replaces the "build from source only" Install section with a
Releases-page download path plus a SmartScreen note, and adds
docs/release-process.md so the tag-bump-build flow is captured for
future maintainers.