diff --git a/CLAUDE.md b/CLAUDE.md index 6bfe2ed..287f082 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,10 @@ This file holds the project facts that are easy to get wrong. bootstrap's first `savePluginSettings` (test-checked), or every update would look like a new install. - Help (the side-nav ?) opens the in-app FAQ (`help-dialog.tsx`), with GitHub Issues as its last link. Its answers take setting names as placeholders filled from the settings' own labels: rename a setting and the FAQ follows. +- Progress bars hatch the usage spent since the open panel last showed them (`use-unseen-usage.ts`): it blinks + 3.5 s, then the fill grows over it. Seen values are in memory, per plugin + line and in used terms (switching + used/left isn't spending). "Open" is asked from the OS (`isVisible`): the hidden panel's webview reports itself + visible before its first show. - Ring logos are sized by `measureLogoExtent` (how far the logo's pixels reach) so square logos stay inside the ring and round ones grow. - Themes: `dark` (default, pure black: classes `.dark.oled`) | `light`. `useSettingsTheme` also sets the diff --git a/src/components/provider-card.test.tsx b/src/components/provider-card.test.tsx index 89c2c7e..c13d89b 100644 --- a/src/components/provider-card.test.tsx +++ b/src/components/provider-card.test.tsx @@ -203,6 +203,28 @@ describe("ProviderCard", () => { expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "58") }) + it("hatches usage spent since the bar was last seen, per plugin and line, in both display modes", () => { + const card = (used: number, displayMode: "used" | "left" = "used") => ( + + ) + const hatch = () => document.querySelector('[data-slot="progress-unseen"]') + const { rerender } = render(card(10)) + expect(hatch()).toBeNull() + + rerender(card(25)) + expect(hatch()?.style.left).toBe("10%") + expect(hatch()?.style.width).toBe("15%") + + rerender(card(25, "left")) + expect(hatch()?.style.left).toBe("75%") + expect(hatch()?.style.width).toBe("15%") + }) + it("uses elapsed-time marker position in displayMode=left", () => { vi.useFakeTimers() const now = new Date("2026-02-02T18:00:00.000Z") diff --git a/src/components/provider-card.tsx b/src/components/provider-card.tsx index 8155511..1192218 100644 --- a/src/components/provider-card.tsx +++ b/src/components/provider-card.tsx @@ -12,6 +12,7 @@ import { UsageSparkline } from "@/components/usage-sparkline" import { PluginError } from "@/components/plugin-error" import { ProviderStatusBadge } from "@/components/provider-status-badge" import { useNowTicker } from "@/hooks/use-now-ticker" +import { useUnseenUsage } from "@/hooks/use-unseen-usage" import { REFRESH_COOLDOWN_MS, type DisplayMode, type ResetTimerDisplayMode, type TimeFormatMode } from "@/lib/settings" import type { ManifestLine, MetricLine, PluginLink } from "@/lib/plugin-types" import { groupLinesByType } from "@/lib/group-lines-by-type" @@ -327,6 +328,7 @@ export function ProviderCard({ onResetTimerDisplayModeToggle={onResetTimerDisplayModeToggle} now={now} refreshing={isRefreshingWithData} + pluginId={pluginId} /> ))} @@ -343,6 +345,7 @@ export function ProviderCard({ onResetTimerDisplayModeToggle={onResetTimerDisplayModeToggle} now={now} refreshing={isRefreshingWithData} + pluginId={pluginId} /> ))} @@ -366,6 +369,7 @@ function MetricLineRenderer({ onResetTimerDisplayModeToggle, now, refreshing, + pluginId, }: { line: MetricLine usageUrl?: string @@ -375,7 +379,14 @@ function MetricLineRenderer({ onResetTimerDisplayModeToggle?: () => void now: number refreshing?: boolean + pluginId?: string }) { + const isProgress = line.type === "progress" + const unseen = useUnseenUsage( + isProgress && pluginId ? `${pluginId}|${line.label}` : null, + isProgress ? getDisplayedUsage(line.used, line.limit, "used").fraction * 100 : 0 + ) + if (line.type === "text" && line.resetCredits) { return } @@ -439,6 +450,7 @@ function MetricLineRenderer({ const hasTimeMarkerContext = hasPaceContext && periodDurationMs! > 0 const { amount: shownAmount, fraction: shownFraction } = getDisplayedUsage(line.used, line.limit, displayMode) const percent = Math.round(shownFraction * 10000) / 100 + const shownUnseen = unseen && displayMode === "left" ? { ...unseen, from: 100 - unseen.from } : unseen const amountText = line.format.kind === "percent" ? `${Math.round(shownAmount)}%` @@ -529,6 +541,7 @@ function MetricLineRenderer({ value={percent} indicatorColor={line.color} markerValue={paceMarkerValue} + unseen={shownUnseen} refreshing={refreshing} /> @@ -543,6 +556,7 @@ function MetricLineRenderer({ value={percent} indicatorColor={line.color} markerValue={paceMarkerValue} + unseen={shownUnseen} refreshing={refreshing} /> )} diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx index 534f523..5067113 100644 --- a/src/components/ui/progress.tsx +++ b/src/components/ui/progress.tsx @@ -7,11 +7,18 @@ interface ProgressProps extends React.HTMLAttributes { indicatorColor?: string markerValue?: number refreshing?: boolean + /** Usage spent unseen, `from` in this bar's own terms (100 - used when it shows what's left). */ + unseen?: { from: number; settling: boolean } | null } const Progress = React.forwardRef( - ({ className, value = 0, indicatorColor, markerValue, refreshing, ...props }, ref) => { + ({ className, value = 0, indicatorColor, markerValue, refreshing, unseen, ...props }, ref) => { const clamped = Math.min(100, Math.max(0, value)) + const unseenFrom = unseen ? Math.min(100, Math.max(0, unseen.from)) : clamped + const hatchStart = Math.min(unseenFrom, clamped) + const hatchWidth = Math.abs(unseenFrom - clamped) + // The fill waits at the start of the spent part while it blinks, then grows over it. + const fillWidth = unseen && !unseen.settling ? hatchStart : clamped const clampedMarker = typeof markerValue === "number" && Number.isFinite(markerValue) ? Math.min(100, Math.max(0, markerValue)) @@ -46,9 +53,20 @@ const Progress = React.forwardRef( {...props} >
+ {unseen && hatchWidth > 0 && ( +