style(x): tray recording waveform + popup polish

- animated sine-wave indicator (braille dot bars, two per cell, 300ms
  frames) beside the tray icon while a meeting records
- popup: hover-anywhere reveals the close button (the drag region was
  swallowing mouse events), leaner card (48px, 76px window), sits 44px
  below the menu bar
This commit is contained in:
Gagan 2026-07-14 01:27:32 +05:30
parent f2d6bbbff3
commit 9ee332e21b
3 changed files with 65 additions and 12 deletions

View file

@ -15,7 +15,7 @@ import type { DetectedMeeting } from "@x/core/dist/meetings/detector.js";
// Lean bar + margins for the overhanging × and the CSS drop shadow.
const POPUP_WIDTH = 400;
const POPUP_HEIGHT = 84;
const POPUP_HEIGHT = 76;
const AUTO_DISMISS_MS = 45_000;
// Display names, Granola-style ("Chrome", not "Google Chrome").
@ -98,7 +98,8 @@ export function showMeetingPopup(meeting: DetectedMeeting): void {
width: POPUP_WIDTH,
height: POPUP_HEIGHT,
x: workArea.x + 24,
y: workArea.y + 24,
// Sit a bit clear of the menu bar rather than hugging it.
y: workArea.y + 44,
// NSPanel (macOS): non-activating, and — unlike a regular window with
// visibleOnFullScreen — can float over fullscreen Spaces without
// turning Rowboat into an "agent" app that loses its Dock icon.

View file

@ -88,6 +88,62 @@ export function setTrayRecordingState(isRecording: boolean): void {
if (recording === isRecording) return;
recording = isRecording;
rebuildMenu();
if (isRecording) startWaveAnimation();
else stopWaveAnimation();
}
// --- Recording indicator: animated mini-waveform beside the tray icon ---
// macOS renders tray titles to the right of the icon. Braille cells give
// 1-dot-wide bars (two bars per character, four height steps each) — a slim
// waveform, an unmissable "Rowboat is capturing this meeting" signal.
const WAVE_FRAME_MS = 300;
const WAVE_BAR_COUNT = 5;
// Dot bits for a bar of height 14 (index 03), built bottom-up. Two bars
// per braille cell (left column: dots 7,3,2,1 — right column: dots 8,6,5,4)
// keeps the columns tightly packed; the sine wave keeps every bar ≥1 dot so
// no column ever reads as missing.
const WAVE_LEFT_BITS = [0x40, 0x44, 0x46, 0x47];
const WAVE_RIGHT_BITS = [0x80, 0xa0, 0xb0, 0xb8];
// Radians per bar / per frame: together they make the crest travel smoothly
// leftward across the five bars.
const WAVE_SPATIAL_STEP = 1.1;
const WAVE_PHASE_STEP = 0.9;
let waveTimer: NodeJS.Timeout | null = null;
let wavePhase = 0;
function waveString(phase: number): string {
const levels: number[] = [];
for (let i = 0; i < WAVE_BAR_COUNT; i++) {
const level = Math.round(1.5 + 1.5 * Math.sin(phase + i * WAVE_SPATIAL_STEP));
levels.push(Math.min(3, Math.max(0, level)));
}
let out = "";
for (let i = 0; i < levels.length; i += 2) {
const left = WAVE_LEFT_BITS[levels[i]];
const right = levels[i + 1] !== undefined ? WAVE_RIGHT_BITS[levels[i + 1]] : 0;
out += String.fromCharCode(0x2800 + left + right);
}
return out;
}
function startWaveAnimation(): void {
if (!tray || process.platform !== "darwin") return;
stopWaveAnimation();
waveTimer = setInterval(() => {
if (!tray) return;
wavePhase += WAVE_PHASE_STEP;
tray.setTitle(` ${waveString(wavePhase)}`, { fontType: "monospaced" });
}, WAVE_FRAME_MS);
}
function stopWaveAnimation(): void {
if (waveTimer) {
clearInterval(waveTimer);
waveTimer = null;
}
if (tray && process.platform === "darwin") tray.setTitle("");
}
function rebuildMenu(): void {

View file

@ -7,9 +7,6 @@ type PopupPayload = {
hasCalendarEvent: boolean
}
const dragRegion = { WebkitAppRegion: 'drag' } as React.CSSProperties
const noDragRegion = { WebkitAppRegion: 'no-drag' } as React.CSSProperties
// Rowboat sail glyph (black on transparent, same asset as the tray icon) —
// rendered on a white chip inside the "Take notes" pill.
const SAIL_ICON =
@ -63,23 +60,23 @@ export function MeetingDetectedPopup() {
// In the browser preview, reproduce the real popup window's exact size
// (448×96) on a desktop-ish backdrop; in Electron the window IS that size.
// No drag region: draggable areas swallow mouse events, which would keep
// the hover-revealed × from ever showing while over the card body.
const popup = (
<div
className={`group ${isPreview ? 'relative' : 'h-screen w-screen relative bg-transparent'}`}
style={isPreview ? { width: 400, height: 84 } : dragRegion}
style={isPreview ? { width: 400, height: 76 } : undefined}
>
{/* Close — floats over the card's top-left corner, revealed on hover */}
<button
onClick={() => act('dismiss')}
className="absolute left-0.5 top-0.5 z-10 flex size-6 items-center justify-center rounded-full bg-neutral-800 border border-neutral-600 text-neutral-200 shadow-md hover:bg-neutral-700 opacity-0 group-hover:opacity-100 transition-opacity"
style={noDragRegion}
aria-label="Dismiss"
className="absolute left-0.5 top-0.5 z-10 flex size-6 items-center justify-center rounded-full bg-neutral-800 border border-neutral-600 text-neutral-200 shadow-md hover:bg-neutral-700 opacity-0 group-hover:opacity-100 transition-opacity" aria-label="Dismiss"
>
<X className="size-3.5" strokeWidth={2.5} />
</button>
{/* Card */}
<div className="absolute left-3 right-3 top-3 h-14 rounded-2xl bg-[#1d1d1d] shadow-[0_8px_28px_rgba(0,0,0,0.55)] flex items-center gap-3 pl-4 pr-2.5">
<div className="absolute left-3 right-3 top-3 h-12 rounded-2xl bg-[#1d1d1d] shadow-[0_8px_28px_rgba(0,0,0,0.55)] flex items-center gap-3 pl-4 pr-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white leading-tight truncate">
{payload?.title ?? 'Meeting detected'}
@ -90,8 +87,7 @@ export function MeetingDetectedPopup() {
</div>
<button
onClick={() => act('take-notes')}
className="flex h-9.5 shrink-0 items-center gap-2 rounded-xl bg-neutral-800/90 border border-neutral-700 pl-2 pr-3 hover:bg-neutral-700 transition-colors"
style={noDragRegion}
className="flex h-8.5 shrink-0 items-center gap-1.5 rounded-lg bg-neutral-800/90 border border-neutral-700 pl-1.5 pr-2.5 hover:bg-neutral-700 transition-colors"
>
<span className="flex size-6 items-center justify-center">
<img src={SAIL_ICON} alt="" className="size-4.5 invert" />