From e5717fb861a4303b380d7c3d43c7b133189b24d9 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:09:03 -0600 Subject: [PATCH 1/2] feat(gui): make usage chart details keyboard and touch accessible Extracted and adapted from fork commit 94a63b4ce086662978a17d49207862af64f97f1f. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- assets/pr-screenshots/usage-chart-review.png | Bin 0 -> 45642 bytes .../src/content/docs/guides/web-dashboard.md | 4 + gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Usage.tsx | 185 +++++++++++++---- gui/src/styles.css | 13 +- gui/tests/usage-chart-interactions.test.tsx | 187 ++++++++++++++++++ gui/tests/usage-custom-range.test.tsx | 10 +- 15 files changed, 370 insertions(+), 47 deletions(-) create mode 100644 assets/pr-screenshots/usage-chart-review.png create mode 100644 gui/tests/usage-chart-interactions.test.tsx diff --git a/assets/pr-screenshots/usage-chart-review.png b/assets/pr-screenshots/usage-chart-review.png new file mode 100644 index 0000000000000000000000000000000000000000..3f2582929a64bfbdc40a6a72f700c7183c4fe68d GIT binary patch literal 45642 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 E073Ze5dZ)H literal 0 HcmV?d00001 diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index ef987bae78..9c62d66230 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -352,3 +352,7 @@ is gated correctly without manual classification. Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. + +### Usage chart keyboard and touch controls + +Usage heatmap days have one Tab entry point. Use Up/Down for adjacent days and Left/Right for adjacent weeks. Weekly bars expose the same day details on keyboard focus, pointer hover, or touch. Day labels include the date, request count, and token count; tooltip overlays stay within the viewport. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 70b62515d9..dd5ca2633c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1822,6 +1822,8 @@ export const de: Record = { "usage.dayWed": "Mi", "usage.dayFri": "Fr", "usage.heatmap.tooltipTokens": "{tokens} Tokens", + "usage.chart.dayDetail": "{date}: {requests} Anfragen, {tokens} Token", + "usage.heatmap.keyboardLabel": "Mit Hoch und Runter tageweise, mit Links und Rechts wochenweise navigieren.", "usage.heatmap.tooltipRequests": "{requests} Anfragen", "nav.storage": "Speicher", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index de2b8906f8..6723d74b81 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1004,6 +1004,8 @@ export const en = { "usage.dayWed": "Wed", "usage.dayFri": "Fri", "usage.heatmap.tooltipTokens": "{tokens} tokens", + "usage.chart.dayDetail": "{date}: {requests} requests, {tokens} tokens", + "usage.heatmap.keyboardLabel": "Use Up and Down to move by day; Left and Right to move by week.", "usage.heatmap.tooltipRequests": "{requests} requests", "nav.storage": "Storage", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5b9a38a588..43d956906f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -981,6 +981,8 @@ export const fr: Record = { "usage.dayWed": "Mer", "usage.dayFri": "Ven", "usage.heatmap.tooltipTokens": "{tokens} jetons", + "usage.chart.dayDetail": "{date} : {requests} requêtes, {tokens} jetons", + "usage.heatmap.keyboardLabel": "Utilisez Haut et Bas pour changer de jour ; Gauche et Droite pour changer de semaine.", "usage.heatmap.tooltipRequests": "{requests} requêtes", "nav.storage": "Stockage", "storage.title": "Stockage", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index b21d4d59b7..d60148bb60 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -917,6 +917,8 @@ export const ja: Record = { "usage.dayWed": "水", "usage.dayFri": "金", "usage.heatmap.tooltipTokens": "{tokens} トークン", + "usage.chart.dayDetail": "{date}: {requests} リクエスト、{tokens} トークン", + "usage.heatmap.keyboardLabel": "上下キーで日単位、左右キーで週単位に移動します。", "usage.heatmap.tooltipRequests": "{requests} リクエスト", "nav.storage": "ストレージ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a6e76eb7b1..be26daf40e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1861,6 +1861,8 @@ export const ko: Record = { "usage.dayWed": "수", "usage.dayFri": "금", "usage.heatmap.tooltipTokens": "{tokens} 토큰", + "usage.chart.dayDetail": "{date}: 요청 {requests}개, 토큰 {tokens}개", + "usage.heatmap.keyboardLabel": "위아래 화살표로 하루씩, 좌우 화살표로 일주일씩 이동합니다.", "usage.heatmap.tooltipRequests": "{requests} 요청", "nav.storage": "저장소", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c28f4464c3..9691f8a49f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -972,6 +972,8 @@ export const ru: Record = { "usage.dayWed": "Ср", "usage.dayFri": "Пт", "usage.heatmap.tooltipTokens": "{tokens} токенов", + "usage.chart.dayDetail": "{date}: {requests} запросов, {tokens} токенов", + "usage.heatmap.keyboardLabel": "Стрелки вверх и вниз перемещают по дням, влево и вправо — по неделям.", "usage.heatmap.tooltipRequests": "{requests} запросов", "nav.storage": "Хранилище", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c6671c3e28..95336fa32f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -991,6 +991,8 @@ export const tr: Record = { "usage.dayWed": "Çar", "usage.dayFri": "Cum", "usage.heatmap.tooltipTokens": "{tokens} jeton", + "usage.chart.dayDetail": "{date}: {requests} istek, {tokens} jeton", + "usage.heatmap.keyboardLabel": "Gün gün ilerlemek için Yukarı ve Aşağı, hafta hafta ilerlemek için Sol ve Sağ tuşlarını kullanın.", "usage.heatmap.tooltipRequests": "{requests} istek", "nav.storage": "Depolama", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9e154fa1b2..19f1e50ce2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -791,6 +791,8 @@ export const zhTW: Record = { "usage.dayWed": "三", "usage.dayFri": "五", "usage.heatmap.tooltipTokens": "{tokens} Token", + "usage.chart.dayDetail": "{date}:{requests} 個請求,{tokens} 個 Token", + "usage.heatmap.keyboardLabel": "使用上下方向鍵按天移動,使用左右方向鍵按週移動。", "usage.heatmap.tooltipRequests": "{requests} 請求", "nav.storage": "儲存", "storage.title": "儲存", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 018f6e251b..e081d027f3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1842,6 +1842,8 @@ export const zh: Record = { "usage.dayWed": "三", "usage.dayFri": "五", "usage.heatmap.tooltipTokens": "{tokens} 令牌", + "usage.chart.dayDetail": "{date}:{requests} 个请求,{tokens} 个 Token", + "usage.heatmap.keyboardLabel": "使用上下方向键按天移动,使用左右方向键按周移动。", "usage.heatmap.tooltipRequests": "{requests} 请求", "nav.storage": "存储", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 900ab6eac0..1b655f8cc9 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import type { UsageReadMetadata } from "../usage-summary-resource"; import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; @@ -141,6 +142,52 @@ function lastSevenDays(days: UsageDay[]): UsageDay[] { return out; } +function formatCalendarDate(date: string, locale: Locale): string { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(`${date}T12:00:00`)); +} + +function chartTipPosition(rect: DOMRect): CSSProperties { + const gutter = 8; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const maxWidth = Math.min(240, Math.max(0, viewportWidth - gutter * 2)); + const left = Math.max(gutter, Math.min(rect.left + rect.width / 2 - maxWidth / 2, viewportWidth - gutter - maxWidth)); + const above = rect.top - gutter > viewportHeight - rect.bottom - gutter; + const vertical = above + ? (() => { + const bottom = Math.max(gutter, Math.min(viewportHeight - gutter, viewportHeight - rect.top + gutter)); + return { bottom, maxHeight: Math.max(0, viewportHeight - bottom - gutter) }; + })() + : (() => { + const top = Math.max(gutter, Math.min(viewportHeight - gutter, rect.bottom + gutter)); + return { top, maxHeight: Math.max(0, viewportHeight - top - gutter) }; + })(); + return { left, maxWidth, ...vertical }; +} + +function UsageChartOverlay({ + anchor, + className, + children, +}: { + anchor: DOMRect; + className: string; + children: ReactNode; +}) { + return createPortal( +
{children}
, + document.body, + ); +} + +function dayDetail(day: Pick, locale: Locale, t: TFn): string { + return t("usage.chart.dayDetail", { + date: formatCalendarDate(day.date, locale), + requests: day.requests, + tokens: formatTokens(day.totalTokens, locale), + }); +} + function quantileBuckets(values: number[]): number[] { const positive = values.filter(v => v > 0).sort((a, b) => a - b); if (positive.length === 0) return [0, 0, 0, 0]; @@ -360,20 +407,33 @@ function UsageSummaryCards({ } function WeekDayBars({ weekBars, locale, t }: { weekBars: UsageDay[]; locale: Locale; t: TFn }) { - const [hoverDay, setHoverDay] = useState(null); + const [active, setActive] = useState<{ date: string; anchor: DOMRect } | null>(null); const max = Math.max(1, ...weekBars.map(day => day.totalTokens)); + const activeDay = weekBars.find(day => day.date === active?.date); + const show = (day: UsageDay, element: HTMLElement) => { + setActive({ date: day.date, anchor: element.getBoundingClientRect() }); + }; return ( -
+
{weekBars.map(day => { const percentage = Math.round((day.totalTokens / max) * 100); - const label = day.date.slice(5); + const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(new Date(`${day.date}T12:00:00`)); return ( -
setHoverDay(day.date)} - onMouseLeave={() => setHoverDay(current => (current === day.date ? null : current))} + aria-label={dayDetail(day, locale, t)} + onFocus={event => show(day, event.currentTarget)} + onBlur={() => setActive(current => current?.date === day.date ? null : current)} + onPointerEnter={event => show(day, event.currentTarget)} + onPointerDown={event => show(day, event.currentTarget)} + onPointerLeave={event => { + if (event.pointerType !== "touch" && document.activeElement !== event.currentTarget) { + setActive(current => current?.date === day.date ? null : current); + } + }} >
- {hoverDay === day.date && day.totalTokens > 0 && ( -
-
{day.date}
- {day.models.slice(0, 8).map(model => ( -
- - {modelLabel(model.model)} - {formatTokens(model.totalTokens, locale)} -
- ))} -
- )} {formatTokens(day.totalTokens, locale)} {label} -
+ ); })} + {active && activeDay && ( + +
{formatCalendarDate(activeDay.date, locale)}
+
+ {t("usage.heatmap.tooltipRequests", { requests: activeDay.requests })} + {t("usage.heatmap.tooltipTokens", { tokens: formatTokens(activeDay.totalTokens, locale) })} +
+ {activeDay.models.slice(0, 8).map(model => ( +
+ + {modelLabel(model.model)} + {formatTokens(model.totalTokens, locale)} +
+ ))} +
+ )}
); } @@ -427,7 +491,34 @@ function UsageHeatmapPanel({ t: TFn; }) { const heatmapRef = useRef(null); - const [hoverCell, setHoverCell] = useState<{ weekIndex: number; dayIndex: number; x: number; y: number } | null>(null); + const cells = useMemo(() => heatmap.weeks.flat().filter(cell => cell.date), [heatmap]); + const [selectedDate, setSelectedDate] = useState(() => cells.at(-1)?.date ?? ""); + const [tip, setTip] = useState<{ date: string; anchor: DOMRect } | null>(null); + const hintId = useId(); + const rovingDate = cells.some(cell => cell.date === selectedDate) ? selectedDate : (cells.at(-1)?.date ?? ""); + + const selectCell = (cell: HeatmapCell, element: HTMLElement) => { + setSelectedDate(cell.date); + setTip({ date: cell.date, anchor: element.getBoundingClientRect() }); + }; + + const onCellKeyDown = (event: React.KeyboardEvent, cell: HeatmapCell) => { + const index = cells.findIndex(candidate => candidate.date === cell.date); + const offset = event.key === "ArrowUp" ? -1 + : event.key === "ArrowDown" ? 1 + : event.key === "ArrowLeft" ? -7 + : event.key === "ArrowRight" ? 7 + : 0; + if (!offset || index < 0) return; + event.preventDefault(); + const next = cells[Math.max(0, Math.min(cells.length - 1, index + offset))]!; + setSelectedDate(next.date); + const element = heatmapRef.current?.querySelector(`[data-date="${next.date}"]`); + if (element) { + element.focus(); + setTip({ date: next.date, anchor: element.getBoundingClientRect() }); + } + }; useEffect(() => { const element = heatmapRef.current; @@ -445,7 +536,7 @@ function UsageHeatmapPanel({ {range === "7d" ? ( ) : ( -
+
{heatmap.months.map(month => ( @@ -456,36 +547,54 @@ function UsageHeatmapPanel({
{t("usage.dayMon")}{t("usage.dayWed")}{t("usage.dayFri")}
-
+
{heatmap.weeks.map((week, weekIndex) => (
- {week.map((cell, dayIndex) => ( -
cell.date ? ( +
))}
- {hoverCell && (() => { - const cell = heatmap.weeks[hoverCell.weekIndex]?.[hoverCell.dayIndex]; + {t("usage.heatmap.keyboardLabel")} + + {cells.find(cell => cell.date === rovingDate) ? dayDetail(cells.find(cell => cell.date === rovingDate)!, locale, t) : ""} + + {tip && (() => { + const cell = cells.find(candidate => candidate.date === tip.date); if (!cell?.date) return null; return ( -
-
{cell.date}
+ +
{formatCalendarDate(cell.date, locale)}
{t("usage.heatmap.tooltipTokens", { tokens: formatTokens(cell.totalTokens, locale) })}
{t("usage.heatmap.tooltipRequests", { requests: cell.requests })}
-
+ ); })()}
diff --git a/gui/src/styles.css b/gui/src/styles.css index 352d1c53a2..065079e7f4 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2592,7 +2592,7 @@ button.prov-account-row.active { cursor: default; } .heatmap-cell-4 { background: var(--green); } .heatmap-legend { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-label); align-self: flex-end; position: sticky; right: 0; } .heatmap-legend .heatmap-cell { width: 10px; height: 10px; } -.heatmap-tip { position: fixed; z-index: 10; transform: translate(-50%, -100%) translateY(-8px); pointer-events: none; +.heatmap-tip { position: fixed; z-index: var(--z-popover); pointer-events: none; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 10px; box-shadow: var(--shadow-sm); white-space: nowrap; font-size: var(--text-label); } .heatmap-tip-date { font-weight: var(--weight-semibold); color: var(--text); margin-bottom: 2px; } @@ -2603,7 +2603,7 @@ button.prov-account-row.active { cursor: default; } /* 7d view: per-day request bar chart (replaces the year heatmap on the 7d toggle). */ .daybars { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; align-items: end; height: 180px; padding-top: 8px; } -.daybar { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; } +.daybar { appearance: none; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; } .daybar-track { width: 100%; max-width: 48px; flex: 1; display: flex; align-items: flex-end; background: var(--border); border-radius: var(--radius-xs); overflow: hidden; } /* Full-height stack scaled on Y — avoids layout thrash from animating height. */ .daybar-stack { @@ -2622,9 +2622,9 @@ button.prov-account-row.active { cursor: default; } .daybar-count { font-size: var(--text-label); font-weight: var(--weight-semibold); color: var(--text); } .daybar-label { font-size: var(--text-caption); white-space: nowrap; } .daybar:hover .daybar-track { outline: 1px solid var(--border); } -.daybar-tip { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); z-index: 5; +.daybar-tip { position: fixed; z-index: var(--z-popover); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; - min-width: 160px; box-shadow: var(--shadow-sm); pointer-events: none; } + min-width: min(160px, calc(100vw - 16px)); box-shadow: var(--shadow-sm); pointer-events: none; } .daybar-tip-date { font-size: var(--text-label); font-weight: var(--weight-semibold); color: var(--text); margin-bottom: 6px; white-space: nowrap; } .daybar-tip-row { display: flex; align-items: center; gap: 8px; font-size: var(--text-label); line-height: var(--leading-relaxed); } .daybar-tip-swatch { width: 10px; height: 10px; border-radius: var(--radius-2xs); flex-shrink: 0; } @@ -2956,3 +2956,8 @@ button.prov-account-row.active { cursor: default; } } .claude-effort-badge.on { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } .claude-effort-badge.off { background: color-mix(in srgb, var(--muted) 12%, transparent); color: var(--muted); } + +/* Usage chart controls retain chart styling while exposing keyboard focus. */ +.heatmap-grid button.heatmap-cell { appearance: none; border: 0; padding: 0; cursor: pointer; } +.heatmap-grid button.heatmap-cell:focus-visible, .daybar:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.chart-overlay { box-sizing: border-box; overflow: hidden; white-space: normal; overflow-wrap: anywhere; } diff --git a/gui/tests/usage-chart-interactions.test.tsx b/gui/tests/usage-chart-interactions.test.tsx new file mode 100644 index 0000000000..02f7392fe1 --- /dev/null +++ b/gui/tests/usage-chart-interactions.test.tsx @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import Usage from "../src/pages/Usage"; + +const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", "fetch", + "ResizeObserver", "IS_REACT_ACT_ENVIRONMENT", +] as const; + +let previous: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let win: Window; +let root: Root | null = null; + +function isoDay(offset = 0): string { + const day = new Date(); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() + offset); + return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, "0")}-${String(day.getDate()).padStart(2, "0")}`; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])) as typeof previous; + win = new Window({ url: "http://localhost/#dashboard" }); + class TestResizeObserver { + observe() {} + disconnect() {} + } + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + ResizeObserver: { configurable: true, value: TestResizeObserver }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + clearClientResourceStoresForTests(); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + clearClientResourceStoresForTests(); + win.close(); + for (const key of globals) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`timed out: ${document.body.innerHTML}`); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 10)); }); + } +} + +async function mount(node: React.ReactNode): Promise { + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + root = createRoot(container); + await act(async () => { root!.render({node}); }); + return container; +} + +function usagePayload(models: Array<{ provider: string; model: string; requests: number; totalTokens: number }> = []) { + const yesterday = isoDay(-1); + const today = isoDay(); + return { + range: "all", + surface: "all", + since: null, + generatedAt: Date.now(), + summary: { + requests: 7, measuredRequests: 7, reportedRequests: 7, unreportedRequests: 0, + unsupportedRequests: 0, estimatedRequests: 0, inputTokens: 70, outputTokens: 30, + cachedInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 100, coverageRatio: 1, + }, + days: [ + { date: yesterday, requests: 2, measuredRequests: 2, reportedRequests: 2, totalTokens: 25, models: [] }, + { date: today, requests: 5, measuredRequests: 5, reportedRequests: 5, totalTokens: 75, models }, + ], + models: [], providers: [], historyTruncated: false, truncatedPrefixBytes: 0, + entriesTruncated: false, entriesDropped: 0, + }; +} + +test("Usage heatmap exposes one roving entry and day/week keyboard movement", async () => { + globalThis.fetch = (async () => Response.json(usagePayload())) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + + expect(container.querySelector(".heatmap-grid")?.getAttribute("role")).toBe("group"); + expect(container.querySelectorAll(".heatmap-grid [role='gridcell']")).toHaveLength(0); + expect(container.querySelectorAll(".heatmap-grid [tabindex='0']")).toHaveLength(1); + const initial = container.querySelector(".heatmap-grid [tabindex='0']")!; + expect(initial.tagName).toBe("BUTTON"); + const initialDate = initial.dataset.date!; + await act(async () => { initial.focus(); }); + const heatmapTip = document.querySelector(".heatmap-tip"); + expect(heatmapTip?.textContent).toContain("requests"); + expect(heatmapTip?.parentNode === document.body).toBe(true); + expect(container.contains(heatmapTip)).toBe(false); + + await act(async () => { + initial.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true })); + await new Promise(resolve => win.setTimeout(resolve, 0)); + }); + const previousDay = container.querySelector(".heatmap-grid [tabindex='0']")!; + expect(previousDay.dataset.date).not.toBe(initialDate); + expect(document.activeElement).toBe(previousDay); + + await act(async () => { + previousDay.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true })); + await new Promise(resolve => win.setTimeout(resolve, 0)); + }); + expect(container.querySelector(".heatmap-grid [tabindex='0']")?.dataset.date).not.toBe(previousDay.dataset.date); +}); + +test("seven-day bars expose the same detail on focus and touch", async () => { + globalThis.fetch = (async () => Response.json(usagePayload())) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + const sevenDay = Array.from(container.querySelectorAll(".usage-segmented-btn")) + .find(button => button.textContent === "7d")!; + await act(async () => { sevenDay.click(); }); + await waitFor(() => container.querySelectorAll(".daybar").length === 7); + + const bars = container.querySelectorAll(".daybar"); + expect(bars).toHaveLength(7); + expect(Array.from(bars).every(bar => bar.tabIndex === 0)).toBe(true); + + const today = bars[6]!; + await act(async () => { today.focus(); }); + const focused = document.querySelector(".daybar-tip")?.textContent; + expect(focused).toContain("5 requests"); + expect(focused).toContain("75 tokens"); + + await act(async () => { today.blur(); }); + await act(async () => { + today.dispatchEvent(new win.PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" })); + }); + expect(document.querySelector(".daybar-tip")?.textContent).toBe(focused); +}); + +test("Usage tooltip portals stay inside viewport gutters at the lower-right edge", async () => { + Object.defineProperties(win, { + innerWidth: { configurable: true, value: 320 }, + innerHeight: { configurable: true, value: 240 }, + }); + const models = Array.from({ length: 8 }, (_, index) => ({ + provider: "openai", + model: `model-${index}`, + requests: 1, + totalTokens: index + 1, + })); + globalThis.fetch = (async () => Response.json(usagePayload(models))) as typeof fetch; + const container = await mount(); + await waitFor(() => container.querySelector(".heatmap-grid") !== null); + const sevenDay = Array.from(container.querySelectorAll(".usage-segmented-btn")) + .find(button => button.textContent === "7d")!; + await act(async () => { sevenDay.click(); }); + await waitFor(() => container.querySelectorAll(".daybar").length === 7); + + const today = container.querySelectorAll(".daybar")[6]!; + Object.defineProperty(today, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 220, right: 320, bottom: 240, left: 300, width: 20, height: 20, x: 300, y: 220, toJSON() {} }), + }); + await act(async () => { today.focus(); }); + + const tooltip = document.querySelector(".daybar-tip")!; + expect(tooltip.parentNode === document.body).toBe(true); + expect(container.contains(tooltip)).toBe(false); + expect(tooltip.querySelectorAll(".daybar-tip-row")).toHaveLength(9); + expect(parseFloat(tooltip.style.left)).toBeGreaterThanOrEqual(8); + expect(parseFloat(tooltip.style.left) + parseFloat(tooltip.style.maxWidth)).toBeLessThanOrEqual(312); + expect(parseFloat(tooltip.style.bottom)).toBeGreaterThanOrEqual(8); + expect(parseFloat(tooltip.style.maxHeight) + parseFloat(tooltip.style.bottom)).toBeLessThanOrEqual(232); +}); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 78a3e9b165..3ef6373ccf 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -221,9 +221,9 @@ test("America/Santiago midnight DST retains final-day activity and tooltip", asy await act(async () => gate.resolve(Response.json(data))); const active = container.querySelector('.heatmap-grid .heatmap-cell:not(.heatmap-cell-0)'); expect(active).not.toBeNull(); - await act(async () => active!.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true }))); - expect(container.querySelector(".heatmap-tip-date")?.textContent).toBe("2026-09-07"); - expect(container.querySelector(".heatmap-tip")?.textContent).toContain("700"); + await act(async () => active!.dispatchEvent(new testWindow.PointerEvent("pointerover", { bubbles: true }))); + expect(document.querySelector(".heatmap-tip-date")?.textContent).toBe("Sep 7, 2026"); + expect(document.querySelector(".heatmap-tip")?.textContent).toContain("700"); if (process.env.OCX_USAGE_SANTIAGO_CHILD === "1") console.log("OCX_SANTIAGO_CASE_COMPLETED"); }, process.env.OCX_USAGE_SANTIAGO_CHILD === "1" ? 10000 : 15000); @@ -254,8 +254,8 @@ test("Apply submits inclusive bounds once; Clear restores the held preset withou // A one-day historical window must not produce a year grid anchored to today's date. expect(container.querySelectorAll(".heatmap-grid .heatmap-cell")).toHaveLength(7); const activeCell = container.querySelector(".heatmap-grid .heatmap-cell-1")!; - await act(async () => { activeCell.dispatchEvent(new testWindow.MouseEvent("mouseover", { bubbles: true })); }); - expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("2020-09-15"); + await act(async () => { activeCell.dispatchEvent(new testWindow.PointerEvent("pointerover", { bubbles: true })); }); + expect(document.querySelector('[role="tooltip"]')?.textContent).toContain("Sep 15, 2020"); await enter("2020-09-16T10:20", "2020-09-16T10:21"); expect(interval()).toBe(appliedInterval); expect(requests).toHaveLength(2); From 71f305e217811cf251175e1bea65c2cbe8f6c388 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 20:46:06 +0900 Subject: [PATCH 2/2] refactor(gui): retain feature styles in bounded owners after rebase Preserve base stylesheet order and all feature declarations. Keep current file-size limits; no GUI build or tests run on the connected host. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- gui/src/main.tsx | 1 + gui/src/styles.css | 5 ----- gui/src/styles/usage-chart-accessibility.css | 4 ++++ 3 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 gui/src/styles/usage-chart-accessibility.css diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 024e29626e..2f3c68dd2c 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import { LanguageProvider } from "./i18n/provider"; import "./styles.css"; +import "./styles/usage-chart-accessibility.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/styles.css b/gui/src/styles.css index 065079e7f4..28c9277c1e 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2956,8 +2956,3 @@ button.prov-account-row.active { cursor: default; } } .claude-effort-badge.on { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } .claude-effort-badge.off { background: color-mix(in srgb, var(--muted) 12%, transparent); color: var(--muted); } - -/* Usage chart controls retain chart styling while exposing keyboard focus. */ -.heatmap-grid button.heatmap-cell { appearance: none; border: 0; padding: 0; cursor: pointer; } -.heatmap-grid button.heatmap-cell:focus-visible, .daybar:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } -.chart-overlay { box-sizing: border-box; overflow: hidden; white-space: normal; overflow-wrap: anywhere; } diff --git a/gui/src/styles/usage-chart-accessibility.css b/gui/src/styles/usage-chart-accessibility.css new file mode 100644 index 0000000000..caa86736e3 --- /dev/null +++ b/gui/src/styles/usage-chart-accessibility.css @@ -0,0 +1,4 @@ +/* Usage chart controls retain chart styling while exposing keyboard focus. */ +.heatmap-grid button.heatmap-cell { appearance: none; border: 0; padding: 0; cursor: pointer; } +.heatmap-grid button.heatmap-cell:focus-visible, .daybar:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.chart-overlay { box-sizing: border-box; overflow: hidden; white-space: normal; overflow-wrap: anywhere; }