Skip to content

Commit 6a33772

Browse files
authored
[codex] add color mode tooltips (#85227)
* fix(ui): add color mode tooltips * docs: update changelog for color mode tooltips * docs: credit changelog contributor --------- Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
1 parent 8df3500 commit 6a33772

5 files changed

Lines changed: 152 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
2525
### Fixes
2626

2727
- Agents/Copilot: drop unsafe GitHub Copilot Responses reasoning replay items before send so Telegram direct sessions no longer fail on overlong replay IDs. Fixes #85197. (#85198) Thanks @galiniliev.
28+
- UI: add accessible tooltips to the topbar color-mode buttons so System, Light, and Dark choices are labeled on hover and focus. (#85227) Thanks @amknight.
2829
- fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.
2930
- Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.
3031
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.

ui/src/styles/layout.css

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@
336336
}
337337

338338
.topbar-theme-mode__btn {
339+
position: relative;
339340
width: 30px;
340341
height: 30px;
341342
display: inline-flex;
@@ -379,6 +380,90 @@
379380
stroke-linejoin: round;
380381
}
381382

383+
.topbar-theme-mode__btn[data-tooltip]::before,
384+
.topbar-theme-mode__btn[data-tooltip]::after {
385+
position: absolute;
386+
left: 50%;
387+
pointer-events: none;
388+
opacity: 0;
389+
transition:
390+
opacity var(--duration-fast) var(--ease-out),
391+
transform var(--duration-fast) var(--ease-out);
392+
z-index: 60;
393+
}
394+
395+
.topbar-theme-mode__btn[data-tooltip]::before {
396+
content: "";
397+
top: calc(100% + 4px);
398+
border-width: 6px;
399+
border-style: solid;
400+
border-color: transparent transparent color-mix(in srgb, var(--card) 94%, black 6%) transparent;
401+
transform: translate(-50%, -3px);
402+
}
403+
404+
.topbar-theme-mode__btn[data-tooltip]::after {
405+
content: attr(data-tooltip);
406+
top: calc(100% + 10px);
407+
max-width: min(220px, 60vw);
408+
padding: 7px 9px;
409+
border: 1px solid color-mix(in srgb, var(--border-strong) 84%, transparent);
410+
border-radius: var(--radius-md);
411+
background: color-mix(in srgb, var(--card) 94%, black 6%);
412+
box-shadow:
413+
0 10px 28px rgba(0, 0, 0, 0.24),
414+
0 0 0 1px rgba(255, 255, 255, 0.04);
415+
color: var(--text);
416+
font-size: 11px;
417+
font-weight: 500;
418+
line-height: 1.35;
419+
text-align: center;
420+
white-space: normal;
421+
transform: translate(-50%, -4px);
422+
}
423+
424+
.topbar-theme-mode__btn:last-child[data-tooltip]::before,
425+
.topbar-theme-mode__btn:last-child[data-tooltip]::after {
426+
left: auto;
427+
}
428+
429+
.topbar-theme-mode__btn:last-child[data-tooltip]::before {
430+
right: 9px;
431+
transform: translateY(-3px);
432+
}
433+
434+
.topbar-theme-mode__btn:last-child[data-tooltip]::after {
435+
right: 0;
436+
transform: translateY(-4px);
437+
}
438+
439+
@media (hover: hover) {
440+
.topbar-theme-mode__btn[data-tooltip]:hover::before,
441+
.topbar-theme-mode__btn[data-tooltip]:hover::after {
442+
opacity: 1;
443+
}
444+
445+
.topbar-theme-mode__btn[data-tooltip]:hover::before,
446+
.topbar-theme-mode__btn[data-tooltip]:hover::after {
447+
transform: translate(-50%, 0);
448+
}
449+
450+
.topbar-theme-mode__btn:last-child[data-tooltip]:hover::before,
451+
.topbar-theme-mode__btn:last-child[data-tooltip]:hover::after {
452+
transform: translateY(0);
453+
}
454+
}
455+
456+
.topbar-theme-mode__btn[data-tooltip]:focus-visible::before,
457+
.topbar-theme-mode__btn[data-tooltip]:focus-visible::after {
458+
opacity: 1;
459+
transform: translate(-50%, 0);
460+
}
461+
462+
.topbar-theme-mode__btn:last-child[data-tooltip]:focus-visible::before,
463+
.topbar-theme-mode__btn:last-child[data-tooltip]:focus-visible::after {
464+
transform: translateY(0);
465+
}
466+
382467
/* ===========================================
383468
Navigation Sidebar
384469
=========================================== */

ui/src/styles/layout.mobile.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ function readGroupedChatCss(): string {
1313
return readStyleSheet("ui/src/styles/chat/grouped.css");
1414
}
1515

16+
function selectorBlocks(css: string, selector: string): string[] {
17+
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18+
return [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`, "gs"))].map(
19+
(match) => match[0],
20+
);
21+
}
22+
1623
describe("chat header responsive mobile styles", () => {
1724
it("keeps the chat header and session controls from clipping on narrow widths", () => {
1825
const css = readMobileCss();
@@ -69,6 +76,29 @@ describe("sidebar menu trigger styles", () => {
6976
});
7077
});
7178

79+
describe("topbar theme mode tooltip styles", () => {
80+
it("clamps the rightmost color mode tooltip inside the viewport edge", () => {
81+
const css = readLayoutCss();
82+
83+
expect(css).toMatch(
84+
/\.topbar-theme-mode__btn:last-child\[data-tooltip\]::after \{[\s\S]*right: 0;/,
85+
);
86+
expect(css).toMatch(
87+
/\.topbar-theme-mode__btn:last-child\[data-tooltip\]:hover::after \{[\s\S]*transform: translateY\(0\);/,
88+
);
89+
expect(css).toMatch(
90+
/\.topbar-theme-mode__btn:last-child\[data-tooltip\]:focus-visible::after \{[\s\S]*transform: translateY\(0\);/,
91+
);
92+
const tooltipBlock =
93+
selectorBlocks(css, ".topbar-theme-mode__btn[data-tooltip]::after").find((block) =>
94+
block.includes("content: attr(data-tooltip);"),
95+
) ?? "";
96+
expect(tooltipBlock).toBeTruthy();
97+
expect(tooltipBlock).not.toContain("min-width:");
98+
expect(tooltipBlock).toContain("max-width: min(220px, 60vw);");
99+
});
100+
});
101+
72102
describe("grouped chat width styles", () => {
73103
it("uses the config-fed CSS variable with the current fallback", () => {
74104
const css = readGroupedChatCss();

ui/src/ui/app-render.helpers.browser.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { render } from "lit";
22
import { describe, expect, it, vi } from "vitest";
33
import { t } from "../i18n/index.ts";
4-
import { renderChatControls, renderChatMobileToggle, renderTab } from "./app-render.helpers.ts";
4+
import {
5+
renderChatControls,
6+
renderChatMobileToggle,
7+
renderTab,
8+
renderTopbarThemeModeToggle,
9+
} from "./app-render.helpers.ts";
510
import type { AppViewState } from "./app-view-state.ts";
611
import type { SessionsListResult } from "./types.ts";
712

@@ -49,6 +54,7 @@ function createState(overrides: Partial<AppViewState> = {}) {
4954
chatAutoScroll: "near-bottom",
5055
},
5156
applySettings: () => undefined,
57+
setThemeMode: () => undefined,
5258
chatMobileControlsOpen: false,
5359
setChatMobileControlsOpen: () => undefined,
5460
chatModelCatalog: [],
@@ -136,6 +142,31 @@ describe("chat header controls (browser)", () => {
136142
}
137143
});
138144

145+
it("renders explicit hover tooltip metadata for the color mode buttons", async () => {
146+
const container = document.createElement("div");
147+
render(renderTopbarThemeModeToggle(createState({ themeMode: "system" })), container);
148+
await Promise.resolve();
149+
150+
const buttons = Array.from(
151+
container.querySelectorAll<HTMLButtonElement>(".topbar-theme-mode__btn[data-tooltip]"),
152+
);
153+
154+
expect(buttons).toHaveLength(3);
155+
156+
const labels = buttons.map((button) => button.getAttribute("data-tooltip"));
157+
expect(labels).toEqual([
158+
t("common.colorModeOption", { mode: t("common.system") }),
159+
t("common.colorModeOption", { mode: t("common.light") }),
160+
t("common.colorModeOption", { mode: t("common.dark") }),
161+
]);
162+
163+
for (const button of buttons) {
164+
expect(button.getAttribute("title")).toBe(button.getAttribute("data-tooltip"));
165+
expect(button.getAttribute("aria-label")).toBe(button.getAttribute("data-tooltip"));
166+
}
167+
expect(buttons[0]?.classList.contains("topbar-theme-mode__btn--active")).toBe(true);
168+
});
169+
139170
it.each([
140171
["connected and idle", {}, false],
141172
["chat history loading", { chatLoading: true }, true],

ui/src/ui/app-render.helpers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,14 +791,16 @@ export function renderTopbarThemeModeToggle(state: AppViewState) {
791791
<div class="topbar-theme-mode" role="group" aria-label=${t("common.colorMode")}>
792792
${THEME_MODE_OPTIONS.map((opt) => {
793793
const label = t(opt.labelKey);
794+
const tooltip = t("common.colorModeOption", { mode: label });
794795
return html`
795796
<button
796797
type="button"
797798
class="topbar-theme-mode__btn ${opt.id === state.themeMode
798799
? "topbar-theme-mode__btn--active"
799800
: ""}"
800-
title=${label}
801-
aria-label=${t("common.colorModeOption", { mode: label })}
801+
title=${tooltip}
802+
aria-label=${tooltip}
803+
data-tooltip=${tooltip}
802804
aria-pressed=${opt.id === state.themeMode}
803805
@click=${(e: Event) => applyMode(opt.id, e)}
804806
>

0 commit comments

Comments
 (0)