diff --git a/tests/js/layout.test.js b/tests/js/layout.test.js
new file mode 100644
index 0000000..0875536
--- /dev/null
+++ b/tests/js/layout.test.js
@@ -0,0 +1,267 @@
+// Bubble layout checked against values worked out from the Rust layout
+// formulas (the GDI bubble's `compute_bubble_layout`) with the same
+// stand-in text measurement.
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import {
+ brighten,
+ cssPx,
+ headLabelText,
+ headMetrics,
+ pulseActive,
+ pulseT,
+ remainingFraction,
+ ringDash,
+ scale,
+ tailLayout,
+ timeFillWidth,
+ usageFillWidth,
+} from '../../ui/layout.js';
+
+/**
+ * Stand-in for canvas `measureText`: 0.55 em per character, truncated.
+ * @param {string} text Text to measure.
+ * @param {number} px Font size.
+ * @returns {number} Width.
+ */
+const measure = (text, px) => Math.trunc((px * text.length * 11) / 20);
+
+/**
+ * Rect shorthand.
+ * @param {number} l Left.
+ * @param {number} t Top.
+ * @param {number} r Right.
+ * @param {number} b Bottom.
+ * @returns {{l: number, t: number, r: number, b: number}} The rect.
+ */
+const rect = (l, t, r, b) => ({ l, t, r, b });
+
+const CASES = [
+ {
+ name: '200 logical at 96 dpi',
+ canvas: { canvasW: 200, canvasH: 66, dpi: 96 },
+ head: {
+ ringCx: 33, ringCy: 33, ringRadius: 26.5, ringStroke: 3, timeRingRadius: 19.5, timeRingStroke: 2,
+ labelRect: rect(4, 18, 62, 29), pctRect: rect(4, 31, 62, 48), fonts: { big: 15, small: 9, main: 9 },
+ },
+ tail: {
+ usagePctRect: rect(165, 23, 186, 34), usageBarRect: rect(72, 25, 157, 31),
+ timeTextRect: rect(165, 33, 186, 44), timeBarRect: rect(72, 37, 157, 40),
+ },
+ },
+ {
+ name: '360 logical at 96 dpi',
+ canvas: { canvasW: 360, canvasH: 138, dpi: 96 },
+ head: {
+ ringCx: 69, ringCy: 69, ringRadius: 62.5, ringStroke: 3, timeRingRadius: 55.5, timeRingStroke: 2,
+ labelRect: rect(4, 39, 134, 59), pctRect: rect(4, 63, 134, 98), fonts: { big: 33, small: 18, main: 18 },
+ },
+ tail: {
+ usagePctRect: rect(305, 53, 346, 73), usageBarRect: rect(144, 57, 297, 69),
+ timeTextRect: rect(305, 68, 346, 88), timeBarRect: rect(144, 75, 297, 80),
+ },
+ },
+ {
+ // 300 px wide: `innerWidth * devicePixelRatio` would drift here.
+ name: '200 logical at 144 dpi',
+ canvas: { canvasW: 300, canvasH: 99, dpi: 144 },
+ head: {
+ ringCx: 49.5, ringCy: 49.5, ringRadius: 40.5, ringStroke: 4, timeRingRadius: 30.5, timeRingStroke: 3,
+ labelRect: rect(6, 27, 93, 43), pctRect: rect(6, 46, 93, 72), fonts: { big: 23, small: 13, main: 13 },
+ },
+ tail: {
+ usagePctRect: rect(248, 35, 279, 51), usageBarRect: rect(108, 38, 236, 47),
+ timeTextRect: rect(248, 50, 279, 66), timeBarRect: rect(108, 56, 236, 60),
+ },
+ },
+ {
+ name: '360 logical at 144 dpi',
+ canvas: { canvasW: 540, canvasH: 207, dpi: 144 },
+ head: {
+ ringCx: 103.5, ringCy: 103.5, ringRadius: 94.5, ringStroke: 4, timeRingRadius: 84.5, timeRingStroke: 3,
+ labelRect: rect(6, 59, 201, 88), pctRect: rect(6, 95, 201, 147), fonts: { big: 49, small: 26, main: 26 },
+ },
+ tail: {
+ usagePctRect: rect(459, 81, 519, 110), usageBarRect: rect(216, 86, 447, 104),
+ timeTextRect: rect(459, 103, 519, 132), timeBarRect: rect(216, 113, 447, 121),
+ },
+ },
+ {
+ name: '140 logical at 120 dpi',
+ canvas: { canvasW: 175, canvasH: 57, dpi: 120 },
+ head: {
+ ringCx: 28.5, ringCy: 28.5, ringRadius: 21, ringStroke: 3, timeRingRadius: 13, timeRingStroke: 2,
+ labelRect: rect(5, 13, 52, 26), pctRect: rect(5, 28, 52, 43), fonts: { big: 13, small: 11, main: 11 },
+ },
+ tail: {
+ usagePctRect: rect(132, 17, 158, 30), usageBarRect: rect(64, 20, 122, 27),
+ timeTextRect: rect(132, 29, 158, 42), timeBarRect: rect(64, 34, 122, 37),
+ },
+ },
+];
+
+describe('headMetrics', () => {
+ for (const c of CASES) {
+ it(`matches the Rust layout for ${c.name}`, () => {
+ assert.deepEqual(headMetrics(c.canvas), c.head);
+ });
+ }
+});
+
+describe('tailLayout', () => {
+ for (const c of CASES) {
+ it(`matches the Rust layout for ${c.name}`, () => {
+ assert.deepEqual(tailLayout(c.canvas, measure), c.tail);
+ });
+ }
+
+ it('shrinks the text column before the bars drop below their minimum', () => {
+ const tail = tailLayout({ canvasW: 200, canvasH: 66, dpi: 96 }, () => 1000);
+ // Content 72..186 is 114 wide: 114 - 8 gap - 8 bar minimum = 98.
+ assert.deepEqual(tail.usagePctRect, rect(88, 23, 186, 34));
+ assert.deepEqual(tail.usageBarRect, rect(72, 25, 80, 31));
+ });
+
+ it('collapses the text column to nothing when even the bars do not fit', () => {
+ const tail = tailLayout({ canvasW: 80, canvasH: 66, dpi: 96 }, () => 50);
+ assert.equal(tail.usagePctRect.l, tail.usagePctRect.r);
+ assert.equal(tail.usageBarRect.r - tail.usageBarRect.l, 8);
+ });
+
+ it('rounds fractional measurements up', () => {
+ const exact = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, () => 40);
+ const fractional = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, () => 39.2);
+ assert.deepEqual(fractional, exact);
+ });
+});
+
+describe('scale and cssPx', () => {
+ it('truncates like the Rust scale', () => {
+ assert.equal(scale(200, 96), 200);
+ assert.equal(scale(200, 144), 300);
+ assert.equal(scale(5, 120), 6);
+ assert.equal(scale(66, 168), 115);
+ });
+
+ it('converts physical px to CSS px from the payload DPI', () => {
+ assert.equal(cssPx(300, 144), 200);
+ assert.equal(cssPx(99, 144), 66);
+ assert.equal(cssPx(66, 96), 66);
+ });
+});
+
+describe('ringDash', () => {
+ const r = 10;
+ const c = 2 * Math.PI * r;
+
+ it('starts the usage arc at 12 o\'clock', () => {
+ assert.deepEqual(ringDash(r, 0.25, 'from-top'), { dashArray: `${c / 4} ${c}`, dashOffset: 0 });
+ });
+
+ it('ends the remaining arc at 12 o\'clock', () => {
+ const dash = ringDash(r, 0.25, 'to-top');
+ assert.equal(dash?.dashArray, `${c / 4} ${c}`);
+ assert.ok(Math.abs((dash?.dashOffset ?? 0) + (c * 3) / 4) < 1e-9);
+ });
+
+ it('draws a full ring at 1 and clamps above it', () => {
+ assert.deepEqual(ringDash(r, 1, 'to-top'), { dashArray: `${c} ${c}`, dashOffset: -0 });
+ assert.deepEqual(ringDash(r, 2, 'from-top'), ringDash(r, 1, 'from-top'));
+ });
+
+ it('draws nothing at zero, below it or for NaN', () => {
+ assert.equal(ringDash(r, 0, 'from-top'), null);
+ assert.equal(ringDash(r, -0.5, 'to-top'), null);
+ assert.equal(ringDash(r, Number.NaN, 'from-top'), null);
+ });
+});
+
+describe('remainingFraction', () => {
+ const now = 1_800_000_000_000;
+
+ it('is null without a reset time', () => {
+ assert.equal(remainingFraction(null, 18_000, now), null);
+ });
+
+ it('is the remaining share of the window', () => {
+ assert.equal(remainingFraction(now + 9_000_000, 18_000, now), 0.5);
+ assert.equal(remainingFraction(now + 3_600_000, 18_000, now), 0.2);
+ });
+
+ it('clamps past resets to 0 and far resets to 1', () => {
+ assert.equal(remainingFraction(now - 1_000, 18_000, now), 0);
+ assert.equal(remainingFraction(now + 36_000_000, 18_000, now), 1);
+ });
+
+ it('is null for a non-positive window length', () => {
+ assert.equal(remainingFraction(now, 0, now), null);
+ });
+});
+
+describe('pulse', () => {
+ it('follows the Rust triangle wave', () => {
+ assert.equal(pulseT(0), 0);
+ assert.equal(pulseT(6), 0.5);
+ assert.equal(pulseT(12), 1);
+ assert.equal(pulseT(18), 0.5);
+ assert.equal(pulseT(24), 0);
+ assert.equal(pulseT(36), 1);
+ });
+
+ it('runs only at 95% and above', () => {
+ assert.equal(pulseActive(null, null), false);
+ assert.equal(pulseActive(94.9, 10), false);
+ assert.equal(pulseActive(95, null), true);
+ assert.equal(pulseActive(null, 99), true);
+ });
+
+ it('brightens toward white by at most 30%', () => {
+ assert.equal(brighten('#A01818', 0), '#A01818');
+ // 160 + 95 * 0.3 = 188.5 -> 189; 24 + 231 * 0.3 = 93.3 -> 93.
+ assert.equal(brighten('#A01818', 1), '#BD5D5D');
+ // Half way: 0.15 of the distance.
+ assert.equal(brighten('#000000', 0.5), '#262626');
+ assert.equal(brighten('#eaeaea', 1), '#F0F0F0');
+ assert.equal(brighten('#FFFFFF', 1), '#FFFFFF');
+ assert.equal(brighten('#123456', 5), brighten('#123456', 1));
+ });
+
+ it('leaves a malformed colour unchanged', () => {
+ assert.equal(brighten('red', 1), 'red');
+ });
+});
+
+describe('bar fills', () => {
+ it('floors a small usage fill at one bar height', () => {
+ assert.equal(usageFillWidth(100, 6, 1), 6);
+ assert.equal(usageFillWidth(100, 6, 50), 50);
+ assert.equal(usageFillWidth(100, 6, 150), 100);
+ assert.equal(usageFillWidth(4, 6, 1), 4);
+ });
+
+ it('draws no usage fill without a value or at zero', () => {
+ assert.equal(usageFillWidth(100, 6, null), 0);
+ assert.equal(usageFillWidth(100, 6, 0), 0);
+ assert.equal(usageFillWidth(0, 6, 50), 0);
+ });
+
+ it('sizes the time fill from the remaining fraction', () => {
+ assert.equal(timeFillWidth(100, 0.25), 25);
+ assert.equal(timeFillWidth(100, null), 0);
+ assert.equal(timeFillWidth(100, 0), 0);
+ });
+});
+
+describe('headLabelText', () => {
+ it('keeps a countdown that fits', () => {
+ assert.equal(headLabelText('2h', 10, 58), '2h');
+ assert.equal(headLabelText('…', 5, 58), '…');
+ });
+
+ it('falls back to 5H when empty or too wide', () => {
+ assert.equal(headLabelText('', 0, 58), '5H');
+ assert.equal(headLabelText('23h 59m', 59, 58), '5H');
+ });
+});
diff --git a/ui/bubble.css b/ui/bubble.css
new file mode 100644
index 0000000..3ea87e5
--- /dev/null
+++ b/ui/bubble.css
@@ -0,0 +1,144 @@
+/* Bubble palette. Accent and fill colours come from the payload. */
+:root[data-theme='dark'] {
+ --bg: #1f1f1f;
+ --track: #2c2c2c;
+ --time-track: #404040;
+ --time-fill: #b0b0b0;
+ --text: #eaeaea;
+ --muted: #a8a8a8;
+}
+
+:root[data-theme='light'] {
+ --bg: #f3f3f3;
+ --track: #e2e2e2;
+ --time-track: #e0e0e0;
+ --time-fill: #666666;
+ --text: #1f1f1f;
+ --muted: #5e5e5e;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: transparent;
+ user-select: none;
+ -webkit-user-select: none;
+ /* Only the pill takes input: the transparent stadium corners are dead. */
+ pointer-events: none;
+}
+
+.pill {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ border-radius: 9999px;
+ background: var(--bg);
+ font-family: 'Segoe UI', system-ui, sans-serif;
+ cursor: move;
+ pointer-events: auto;
+}
+
+/* Every pointer event targets the pill itself, whose hit area follows its
+ rounded outline. */
+.pill * {
+ pointer-events: none;
+}
+
+.rings {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.rings circle {
+ fill: none;
+}
+
+.ring-track {
+ stroke: var(--track);
+}
+
+.time-ring-track {
+ stroke: var(--time-track);
+}
+
+.time-ring-arc {
+ stroke: var(--time-fill);
+}
+
+.ring-arc,
+.time-ring-arc {
+ stroke-linecap: round;
+}
+
+.text {
+ position: absolute;
+ margin: 0;
+ white-space: nowrap;
+ color: var(--text);
+}
+
+.head-label {
+ font-weight: 600;
+ color: var(--muted);
+ text-align: center;
+}
+
+.head-pct {
+ font-weight: 700;
+ text-align: center;
+}
+
+.tail-pct,
+.tail-time {
+ overflow: hidden;
+ text-align: right;
+ text-overflow: ellipsis;
+}
+
+.tail-pct {
+ font-weight: 600;
+}
+
+.tail-time {
+ font-weight: 400;
+ color: var(--muted);
+}
+
+.bar {
+ position: absolute;
+ overflow: hidden;
+ border-radius: 9999px;
+}
+
+.usage-bar {
+ background: var(--track);
+}
+
+.time-bar {
+ background: var(--time-track);
+}
+
+.fill {
+ position: absolute;
+ top: 0;
+ height: 100%;
+ border-radius: 9999px;
+}
+
+.usage-fill {
+ left: 0;
+}
+
+.time-fill {
+ right: 0;
+ background: var(--time-fill);
+}
+
+[hidden] {
+ display: none !important;
+}
diff --git a/ui/bubble.html b/ui/bubble.html
index da055c4..eb6a672 100644
--- a/ui/bubble.html
+++ b/ui/bubble.html
@@ -1,27 +1,25 @@
-
+
Claude Code Usage Bubble
-
+
- Waiting for the first payload...
+
+
+
+
+
+
+
+
+
diff --git a/ui/bubble.js b/ui/bubble.js
index d95de95..19168c2 100644
--- a/ui/bubble.js
+++ b/ui/bubble.js
@@ -1,55 +1,493 @@
-// Test view: renders the bubble payload as raw JSON until the real bubble
-// UI replaces this page.
+// The bubble page: lays out the payload Rust sends and forwards pointer
+// input. Rust formats every displayed string and picks every fill colour;
+// this file only places pixels.
-const tauri = window.__TAURI__;
-const view = /** @type {HTMLElement} */ (document.getElementById('payload'));
-const webview = tauri.webviewWindow.getCurrentWebviewWindow();
-const provider = webview.label.replace(/^bubble-/, '');
+import {
+ BASE_DPI,
+ brighten,
+ cssPx,
+ headLabelText,
+ headMetrics,
+ pulseActive,
+ pulseT,
+ remainingFraction,
+ ringDash,
+ tailLayout,
+ timeFillWidth,
+ usageFillWidth,
+} from './layout.js';
+import {
+ forwardErrors,
+ invoke,
+ listen,
+ logFailure,
+ startDragging,
+ windowLabel,
+} from './tauri-bridge.js';
/**
- * Forward a message to the diagnostic log.
- * @param {'error' | 'warn' | 'info' | 'debug'} level Log level.
- * @param {string} message Text to log.
+ * The `bubble:update` payload and `get_bubble_state` result.
+ * @typedef {object} BubblePayload
+ * @property {string} provider Provider slug.
+ * @property {number | null} sessionPct Session usage percent.
+ * @property {number | null} weeklyPct Weekly usage percent.
+ * @property {string} sessionPctText Head percent text.
+ * @property {string | null} weeklyPctText Tail percent text; null hides it.
+ * @property {string} headLabel Session countdown; empty or too wide shows "5H".
+ * @property {string} weeklyCountdown Weekly countdown; empty hides it.
+ * @property {number | null} sessionResetsAtMs Session reset, Unix ms.
+ * @property {number | null} weeklyResetsAtMs Weekly reset, Unix ms.
+ * @property {number} primaryDurSecs Session window length.
+ * @property {number} secondaryDurSecs Weekly window length.
+ * @property {string} sessionFill Ring arc colour.
+ * @property {string} weeklyFill Weekly bar colour.
+ * @property {string} accent Provider accent colour.
+ * @property {boolean} isDark Dark palette.
+ * @property {number} canvasW Window width in physical px.
+ * @property {number} canvasH Window height in physical px.
+ * @property {number} dpi Window DPI.
+ */
+
+/** Pointer travel, in physical px, that turns a press into a drag. */
+const DRAG_THRESHOLD_PX = 3;
+const PULSE_INTERVAL_MS = 80;
+const TIME_PROGRESS_INTERVAL_MS = 60_000;
+const FONT_STACK = '"Segoe UI", system-ui, sans-serif';
+/** Weight the layout's reserved text widths are measured at. */
+const MEASURE_WEIGHT = 400;
+const HEAD_LABEL_WEIGHT = 600;
+
+const slug = windowLabel.replace(/^bubble-/, '');
+
+/**
+ * Look up a required element.
+ * @template {Element} T
+ * @param {string} id Element id.
+ * @returns {T} The element.
+ */
+function byId(id) {
+ const element = document.getElementById(id);
+ if (!element) {
+ throw new Error(`bubble.html is missing #${id}`);
+ }
+ return /** @type {T} */ (/** @type {unknown} */ (element));
+}
+
+/** @type {HTMLElement} */
+const pill = byId('pill');
+/** @type {SVGSVGElement} */
+const rings = byId('rings');
+/** @type {SVGCircleElement} */
+const ringTrack = byId('ring-track');
+/** @type {SVGCircleElement} */
+const ringArc = byId('ring-arc');
+/** @type {SVGCircleElement} */
+const timeRingTrack = byId('time-ring-track');
+/** @type {SVGCircleElement} */
+const timeRingArc = byId('time-ring-arc');
+/** @type {HTMLElement} */
+const headLabel = byId('head-label');
+/** @type {HTMLElement} */
+const headPct = byId('head-pct');
+/** @type {HTMLElement} */
+const usageBar = byId('usage-bar');
+/** @type {HTMLElement} */
+const usageFill = byId('usage-fill');
+/** @type {HTMLElement} */
+const timeBar = byId('time-bar');
+/** @type {HTMLElement} */
+const timeFill = byId('time-fill');
+/** @type {HTMLElement} */
+const tailPct = byId('tail-pct');
+/** @type {HTMLElement} */
+const tailTime = byId('tail-time');
+
+const measureContext = document.createElement('canvas').getContext('2d');
+
+/**
+ * Width of `text` in physical px. The measuring canvas is unscaled, so a
+ * font size in physical px yields a width in physical px.
+ * @param {string} text Text to measure.
+ * @param {number} fontPx Font size in physical px.
+ * @param {number} [weight] Font weight.
+ * @returns {number} Width in physical px; 0 without a 2D context.
+ */
+function measure(text, fontPx, weight = MEASURE_WEIGHT) {
+ if (!measureContext) {
+ return 0;
+ }
+ measureContext.font = `${weight} ${fontPx}px ${FONT_STACK}`;
+ return measureContext.measureText(text).width;
+}
+
+/** @type {BubblePayload | null} */
+let payload = null;
+/** @type {string} */
+let layoutKey = '';
+/** @type {ReturnType | null} */
+let head = null;
+/** @type {ReturnType | null} */
+let tail = null;
+
+let pulsePhase = 0;
+/** @type {ReturnType | null} */
+let pulseTimer = null;
+/** @type {ReturnType | null} */
+let timeProgressTimer = null;
+
+/**
+ * Place an element on a physical-px rect.
+ * @param {HTMLElement} element Absolutely positioned element.
+ * @param {import('./layout.js').Rect} rect Rect in physical px.
+ * @param {number} dpi Window DPI.
* @returns {void}
*/
-function logJs(level, message) {
- tauri.core.invoke('log_js', { level, message }).catch(() => {});
+function place(element, rect, dpi) {
+ element.style.left = `${cssPx(rect.l, dpi)}px`;
+ element.style.top = `${cssPx(rect.t, dpi)}px`;
+ element.style.width = `${cssPx(Math.max(rect.r - rect.l, 0), dpi)}px`;
+ element.style.height = `${cssPx(Math.max(rect.b - rect.t, 0), dpi)}px`;
}
/**
- * Show one payload.
- * @param {unknown} payload The bubble payload from Rust.
+ * Place a text box: one line, vertically centred in its rect.
+ * @param {HTMLElement} element Text element.
+ * @param {import('./layout.js').Rect} rect Rect in physical px.
+ * @param {number} fontPx Font size in physical px.
+ * @param {number} dpi Window DPI.
* @returns {void}
*/
-function render(payload) {
- view.textContent = JSON.stringify(payload, null, 2);
+function placeText(element, rect, fontPx, dpi) {
+ place(element, rect, dpi);
+ element.style.fontSize = `${cssPx(fontPx, dpi)}px`;
+ element.style.lineHeight = `${cssPx(Math.max(rect.b - rect.t, 0), dpi)}px`;
}
-window.addEventListener('error', (event) => {
- logJs('error', `${event.message} at ${event.filename}:${event.lineno}`);
+/**
+ * Set a ring circle's geometry.
+ * @param {SVGCircleElement} circle Circle element.
+ * @param {number} cx Centre x.
+ * @param {number} cy Centre y.
+ * @param {number} r Radius.
+ * @param {number} stroke Stroke width.
+ * @returns {void}
+ */
+function placeCircle(circle, cx, cy, r, stroke) {
+ circle.setAttribute('cx', String(cx));
+ circle.setAttribute('cy', String(cy));
+ circle.setAttribute('r', String(r));
+ circle.setAttribute('stroke-width', String(stroke));
+ // Paths of a circle start at 3 o'clock; rotate so arcs start at 12.
+ circle.setAttribute('transform', `rotate(-90 ${cx} ${cy})`);
+}
+
+/**
+ * Show an arc for `fraction` of a ring, or hide it.
+ * @param {SVGCircleElement} circle Arc circle.
+ * @param {number} radius Ring radius.
+ * @param {number | null} fraction Share of the ring, or null.
+ * @param {'from-top' | 'to-top'} anchor Where the arc is pinned.
+ * @returns {void}
+ */
+function setArc(circle, radius, fraction, anchor) {
+ const dash = fraction === null ? null : ringDash(radius, fraction, anchor);
+ if (!dash) {
+ circle.setAttribute('visibility', 'hidden');
+ return;
+ }
+ circle.setAttribute('stroke-dasharray', dash.dashArray);
+ circle.setAttribute('stroke-dashoffset', String(dash.dashOffset));
+ circle.removeAttribute('visibility');
+}
+
+/**
+ * Recompute the geometry for the payload's canvas, when it changed.
+ * @param {BubblePayload} p Current payload.
+ * @returns {void}
+ */
+function relayout(p) {
+ const key = `${p.canvasW}x${p.canvasH}@${p.dpi}`;
+ if (key === layoutKey && head && tail) {
+ return;
+ }
+ const canvas = { canvasW: p.canvasW, canvasH: p.canvasH, dpi: p.dpi };
+ head = headMetrics(canvas);
+ tail = tailLayout(canvas, (text, px) => measure(text, px));
+ layoutKey = key;
+
+ rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`);
+ 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);
+ placeCircle(timeRingArc, head.ringCx, head.ringCy, head.timeRingRadius, head.timeRingStroke);
+
+ placeText(headLabel, head.labelRect, head.fonts.small, p.dpi);
+ placeText(headPct, head.pctRect, head.fonts.big, p.dpi);
+ placeText(tailPct, tail.usagePctRect, head.fonts.small, p.dpi);
+ placeText(tailTime, tail.timeTextRect, head.fonts.main, p.dpi);
+ place(usageBar, tail.usageBarRect, p.dpi);
+ place(timeBar, tail.timeBarRect, p.dpi);
+}
+
+/**
+ * Write the texts and the usage values that only change with a payload.
+ * @param {BubblePayload} p Current payload.
+ * @returns {void}
+ */
+function renderValues(p) {
+ if (!head || !tail) {
+ return;
+ }
+ const labelRectW = head.labelRect.r - head.labelRect.l;
+ const labelW = measure(p.headLabel, head.fonts.small, HEAD_LABEL_WEIGHT);
+ headLabel.textContent = headLabelText(p.headLabel, labelW, labelRectW);
+ headPct.textContent = p.sessionPctText;
+
+ const pctRectW = tail.usagePctRect.r - tail.usagePctRect.l;
+ tailPct.textContent = p.weeklyPctText ?? '';
+ tailPct.hidden = p.weeklyPctText === null || pctRectW <= 0;
+ const timeRectW = tail.timeTextRect.r - tail.timeTextRect.l;
+ tailTime.textContent = p.weeklyCountdown;
+ tailTime.hidden = p.weeklyCountdown === '' || timeRectW <= 0;
+
+ setArc(
+ ringArc,
+ head.ringRadius,
+ p.sessionPct === null ? null : p.sessionPct / 100,
+ 'from-top',
+ );
+ const barW = tail.usageBarRect.r - tail.usageBarRect.l;
+ const barH = tail.usageBarRect.b - tail.usageBarRect.t;
+ const fillW = usageFillWidth(barW, barH, p.weeklyPct);
+ usageFill.hidden = fillW <= 0;
+ usageFill.style.width = `${cssPx(fillW, p.dpi)}px`;
+}
+
+/**
+ * Redraw the remaining-time ring and bar for the current time.
+ * @returns {void}
+ */
+function renderTime() {
+ if (!payload || !head || !tail) {
+ return;
+ }
+ const now = Date.now();
+ setArc(
+ timeRingArc,
+ head.timeRingRadius,
+ remainingFraction(payload.sessionResetsAtMs, payload.primaryDurSecs, now),
+ 'to-top',
+ );
+ const barW = tail.timeBarRect.r - tail.timeBarRect.l;
+ const fillW = timeFillWidth(
+ barW,
+ remainingFraction(payload.weeklyResetsAtMs, payload.secondaryDurSecs, now),
+ );
+ timeFill.hidden = fillW <= 0;
+ timeFill.style.width = `${cssPx(fillW, payload.dpi)}px`;
+}
+
+/**
+ * Paint the colours that pulse: the ring arc while the session is at 95%
+ * or more, the weekly bar fill and percent text while the week is.
+ * @returns {void}
+ */
+function paintPulse() {
+ if (!payload) {
+ return;
+ }
+ const t = pulseT(pulsePhase);
+ const sessionAlarm = pulseActive(payload.sessionPct, null);
+ const weeklyAlarm = pulseActive(null, payload.weeklyPct);
+ ringArc.style.stroke = sessionAlarm ? brighten(payload.sessionFill, t) : payload.sessionFill;
+ usageFill.style.background = weeklyAlarm ? brighten(payload.weeklyFill, t) : payload.weeklyFill;
+ if (weeklyAlarm) {
+ const text = getComputedStyle(document.documentElement).getPropertyValue('--text');
+ tailPct.style.color = brighten(text, t);
+ } else {
+ tailPct.style.color = '';
+ }
+}
+
+/**
+ * Run the 80 ms pulse only while a value is at 95% or more; reset the
+ * phase whenever it stops.
+ * @returns {void}
+ */
+function syncPulseTimer() {
+ const active = payload !== null && pulseActive(payload.sessionPct, payload.weeklyPct);
+ if (active && pulseTimer === null) {
+ pulseTimer = setInterval(() => {
+ pulsePhase += 1;
+ paintPulse();
+ }, PULSE_INTERVAL_MS);
+ } else if (!active && pulseTimer !== null) {
+ clearInterval(pulseTimer);
+ pulseTimer = null;
+ pulsePhase = 0;
+ }
+}
+
+/**
+ * Advance the time progress every minute while a reset time is known.
+ * @returns {void}
+ */
+function syncTimeProgressTimer() {
+ const active =
+ payload !== null && (payload.sessionResetsAtMs !== null || payload.weeklyResetsAtMs !== null);
+ if (active && timeProgressTimer === null) {
+ timeProgressTimer = setInterval(renderTime, TIME_PROGRESS_INTERVAL_MS);
+ } else if (!active && timeProgressTimer !== null) {
+ clearInterval(timeProgressTimer);
+ timeProgressTimer = null;
+ }
+}
+
+/**
+ * Apply the theme palette.
+ * @param {boolean} isDark Dark palette.
+ * @returns {void}
+ */
+function applyTheme(isDark) {
+ document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
+}
+
+/**
+ * Render a payload.
+ * @param {BubblePayload} p Payload from Rust.
+ * @returns {void}
+ */
+function applyPayload(p) {
+ payload = p;
+ applyTheme(p.isDark);
+ relayout(p);
+ renderValues(p);
+ renderTime();
+ syncPulseTimer();
+ syncTimeProgressTimer();
+ paintPulse();
+}
+
+// ---------- Input ----------
+
+/** @type {{pointerId: number, x: number, y: number} | null} */
+let pressed = null;
+
+/**
+ * Physical px per CSS px, from the payload DPI.
+ * @returns {number} Scale factor.
+ */
+function physicalScale() {
+ return payload ? payload.dpi / BASE_DPI : window.devicePixelRatio || 1;
+}
+
+/** @returns {void} */
+function clearPressed() {
+ pressed = null;
+}
+
+pill.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0) {
+ return;
+ }
+ try {
+ pill.setPointerCapture(event.pointerId);
+ } catch (err) {
+ // Without capture a release outside the bubble is missed, which only
+ // costs that one click.
+ logFailure('setPointerCapture')(err);
+ }
+ pressed ={ pointerId: event.pointerId, x: event.screenX, y: event.screenY };
});
-window.addEventListener('unhandledrejection', (event) => {
- logJs('error', `unhandled rejection: ${String(event.reason)}`);
+
+pill.addEventListener('pointermove', (event) => {
+ if (!pressed || event.pointerId !== pressed.pointerId) {
+ return;
+ }
+ if ((event.buttons & 1) !== 1) {
+ clearPressed();
+ return;
+ }
+ const scale = physicalScale();
+ const dx = Math.abs(event.screenX - pressed.x) * scale;
+ const dy = Math.abs(event.screenY - pressed.y) * scale;
+ if (Math.max(dx, dy) < DRAG_THRESHOLD_PX) {
+ return;
+ }
+ // The OS move loop swallows the release, so the drag ends in Rust; the
+ // pressed state is cleared now so a later hover cannot start another.
+ clearPressed();
+ if (pill.hasPointerCapture(event.pointerId)) {
+ pill.releasePointerCapture(event.pointerId);
+ }
+ // Arm the move-end handling before the window starts moving.
+ invoke('bubble_drag_start', { slug })
+ .catch(logFailure('bubble_drag_start'))
+ .then(() => startDragging())
+ .catch(logFailure('startDragging'));
});
+pill.addEventListener('pointerup', (event) => {
+ if (!pressed || event.pointerId !== pressed.pointerId || event.button !== 0) {
+ return;
+ }
+ clearPressed();
+ invoke('bubble_click', { slug }).catch(logFailure('bubble_click'));
+});
+
+pill.addEventListener('pointercancel', clearPressed);
+pill.addEventListener('lostpointercapture', clearPressed);
+window.addEventListener('blur', clearPressed);
+
window.addEventListener('contextmenu', (event) => {
event.preventDefault();
- tauri.core
- .invoke('show_context_menu', { label: webview.label })
- .catch((err) => logJs('error', `show_context_menu failed: ${String(err)}`));
+ if (event.target !== pill) {
+ return;
+ }
+ invoke('show_context_menu', { label: windowLabel }).catch(logFailure('show_context_menu'));
});
-webview
- .listen('bubble:update', (event) => render(event.payload))
- .catch((err) => logJs('error', `listen failed: ${String(err)}`));
+window.addEventListener(
+ 'wheel',
+ (event) => {
+ if (!event.ctrlKey) {
+ return;
+ }
+ // Ctrl+wheel would otherwise zoom the page.
+ event.preventDefault();
+ const dir = Math.sign(-event.deltaY);
+ if (event.target !== pill || dir === 0) {
+ return;
+ }
+ invoke('bubble_resize_step', { dir }).catch(logFailure('bubble_resize_step'));
+ },
+ { passive: false },
+);
-tauri.core
- .invoke('get_bubble_state', { provider })
- .then((payload) => {
- render(payload);
- return tauri.core.invoke('bubble_ready', { label: webview.label });
+document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ renderTime();
+ }
+});
+
+// ---------- Start-up ----------
+
+forwardErrors();
+
+listen('bubble:update', (/** @type {BubblePayload} */ p) => applyPayload(p)).catch(
+ logFailure('listen bubble:update'),
+);
+listen('theme', (/** @type {{isDark: boolean}} */ p) => {
+ applyTheme(p.isDark);
+ paintPulse();
+}).catch(logFailure('listen theme'));
+
+invoke('get_bubble_state', { provider: slug })
+ .then((/** @type {BubblePayload} */ p) => {
+ // A push that arrived first is at least as new as this answer.
+ if (payload === null) {
+ applyPayload(p);
+ }
+ return invoke('bubble_ready', { label: windowLabel });
})
- .catch((err) => {
- view.textContent = `get_bubble_state failed: ${String(err)}`;
- logJs('error', `startup failed: ${String(err)}`);
- });
+ .catch(logFailure('bubble start-up'));
diff --git a/ui/layout.js b/ui/layout.js
new file mode 100644
index 0000000..d646e93
--- /dev/null
+++ b/ui/layout.js
@@ -0,0 +1,343 @@
+// Pure bubble geometry and colour helpers. Every length is in physical
+// pixels, driven by the canvas size and DPI that Rust sends in the payload,
+// never by `innerWidth * devicePixelRatio`. Integer steps truncate toward
+// zero, like the Rust `i32` arithmetic they mirror.
+
+/** The DPI the OS reports for 100% scaling. */
+export const BASE_DPI = 96;
+
+/** Width the head label may not exceed; wider labels fall back to this. */
+export const HEAD_LABEL_FALLBACK = '5H';
+
+/** Widest countdown text the tail reserves room for. */
+const COUNTDOWN_TEMPLATE = '999d';
+
+/** Widest percent text the tail reserves room for. */
+const PCT_TEMPLATE = '100%';
+
+/** Usage at or above this percent pulses. */
+export const PULSE_THRESHOLD_PCT = 95;
+
+/** Pulse period in ticks and the share of the way to white at the peak. */
+const PULSE_PERIOD = 24;
+const PULSE_STRENGTH = 0.3;
+
+/**
+ * @typedef {object} Canvas
+ * @property {number} canvasW Window width in physical px.
+ * @property {number} canvasH Window height in physical px.
+ * @property {number} dpi Window DPI (96 at 100% scaling).
+ */
+
+/**
+ * Rectangle with exclusive right and bottom edges, like Win32 `RECT`.
+ * @typedef {object} Rect
+ * @property {number} l Left edge.
+ * @property {number} t Top edge.
+ * @property {number} r Right edge.
+ * @property {number} b Bottom edge.
+ */
+
+/**
+ * @typedef {object} FontSizes
+ * @property {number} big Head percent, bold.
+ * @property {number} small Head label and tail percent, semibold.
+ * @property {number} main Tail countdown, regular.
+ */
+
+/**
+ * @typedef {object} HeadMetrics
+ * @property {number} ringCx Ring centre x.
+ * @property {number} ringCy Ring centre y.
+ * @property {number} ringRadius Centre-line radius of the usage ring.
+ * @property {number} ringStroke Usage ring stroke width.
+ * @property {number} timeRingRadius Centre-line radius of the time ring.
+ * @property {number} timeRingStroke Time ring stroke width.
+ * @property {Rect} labelRect Head label (countdown or "5H").
+ * @property {Rect} pctRect Head percent.
+ * @property {FontSizes} fonts Font sizes in physical px.
+ */
+
+/**
+ * @typedef {object} TailLayout
+ * @property {Rect} usagePctRect Weekly percent text.
+ * @property {Rect} usageBarRect Weekly usage bar.
+ * @property {Rect} timeTextRect Weekly countdown text.
+ * @property {Rect} timeBarRect Weekly remaining-time bar.
+ */
+
+/**
+ * Measures `text` set at `fontPx` (regular weight) in physical px.
+ * @callback MeasureText
+ * @param {string} text Text to measure.
+ * @param {number} fontPx Font size in physical px.
+ * @returns {number} Width in physical px.
+ */
+
+/**
+ * @typedef {object} RingDash
+ * @property {string} dashArray Value for `stroke-dasharray`.
+ * @property {number} dashOffset Value for `stroke-dashoffset`.
+ */
+
+/**
+ * Integer division truncating toward zero, like Rust `i32 / i32`.
+ * @param {number} a Dividend.
+ * @param {number} b Divisor.
+ * @returns {number} Truncated quotient.
+ */
+function idiv(a, b) {
+ return Math.trunc(a / b);
+}
+
+/**
+ * Clamp `v` into `[lo, hi]`.
+ * @param {number} v Value.
+ * @param {number} lo Lower bound.
+ * @param {number} hi Upper bound.
+ * @returns {number} Clamped value.
+ */
+function clamp(v, lo, hi) {
+ return Math.min(Math.max(v, lo), hi);
+}
+
+/**
+ * Scale a logical (96-DPI) length to `dpi`, truncating.
+ * @param {number} logical Length at 96 DPI.
+ * @param {number} dpi Target DPI.
+ * @returns {number} Physical length.
+ */
+export function scale(logical, dpi) {
+ return idiv(logical * dpi, BASE_DPI);
+}
+
+/**
+ * Convert a physical length to CSS px for a window at `dpi`.
+ * @param {number} physical Length in physical px.
+ * @param {number} dpi Window DPI.
+ * @returns {number} Length in CSS px.
+ */
+export function cssPx(physical, dpi) {
+ return (physical * BASE_DPI) / dpi;
+}
+
+/**
+ * Font sizes for a canvas.
+ * @param {Canvas} canvas Window canvas.
+ * @returns {FontSizes} Sizes in physical px.
+ */
+export function fontSizes(canvas) {
+ const { canvasH: h, dpi } = canvas;
+ const big = Math.max(idiv(h * 24, 100), scale(11, dpi));
+ const small = Math.max(idiv(big * 55, 100), scale(9, dpi));
+ return { big, small, main: small };
+}
+
+/**
+ * Geometry of the head: the usage and time rings and the two head texts.
+ * The head is the left `canvasH × canvasH` square of the stadium.
+ * @param {Canvas} canvas Window canvas.
+ * @returns {HeadMetrics} Head geometry in physical px.
+ */
+export function headMetrics(canvas) {
+ const { canvasH: h, dpi } = canvas;
+ const headD = h;
+ const headPad = scale(5, dpi);
+ const ringStroke = clamp(scale(3, dpi), 2, 4);
+ const ringOuter = headD / 2 - headPad;
+ const ringRadius = ringOuter - ringStroke / 2;
+ const timeRingStroke = clamp(scale(2, dpi), 2, 3);
+ const timeRingRadius = Math.max(ringRadius - ringStroke - scale(4, dpi), timeRingStroke);
+
+ const fonts = fontSizes(canvas);
+ const labelH = fonts.small + scale(2, dpi);
+ const pctH = fonts.big + scale(2, dpi);
+ const gap = Math.max(idiv(fonts.big * 15, 100), scale(2, dpi));
+ const total = labelH + gap + pctH;
+ const top = idiv(h - total, 2);
+ const inset = scale(4, dpi);
+ return {
+ ringCx: headD / 2,
+ ringCy: h / 2,
+ ringRadius,
+ ringStroke,
+ timeRingRadius,
+ timeRingStroke,
+ labelRect: { l: inset, t: top, r: headD - inset, b: top + labelH },
+ pctRect: { l: inset, t: top + labelH + gap, r: headD - inset, b: top + total },
+ fonts,
+ };
+}
+
+/**
+ * Geometry of the tail: the weekly usage bar and remaining-time bar with
+ * their right-aligned texts. When the tail is too narrow, the text column
+ * shrinks (possibly to zero) before the bars drop below their minimum.
+ * @param {Canvas} canvas Window canvas.
+ * @param {MeasureText} measure Regular-weight text measurement.
+ * @returns {TailLayout} Tail geometry in physical px.
+ */
+export function tailLayout(canvas, measure) {
+ const { canvasW: w, canvasH: h, dpi } = canvas;
+ const fonts = fontSizes(canvas);
+ const tailLeft = h;
+ const contentRight = w - scale(14, dpi);
+ const contentLeft = tailLeft + scale(6, dpi);
+ const barTextGap = scale(8, dpi);
+
+ const countdownW = Math.ceil(measure(COUNTDOWN_TEMPLATE, fonts.main));
+ const pctReserveW = Math.ceil(measure(PCT_TEMPLATE, fonts.small)) + scale(2, dpi);
+
+ const usageBarH = clamp(idiv(h * 10, 100), scale(6, dpi), scale(12, dpi));
+ const timeBarH = clamp(idiv(h * 4, 100), scale(3, dpi), scale(6, dpi));
+ const laneGap = scale(6, dpi);
+ const usageBarTop = idiv(h - (usageBarH + laneGap + timeBarH), 2);
+ const timeBarTop = usageBarTop + usageBarH + laneGap;
+ const timeTextH = fonts.main + scale(2, dpi);
+ const usagePctH = fonts.small + scale(2, dpi);
+
+ const contentW = Math.max(contentRight - contentLeft, 0);
+ const barMin = scale(8, dpi);
+ const desiredTextW = Math.max(countdownW, pctReserveW);
+ const textW =
+ contentW >= desiredTextW + barTextGap + barMin
+ ? desiredTextW
+ : Math.max(contentW - barTextGap - barMin, 0);
+ const textLeft = contentRight - textW;
+ const barLeft = contentLeft;
+ const barRight = Math.max(textLeft - barTextGap, barLeft + barMin);
+
+ const usagePctTop = usageBarTop + idiv(usageBarH - usagePctH, 2);
+ const timeTextTop = timeBarTop + idiv(timeBarH - timeTextH, 2);
+ return {
+ usagePctRect: { l: textLeft, t: usagePctTop, r: contentRight, b: usagePctTop + usagePctH },
+ usageBarRect: { l: barLeft, t: usageBarTop, r: barRight, b: usageBarTop + usageBarH },
+ timeTextRect: { l: textLeft, t: timeTextTop, r: contentRight, b: timeTextTop + timeTextH },
+ timeBarRect: { l: barLeft, t: timeBarTop, r: barRight, b: timeBarTop + timeBarH },
+ };
+}
+
+/**
+ * Dash pattern for an arc on an SVG circle rotated -90° (so the path
+ * starts at 12 o'clock and runs clockwise). The usage arc starts at
+ * 12 o'clock; the remaining-time arc ends there, so the consumed wedge
+ * grows clockwise from 12 like a clock hand.
+ * @param {number} radius Circle radius.
+ * @param {number} fraction Share of the full turn, clamped to [0, 1].
+ * @param {'from-top' | 'to-top'} anchor Whether the arc starts or ends at 12 o'clock.
+ * @returns {RingDash | null} The dash values, or null when nothing is drawn.
+ */
+export function ringDash(radius, fraction, anchor) {
+ const f = clamp(fraction, 0, 1);
+ if (!(f > 0)) {
+ return null;
+ }
+ const circumference = 2 * Math.PI * radius;
+ const length = f * circumference;
+ return {
+ dashArray: `${length} ${circumference}`,
+ dashOffset: anchor === 'to-top' ? -(circumference - length) : 0,
+ };
+}
+
+/**
+ * Share of a usage window still left before it resets.
+ * @param {number | null} resetsAtMs Reset time in Unix ms, or null.
+ * @param {number} durationSecs Window length in seconds.
+ * @param {number} nowMs Current time in Unix ms.
+ * @returns {number | null} Fraction in [0, 1], or null without a reset time.
+ */
+export function remainingFraction(resetsAtMs, durationSecs, nowMs) {
+ if (resetsAtMs === null || resetsAtMs === undefined || !(durationSecs > 0)) {
+ return null;
+ }
+ const remainingSecs = Math.max(resetsAtMs - nowMs, 0) / 1000;
+ return clamp(remainingSecs / durationSecs, 0, 1);
+}
+
+/**
+ * Pulse triangle wave: 0 at phase 0, 1 at phase 12, period 24.
+ * @param {number} phase Tick counter (a non-negative integer).
+ * @returns {number} Brightening amount in [0, 1].
+ */
+export function pulseT(phase) {
+ return 1 - Math.abs((phase % PULSE_PERIOD) - PULSE_PERIOD / 2) / (PULSE_PERIOD / 2);
+}
+
+/**
+ * Whether either percent is in the pulsing alarm band.
+ * @param {number | null} sessionPct Session percent.
+ * @param {number | null} weeklyPct Weekly percent.
+ * @returns {boolean} True while the pulse timer should run.
+ */
+export function pulseActive(sessionPct, weeklyPct) {
+ return (
+ (sessionPct !== null && sessionPct >= PULSE_THRESHOLD_PCT) ||
+ (weeklyPct !== null && weeklyPct >= PULSE_THRESHOLD_PCT)
+ );
+}
+
+/**
+ * Move a `#RRGGBB` colour toward white by up to 30%.
+ * @param {string} hex Colour as `#RRGGBB`.
+ * @param {number} t Pulse amount in [0, 1].
+ * @returns {string} The brightened colour as upper-case `#RRGGBB`; the input
+ * unchanged when it is not a `#RRGGBB` colour.
+ */
+export function brighten(hex, t) {
+ const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
+ if (!match) {
+ return hex;
+ }
+ const k = clamp(t, 0, 1) * PULSE_STRENGTH;
+ const value = parseInt(match[1], 16);
+ const channels = [value >> 16, (value >> 8) & 0xff, value & 0xff].map((c) =>
+ Math.round(c + (255 - c) * k),
+ );
+ return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
+}
+
+/**
+ * Width of the weekly usage fill. A small non-zero value is floored at one
+ * bar height, so 1% still shows as a dot.
+ * @param {number} barW Bar width.
+ * @param {number} barH Bar height.
+ * @param {number | null} pct Weekly percent, or null.
+ * @returns {number} Fill width; 0 draws nothing.
+ */
+export function usageFillWidth(barW, barH, pct) {
+ if (pct === null || !(barW > 0) || !(barH > 0)) {
+ return 0;
+ }
+ const fill = barW * (clamp(pct, 0, 100) / 100);
+ if (!(fill > 0)) {
+ return 0;
+ }
+ const clipped = Math.min(fill, barW);
+ return clipped < barH ? Math.min(barH, barW) : clipped;
+}
+
+/**
+ * Width of the remaining-time fill, anchored on the bar's right edge.
+ * @param {number} barW Bar width.
+ * @param {number | null} fraction Remaining fraction, or null.
+ * @returns {number} Fill width; 0 draws nothing.
+ */
+export function timeFillWidth(barW, fraction) {
+ if (fraction === null || !(barW > 0)) {
+ return 0;
+ }
+ return Math.max(Math.min(barW * fraction, barW), 0);
+}
+
+/**
+ * The head label to draw: the countdown, or "5H" when it is empty or wider
+ * than its rect.
+ * @param {string} label Countdown from the payload.
+ * @param {number} labelW Its measured width in physical px.
+ * @param {number} rectW Width of the head label rect.
+ * @returns {string} Text to draw.
+ */
+export function headLabelText(label, labelW, rectW) {
+ return label === '' || labelW > rectW ? HEAD_LABEL_FALLBACK : label;
+}
diff --git a/ui/tauri-bridge.js b/ui/tauri-bridge.js
new file mode 100644
index 0000000..13e1e2b
--- /dev/null
+++ b/ui/tauri-bridge.js
@@ -0,0 +1,95 @@
+// Thin typed wrappers over `window.__TAURI__` (`withGlobalTauri`), shared by
+// the bubble and panel pages.
+
+/**
+ * The slice of a Tauri webview window this UI uses.
+ * @typedef {object} TauriWebviewWindow
+ * @property {string} label Window label.
+ * @property {(event: string, handler: (e: {payload: unknown}) => void) => Promise<() => void>} listen
+ * Listen for an event targeted at this window.
+ * @property {() => Promise} startDragging Start an OS window drag.
+ */
+
+/**
+ * The slice of `window.__TAURI__` (`@tauri-apps/api`, which this
+ * bundler-free UI does not import) that this UI uses.
+ * @typedef {object} TauriGlobal
+ * @property {{invoke: (command: string, args?: Record) => Promise}} core
+ * Command IPC.
+ * @property {{getCurrentWebviewWindow: () => TauriWebviewWindow}} webviewWindow
+ * Window access.
+ */
+
+/** @type {TauriGlobal} */
+const tauri = window.__TAURI__;
+
+/** The webview window this page runs in. */
+const webview = tauri.webviewWindow.getCurrentWebviewWindow();
+
+/**
+ * Label of this page's window, e.g. `bubble-claude`.
+ * @type {string}
+ */
+export const windowLabel = webview.label;
+
+/**
+ * Call a Rust command.
+ * @template T
+ * @param {string} command Command name.
+ * @param {Record} [args] Command arguments (camelCase).
+ * @returns {Promise} The command's result.
+ */
+export function invoke(command, args = {}) {
+ return /** @type {Promise} */ (tauri.core.invoke(command, args));
+}
+
+/**
+ * Listen for an event Rust sends to this window.
+ * @template T
+ * @param {string} event Event name.
+ * @param {(payload: T) => void} handler Called with each event's payload.
+ * @returns {Promise<() => void>} Resolves to a function that stops listening.
+ */
+export function listen(event, handler) {
+ return webview.listen(event, (e) => handler(/** @type {T} */ (e.payload)));
+}
+
+/**
+ * Start an OS window drag. Call it while the primary button is down.
+ * @returns {Promise} Resolves once the drag was requested.
+ */
+export function startDragging() {
+ return webview.startDragging();
+}
+
+/**
+ * Forward a message to the diagnostic log (written only with `--diagnose`).
+ * @param {'error' | 'warn' | 'info' | 'debug'} level Log level.
+ * @param {string} message Text to log.
+ * @returns {void}
+ */
+export function logJs(level, message) {
+ invoke('log_js', { level, message }).catch(() => {});
+}
+
+/**
+ * Turn a rejected promise into an error log line.
+ * @param {string} what What failed, for the log.
+ * @returns {(err: unknown) => void} A `catch` handler.
+ */
+export function logFailure(what) {
+ return (err) => logJs('error', `${what} failed: ${String(err)}`);
+}
+
+/**
+ * Forward uncaught errors and unhandled rejections to the diagnostic log.
+ * @returns {void}
+ */
+export function forwardErrors() {
+ window.addEventListener('error', (event) => {
+ logJs('error', `${event.message} at ${event.filename}:${event.lineno}`);
+ });
+ window.addEventListener('unhandledrejection', (event) => {
+ logJs('error', `unhandled rejection: ${String(event.reason)}`);
+ });
+}