From cac9798d44083b4b9788c2669e316c04107fbddf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 16:53:47 +0000 Subject: [PATCH 1/3] fix(web,mobile): Limits bars show the even-pace mark as a tick in a gap The even-pace mark was a thin line that disappeared on foreground-coloured fills and spilled past the bar at 0% and 100%. Every Limits bar (web and mobile rows, web and mobile pooled segments) now cuts a 6px transparent gap through track and fill with an opaque 2px tick inside it, clamped 3px from each end. Unknown timing still draws no mark. Rows reuse #16937's rendering. Co-Authored-By: Claude Opus 5.5 --- .../src/features/usage/UsageLimitsPooled.tsx | 73 +++++++++++++---- .../src/features/usage/UsageLimitsSection.tsx | 74 ++++++++++++----- .../usage/UsageLimits.browser.test.tsx | 80 +++++++++++++++++++ apps/web/src/components/usage/UsageLimits.tsx | 45 ++++++++--- .../components/usage/UsageLimitsPooled.tsx | 37 +++++---- 5 files changed, 248 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/usage/UsageLimits.browser.test.tsx diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 9013a3dfcc9fb..010c902364c4b 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -41,6 +41,53 @@ function accountName(account: LimitAccount) { return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; } +// Same gap-and-tick as the Limits rows: two clipped copies of the segment leave a see-through gap. +function PaceGap({ + timeLeft, + children, +}: { + readonly timeLeft: number | null; + readonly children: ReactNode; +}) { + const [width, setWidth] = useState(0); + const tickX = + timeLeft === null || width < 6 + ? null + : Math.min(Math.max((width * timeLeft) / 100, 3), width - 3); + return ( + setWidth(event.nativeEvent.layout.width)} + > + {tickX === null + ? children + : ( + [ + [0, tickX - 3], + [tickX + 3, width], + ] as const + ).map(([start, end]) => ( + + + {children} + + + ))} + {tickX !== null ? ( + + ) : null} + + ); +} + function AccountSegment({ remaining, color, @@ -155,22 +202,18 @@ function PoolWindowCard({ accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}`} accessibilityHint="Show account details" onPress={() => openAccount(account)} - className="h-7 min-w-0 flex-1 rounded-md bg-subtle" + className="h-7 min-w-0 flex-1 rounded-md" > - - - - {timeLeft !== null ? ( - - ) : null} + + + + + + 0 ? { width: barWidth } : undefined} + > + 30 && props.color ? { backgroundColor: props.color } : null, + ]} + /> + + + ); return ( @@ -57,27 +83,35 @@ function WindowRow(props: { {remaining}% left - - - 30 && props.color ? { backgroundColor: props.color } : null, - ]} - /> - - - {timeLeft !== null ? ( + setBarWidth(event.nativeEvent.layout.width)} + > + {tickX === null ? ( + bar + ) : ( + + {/* Clip the same full-width bar so neither fill proportions nor corner radii change. */} + {( + [ + [0, tickX - 3], + [tickX + 3, barWidth], + ] as const + ).map(([start, end]) => ( + + {bar} + + ))} + + )} + {tickX !== null ? ( ) : null} diff --git a/apps/web/src/components/usage/UsageLimits.browser.test.tsx b/apps/web/src/components/usage/UsageLimits.browser.test.tsx new file mode 100644 index 0000000000000..5ed36af123c03 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimits.browser.test.tsx @@ -0,0 +1,80 @@ +import { ProviderDriverKind, type ServerProviderUsageWindow } from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import "~/index.css"; + +vi.mock("../../state/session", () => ({ + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { consumeResetCredit: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" })); + +import { LimitWindows } from "./UsageLimits"; + +const codex = ProviderDriverKind.make("codex"); +const now = Date.parse("2026-09-03T12:00:00Z"); + +async function render(ui: ReactNode) { + const container = document.createElement("div"); + container.style.width = "640px"; + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render(ui)); + return { + [Symbol.asyncDispose]: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; +} + +const sessionWindow = (minutesUntilReset: number | null): ServerProviderUsageWindow => ({ + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 40, + ...(minutesUntilReset === null + ? {} + : { + windowDurationMins: 300, + resetsAt: new Date(now + minutesUntilReset * 60_000).toISOString(), + }), +}); + +const tick = () => { + const bar = page.getByRole("img", { name: /^Session:/ }).element(); + const mark = bar.querySelector(":scope > span[aria-hidden]"); + return { bar: bar.getBoundingClientRect(), mark: mark?.getBoundingClientRect() }; +}; + +it("places the even-pace tick at the share of the window left", async () => { + await using _screen = await render( + , + ); + const { bar, mark } = tick(); + expect(mark).toBeDefined(); + expect(mark!.left + mark!.width / 2 - bar.left).toBeCloseTo(bar.width * 0.3, 0); +}); + +it("keeps the tick inside the bar at both ends of the window", async () => { + for (const minutesUntilReset of [0, 300]) { + await using _screen = await render( + , + ); + const { bar, mark } = tick(); + expect(mark!.left).toBeGreaterThanOrEqual(bar.left); + expect(mark!.right).toBeLessThanOrEqual(bar.right); + } +}); + +it("shows no tick when the window timing is unknown", async () => { + await using _screen = await render( + , + ); + expect(tick().mark).toBeUndefined(); +}); diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 0ac9cf2362924..44689144233c4 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -73,6 +73,27 @@ export function PaceIcon({ pace }: { readonly pace: LimitPace }) { ); } +// The tick sits in a transparent gap cut from the bar, clamped so it stays inside at 0% and 100%. +// The gap is a clip-path slot rather than a mask: a mask composites its layer and re-antialiases +// the bar's rounded ends, and the polygon reaches 1px past the box so only the slot edges clip. +export function paceMarkStyles(timeLeft: number | null) { + if (timeLeft === null) return null; + const at = `clamp(3px, ${timeLeft}%, calc(100% - 3px))`; + const [left, right] = [`calc(${at} - 3px)`, `calc(${at} + 3px)`]; + const [near, far] = ["-1px", "calc(100% + 1px)"]; + return { + tick: { left: at }, + gap: { + clipPath: `polygon(${near} ${near}, ${left} ${near}, ${left} ${far}, ${right} ${far}, ${right} ${near}, ${far} ${near}, ${far} ${far}, ${near} ${far})`, + }, + // For a track painted on an element that also draws focus rings, which a clip would cut. + gappedTrack: { + backgroundColor: "transparent", + backgroundImage: `linear-gradient(to right, var(--color-muted) ${left}, transparent ${left}, transparent ${right}, var(--color-muted) ${right})`, + }, + }; +} + function WindowBar({ color, window, @@ -85,6 +106,7 @@ function WindowBar({ const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); const remaining = remainingPercent(window); const timeLeft = paceRemainingPercent(window, now); + const paceMark = paceMarkStyles(timeLeft); const resetsIn = formatResetsIn(window, now); const resetsAt = window.resetsAt ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) @@ -105,18 +127,21 @@ function WindowBar({ /> } > -
- {remaining > 0 ? ( -
- ) : null} - {timeLeft !== null ? ( + {/* Clip both layers so the gap exposes even a translucent composer surface. */} +
+
+ {remaining > 0 ? ( +
+ ) : null} +
+ {paceMark ? ( ) : null} diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 8fc4b775b850c..813d1bf53f398 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -42,6 +42,7 @@ import { PaceIcon, ResetCreditDialog, barColor, + paceMarkStyles, resetCreditsSummary, useResetCredit, } from "./UsageLimits"; @@ -342,6 +343,7 @@ function SegmentTriggers({ const payload: SegmentPayload = { account, window, reset, redeem }; const remaining = remainingPercent(window); const timeLeft = paceRemainingPercent(window, now); + const paceMark = paceMarkStyles(timeLeft); const resetsIn = formatResetsIn(window, now); const credits = account.limits.resetCredits?.availableCount ?? 0; return ( @@ -353,34 +355,37 @@ function SegmentTriggers({ render={