fix: align Inbox notification ages and controls (#156560)

This commit is contained in:
Vincent Koc 2026-09-23 22:26:40 +08:00 • committed by GitHub
parent 3b77f9653c
commit 318fc3c41f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 343 additions and 115 deletions

View file

@ -0,0 +1,39 @@
import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { formatDateTimeMs, formatRelativeTimestamp } from "../lib/format.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { PollController } from "../lit/poll-controller.ts";
class RelativeTime extends OpenClawLightDomElement {
@property({ attribute: false }) timestampMs: number | null = null;
private readonly polling = new PollController(
this,
60_000,
() => this.requestUpdate(),
false,
"visible",
);
override connectedCallback() {
super.connectedCallback();
this.polling.start();
this.requestUpdate();
}
override render() {
const timestamp = asDateTimestampMs(this.timestampMs);
return timestamp === undefined
? nothing
: html`<time
datetime=${new Date(timestamp).toISOString()}
title=${formatDateTimeMs(timestamp)}
>${formatRelativeTimestamp(timestamp)}</time
>`;
}
}
if (!customElements.get("openclaw-relative-time")) {
customElements.define("openclaw-relative-time", RelativeTime);
}

View file

@ -54,6 +54,14 @@ describe("renderSidebarIssueItem", () => {
});
describe("renderSidebarMentionItem", () => {
beforeEach(() => {
document.body.append(container);
});
afterEach(() => {
container.remove();
vi.useRealTimers();
});
const mention: MentionInboxItem = {
id: "mention-riley",
senderProfileId: "profile-riley",
@ -85,10 +93,28 @@ describe("renderSidebarMentionItem", () => {
return params;
}
it("opens the linked session without dismissing the mention", () => {
it("expands the thread-titled mention and opens its session without dismissing it", async () => {
vi.useFakeTimers({ now: mention.createdAt + 5 * 60_000 });
const { onNavigate, onDismiss } = renderMention();
const open = container.querySelector<HTMLAnchorElement>("a[data-issue-row-focus]")!;
await container.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"openclaw-relative-time",
)?.updateComplete;
const details = container.querySelector("details")!;
const summary = details.querySelector("summary")!;
const title = summary.querySelector(".sidebar-issues-panel__entity")!;
expect(title.textContent).toBe("Release notes");
expect(title.nextElementSibling?.textContent?.replace(/\s+/g, " ").trim()).toBe(
"Riley mentioned you · 5m ago",
);
expect(summary.querySelector(".sidebar-issues-panel__chevron svg")).not.toBeNull();
expect(container.querySelectorAll("[data-issue-row-focus]")).toHaveLength(1);
expect(summary.hasAttribute("data-issue-row-focus")).toBe(true);
expect(details.open).toBe(false);
summary.click();
expect(details.open).toBe(true);
const open = container.querySelector<HTMLAnchorElement>("a[href]")!;
expect(open.getAttribute("href")).toBe(pathname);
expect(open.closest(".sidebar-issues-panel__body")).not.toBeNull();
let nativeNavigationPreserved = false;
open.addEventListener(
@ -109,11 +135,29 @@ describe("renderSidebarMentionItem", () => {
expect(onNavigate).toHaveBeenCalledExactlyOnceWith("chat", navigation);
expect(onDismiss).not.toHaveBeenCalled();
container.querySelector<HTMLButtonElement>("[data-mention-id] button")!.click();
summary.click();
const dismiss = summary.querySelector<HTMLButtonElement>("button")!;
expect(dismiss.getAttribute("aria-label")).toBe("Dismiss Release notes");
expect(dismiss.textContent?.trim()).toBe("");
dismiss.click();
expect(details.open).toBe(false);
expect(onDismiss).toHaveBeenCalledOnce();
expect(onNavigate).toHaveBeenCalledOnce();
});
it("preserves expansion for the same mention but collapses a replacement mention", () => {
renderMention();
container.querySelector("summary")!.click();
expect(container.querySelector("details")?.open).toBe(true);
renderMention({ mention: { ...mention, excerpt: "Updated release notes" } });
expect(container.querySelector("details")?.open).toBe(true);
renderMention({ mention: { ...mention, id: "mention-next", sessionTitle: "Next release" } });
expect(container.querySelector("details")?.open).toBe(false);
expect(container.querySelector("summary")?.textContent).toContain("Next release");
});
it("renders the message excerpt as text rather than HTML or Markdown", () => {
const excerpt = '<img src="about:blank" onerror="alert(1)"> & **release notes**';
renderMention({ mention: { ...mention, excerpt } });
@ -121,6 +165,7 @@ describe("renderSidebarMentionItem", () => {
const renderedExcerpt = container.querySelector(".sidebar-mention-row__excerpt")!;
expect(renderedExcerpt.textContent).toBe(excerpt);
expect(renderedExcerpt.children).toHaveLength(0);
expect(renderedExcerpt.closest(".sidebar-issues-panel__body")).not.toBeNull();
});
it("disables repeated dismissal while leaving the session link usable", () => {
@ -130,7 +175,8 @@ describe("renderSidebarMentionItem", () => {
dismiss.click();
expect(onDismiss).not.toHaveBeenCalled();
container.querySelector<HTMLAnchorElement>("a[data-issue-row-focus]")!.click();
container.querySelector("summary")!.click();
container.querySelector<HTMLAnchorElement>("a[href]")!.click();
expect(onNavigate).toHaveBeenCalledExactlyOnceWith("chat", navigation);
});
});

View file

@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { keyed } from "lit/directives/keyed.js";
import type { MentionInboxItem } from "../../../packages/gateway-protocol/src/index.js";
import type { NavigationRouteId } from "../app-navigation.ts";
import { pathForRoute } from "../app-route-paths.ts";
@ -8,7 +9,6 @@ import type { ExecApprovalDecision, ExecApprovalRequest } from "../app/exec-appr
import type { UpdateProgress } from "../app/update-confirmation.ts";
import { t } from "../i18n/index.ts";
import { registerSidebarAttentionEnglish } from "../i18n/locales/en-sidebar-attention.ts";
import { formatDateTimeMs, formatRelativeTimestamp } from "../lib/format.ts";
import { canCallGatewayMethod } from "../lib/gateway-methods.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import type { PresenceViewer } from "../lib/presence-users.ts";
@ -17,6 +17,7 @@ import { areUiSessionKeysEquivalent } from "../lib/sessions/session-key.ts";
import { renderSidebarApprovalRow } from "./exec-approval-card.ts";
import { icons } from "./icons.ts";
import type { SidebarAttentionItem } from "./sidebar-attention-entries.ts";
import { renderSidebarNotificationCard } from "./sidebar-notification-card.ts";
import "./sidebar-update-card.ts";
import "./viewer-facepile.ts";
@ -79,57 +80,42 @@ export function renderSidebarMentionItem(params: {
data-mention-id=${mention.id}
aria-label=${label}
>
<div class="sidebar-issues-panel__summary sidebar-mention-row__summary">
<span class="sidebar-mention-row__avatar" aria-hidden="true">
<openclaw-viewer-avatar
${keyed(
mention.id,
renderSidebarNotificationCard({
title: mention.sessionTitle,
detail: label,
timestampMs: mention.createdAt,
icon: html`<openclaw-viewer-avatar
.user=${sender}
.markAsViewer=${false}
variant="footer"
></openclaw-viewer-avatar>
</span>
<div class="sidebar-issues-panel__content">
<div class="sidebar-mention-row__header">
<span class="sidebar-issues-panel__entity" title=${label}>${label}</span>
<time
class="sidebar-mention-row__age"
datetime=${new Date(mention.createdAt).toISOString()}
title=${formatDateTimeMs(mention.createdAt)}
>${formatRelativeTimestamp(mention.createdAt)}</time
>
</div>
<span class="sidebar-issues-panel__state" title=${mention.sessionTitle}
>${mention.sessionTitle}</span
>
${
mention.excerpt
? html`<p class="sidebar-mention-row__excerpt">${mention.excerpt}</p>`
: nothing
}
<div class="sidebar-issues-panel__actions sidebar-mention-row__actions">
<a
class="sidebar-issues-panel__action sidebar-issues-panel__action--primary"
href=${target.href}
data-issue-row-focus
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
params.onNavigate("chat", target.options);
}}
>${t("attention.mentions.open")}</a
>
<button
type="button"
class="sidebar-issues-panel__action"
?disabled=${params.dismissing}
@click=${params.onDismiss}
>
${t(params.dismissing ? "attention.mentions.dismissing" : "attention.mentions.dismiss")}
</button>
</div>
</div>
</div>
></openclaw-viewer-avatar>`,
onDismiss: params.onDismiss,
dismissing: params.dismissing,
body: html`
${
mention.excerpt
? html`<p class="sidebar-mention-row__excerpt">${mention.excerpt}</p>`
: nothing
}
<div class="sidebar-issues-panel__actions sidebar-mention-row__actions">
<a
class="sidebar-issues-panel__action sidebar-issues-panel__action--primary"
href=${target.href}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
params.onNavigate("chat", target.options);
}}
>${t("attention.mentions.open")}</a
>
</div>
`,
}),
)}
</article>`;
}

View file

@ -0,0 +1,68 @@
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../i18n/index.ts";
import { icons } from "./icons.ts";
import "./relative-time.ts";
export function renderSidebarNotificationCard(props: {
title: string;
detail: string;
timestampMs?: number | null;
icon: TemplateResult;
severity?: "error" | "warning";
critical?: boolean;
dismissing?: boolean;
onDismiss?: () => void;
body: TemplateResult | typeof nothing;
bodyClass?: string;
}) {
const dismissLabel = t("attention.dismissItem", { item: props.title });
return html`<details
class="sidebar-issues-panel__details ${props.severity ? `sidebar-issues-panel__details--${props.severity}` : ""}"
>
<summary class="sidebar-issues-panel__summary" data-issue-row-focus>
<span
class="sidebar-issues-panel__icon ${props.critical ? "sidebar-issues-panel__icon--critical" : ""}"
aria-hidden="true"
>${props.icon}</span
>
<span class="sidebar-issues-panel__content">
<span class="sidebar-issues-panel__entity" title=${props.title}>${props.title}</span>
<span class="sidebar-issues-panel__state-row sidebar-issues-panel__notification-meta">
<span class="sidebar-issues-panel__state" title=${props.detail}>${props.detail}</span>
${
props.timestampMs == null
? nothing
: html`
<span aria-hidden="true">·</span>
<openclaw-relative-time
class="sidebar-issues-panel__age"
.timestampMs=${props.timestampMs}
></openclaw-relative-time>
`
}
</span>
</span>
${
props.onDismiss
? html`<button
type="button"
class="sidebar-issues-panel__dismiss"
aria-label=${dismissLabel}
aria-busy=${props.dismissing ? "true" : nothing}
title=${props.dismissing ? t("attention.mentions.dismissing") : dismissLabel}
?disabled=${props.dismissing}
@click=${(event: Event) => {
event.preventDefault();
event.stopPropagation();
props.onDismiss?.();
}}
>
${icons.x}
</button>`
: nothing
}
<span class="sidebar-issues-panel__chevron" aria-hidden="true">${icons.chevronRight}</span>
</summary>
<div class="sidebar-issues-panel__body ${props.bodyClass ?? ""}">${props.body}</div>
</details>`;
}

View file

@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateRunRecord } from "../../../src/infra/update-run-record.ts";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts";
import { formatDateTimeMs } from "../lib/format.ts";
import { createUpdateRunFixture } from "../test-helpers/update-run.ts";
import "./sidebar-update-card.ts";
@ -52,8 +53,9 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
vi.useRealTimers();
vi.restoreAllMocks();
if (originalWebkit) {
Object.defineProperty(window, "webkit", originalWebkit);
} else {
@ -331,6 +333,7 @@ describe("SidebarUpdateCard", () => {
expect(element.querySelector(".sidebar-issues-panel__entity")?.textContent).toBe(
"Update available",
);
expect(element.querySelector("summary time")).toBeNull();
expect(element.querySelector(".sidebar-update-card__action")?.textContent).toContain(
"Update Gateway",
);
@ -343,6 +346,87 @@ describe("SidebarUpdateCard", () => {
expect(element.querySelector("details")?.open).toBe(false);
});
it.each([
{ state: "running", ageMinutes: 30 },
{ state: "succeeded", ageMinutes: 5 },
{ state: "failed", ageMinutes: 5 },
{ state: "campaign", ageMinutes: 20 },
] as const)(
"shows the recorded $state age beside the compact row's detail",
async ({ state, ageMinutes }) => {
const now = Date.parse("2026-09-23T12:00:00Z");
vi.useFakeTimers({ now });
const element = await mount(null);
element.compact = true;
if (state === "campaign") {
element.updateSchedule = {
channel: "stable",
autoEnabled: true,
target: { kind: "package", version: "2026.9.2" },
campaign: {
id: "campaign-age",
state: "countdown",
announcedAtMs: now - 20 * 60_000,
updatedAtMs: now - 60_000,
applyAtMs: now + 10 * 60_000,
forceAtMs: now + 60 * 60_000,
},
};
} else {
element.updateRun = createUpdateRunFixture({
status: state,
phase: state === "running" ? "staging" : "finished",
createdAtMs: now - 30 * 60_000,
updatedAtMs: now - 60_000,
finishedAtMs: state === "running" ? null : now - 5 * 60_000,
});
}
await element.updateComplete;
await element.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"openclaw-relative-time",
)?.updateComplete;
const title = element.querySelector("summary .sidebar-issues-panel__entity");
const meta = title?.nextElementSibling;
const time = meta?.querySelector("time");
const timestamp = now - ageMinutes * 60_000;
expect(meta?.querySelector(".sidebar-issues-panel__state")).not.toBeNull();
expect(time?.getAttribute("datetime")).toBe(new Date(timestamp).toISOString());
expect(time?.getAttribute("title")).toBe(formatDateTimeMs(timestamp));
expect(time?.textContent?.trim()).toBe(`${ageMinutes}m ago`);
expect(element.querySelector("details")?.open).toBe(false);
},
);
it("refreshes a completed update's age without new state and stops ticking on disconnect", async () => {
const now = Date.parse("2026-09-23T12:00:00Z");
vi.useFakeTimers({ now });
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
const timersBefore = vi.getTimerCount();
const element = await mount(null);
element.compact = true;
element.updateRun = createUpdateRunFixture({
status: "succeeded",
phase: "finished",
createdAtMs: now - 30 * 60_000,
updatedAtMs: now - 5 * 60_000,
finishedAtMs: now - 5 * 60_000,
});
await element.updateComplete;
await element.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"openclaw-relative-time",
)?.updateComplete;
expect(element.querySelector("summary time")?.textContent?.trim()).toBe("5m ago");
await vi.advanceTimersByTimeAsync(60_000);
expect(element.querySelector("summary time")?.textContent?.trim()).toBe("6m ago");
element.remove();
expect(vi.getTimerCount()).toBe(timersBefore);
await vi.advanceTimersByTimeAsync(60_000);
expect(element.querySelector("summary time")?.textContent?.trim()).toBe("6m ago");
});
it("keeps an unauthorized update discoverable without allowing activation", async () => {
const element = await mount(
{

View file

@ -21,6 +21,7 @@ import { PollController } from "../lit/poll-controller.ts";
import "../styles/sidebar-update-card.css";
import { icons } from "./icons.ts";
import { isUpdateRunAttentionVisible } from "./sidebar-attention-update.ts";
import { renderSidebarNotificationCard } from "./sidebar-notification-card.ts";
import "./tooltip.ts";
import { renderUpdateGitRevisions } from "./update-git-revisions.ts";
@ -196,6 +197,10 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
return {
title: view.headline,
detail: view.compactLabel,
timestampMs:
this.updateRun.status === "running"
? this.updateRun.createdAtMs
: this.updateRun.finishedAtMs,
icon:
this.updateRun.status === "running"
? icons.refresh
@ -217,6 +222,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
const blocked = statusBanner && statusBanner.tone !== "info";
const blockedReason = statusBanner?.text.trim() || t("updates.sidebar.blockedSummary");
return {
timestampMs: campaign?.announcedAtMs,
detail: blocked
? campaign?.state === "waiting-for-idle" && targetLabel
? t("updates.sidebar.blockedWaiting", { target: targetLabel })
@ -245,44 +251,12 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
if (!summary) {
return nothing;
}
return html`<details
class="sidebar-issues-panel__details sidebar-issues-panel__details--${summary.severity}"
>
<summary class="sidebar-issues-panel__summary" data-issue-row-focus>
<span
class="sidebar-issues-panel__icon ${
summary.critical ? "sidebar-issues-panel__icon--critical" : ""
}"
aria-hidden="true"
>${summary.icon}</span
>
<span class="sidebar-issues-panel__content">
<span class="sidebar-issues-panel__entity" title=${summary.title}>${summary.title}</span>
<span class="sidebar-issues-panel__state" title=${summary.detail}>${summary.detail}</span>
</span>
${
this.onDismiss
? html`<button
type="button"
class="sidebar-issues-panel__dismiss"
aria-label=${t("attention.dismissItem", { item: summary.title })}
title=${t("attention.dismissItem", { item: summary.title })}
@click=${(event: Event) => {
event.preventDefault();
event.stopPropagation();
this.onDismiss?.();
}}
>
${icons.x}
</button>`
: nothing
}
<span class="sidebar-issues-panel__chevron" aria-hidden="true">${icons.chevronRight}</span>
</summary>
<div class="sidebar-issues-panel__body sidebar-update-issue__body">
${this.renderCompactDetails()}
</div>
</details>`;
return renderSidebarNotificationCard({
...summary,
onDismiss: this.onDismiss,
body: this.renderCompactDetails(),
bodyClass: "sidebar-update-issue__body",
});
}
private renderCompactDetails() {

View file

@ -7,6 +7,7 @@ import {
installMockGateway,
} from "../test-helpers/control-ui-e2e.ts";
import { compactCronJobFixture } from "../test-helpers/cron.ts";
import { createUpdateRunFixture } from "../test-helpers/update-run.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({ name: "Inbox dismissal clarity" });
@ -29,12 +30,24 @@ suite.define(() => {
await suite.withPage(
{ viewport: { width, height: 900 }, colorScheme: "dark", locale: "en-US" },
async ({ page }) => {
const notificationAt = Date.now() - 5 * 60_000;
const gateway = await installMockGateway(page, {
presenceUsers: [
{ self: true, id: "alex", name: "Alex", identity: { type: "profile", id: "alex" } },
],
featureMethods: [...defaultControlUiFeatureMethods, "mentions.list", "mentions.dismiss"],
methodResponses: {
"update.status": {
activeRun: null,
lastRun: createUpdateRunFixture({
status: "succeeded",
phase: "finished",
createdAtMs: notificationAt - 60_000,
updatedAtMs: notificationAt,
finishedAtMs: notificationAt,
after: { version: "2026.9.2" },
}),
},
"models.authStatus": { providers: [], ts: 1 },
"cron.list": {
jobs: [compactCronJobFixture(failedJob)],
@ -57,7 +70,7 @@ suite.define(() => {
agentId: "main",
sessionTitle: "Release review",
messageId: "message-review",
createdAt: Date.now(),
createdAt: notificationAt,
expiresAt: Date.now() + 60_000,
excerpt: "Can you review the release notes?",
},
@ -81,10 +94,38 @@ suite.define(() => {
const panel = page.locator("#sidebar-issues-panel");
const automation = panel.locator('[data-attention-kind="cronFailed"]');
const mention = panel.locator('[data-mention-id="mention-review"]');
const update = panel.locator('[data-attention-kind="updateAvailable"]');
const approval = panel.locator('[data-approval-id="approval-pending"]');
await automation.waitFor();
await mention.waitFor();
await update.waitFor();
await approval.waitFor();
expect(await mention.locator("summary .sidebar-issues-panel__entity").textContent()).toBe(
"Release review",
);
expect(await mention.locator("summary .sidebar-issues-panel__state").textContent()).toBe(
"Riley mentioned you",
);
for (const notification of [mention, update]) {
const summary = notification.locator("summary");
const body = notification.locator(".sidebar-issues-panel__body");
expect(await notification.locator("details").getAttribute("open")).toBeNull();
expect(await summary.locator(".sidebar-issues-panel__state-row time").isVisible()).toBe(
true,
);
expect(await summary.locator(".sidebar-issues-panel__chevron").isVisible()).toBe(true);
expect(await body.isVisible()).toBe(false);
await summary.click();
await body.waitFor({ state: "visible" });
if (notification === mention) {
expect(await body.getByRole("link", { name: "Open", exact: true }).isVisible()).toBe(
true,
);
expect(await body.textContent()).toContain("Can you review the release notes?");
}
await summary.click();
await body.waitFor({ state: "hidden" });
}
if (process.env.OPENCLAW_CAPTURE_UI_PROOF === "1") {
await page.screenshot({
path: path.join(suite.artifactDir, `inbox-${width}.png`),
@ -98,10 +139,15 @@ suite.define(() => {
).toBe("");
expect(
(
await mention.getByRole("button", { name: "Dismiss", exact: true }).textContent()
await mention
.getByRole("button", { name: "Dismiss Release review", exact: true })
.textContent()
)?.trim(),
).toBe("Dismiss");
).toBe("");
expect(await panel.locator(".sidebar-issues-panel__dismiss-help").count()).toBe(0);
await update.getByRole("button", { name: /^Dismiss .*OpenClaw updated/ }).click();
await update.waitFor({ state: "hidden" });
expect(await gateway.getRequests("update.run")).toHaveLength(0);
// A scoped bulk action must not acknowledge another tab's mention.
await panel.getByRole("tab", { name: /Automations/ }).click();

View file

@ -91,11 +91,13 @@ suite.define(() => {
await panel.waitFor({ state: "hidden" });
expect(await drawer.getAttribute("aria-hidden")).toBeNull();
await page.locator(".sidebar-issues-button:visible").click();
await panel.locator('[data-mention-id="mobile-mention"] summary').click();
await panel.locator('[data-mention-id="mobile-mention"] a').click();
await waitForControlUiRoute(page, { pathname: "/chat/main", routeId: "chat" });
await expectClosed();
// An Inbox link to the already-open session must close the drawer too.
await openInbox();
await panel.locator('[data-mention-id="mobile-mention"] summary').click();
await panel.locator('[data-mention-id="mobile-mention"] a').click();
await expectClosed();
expect(await gateway.getRequests("mentions.dismiss")).toHaveLength(0);

View file

@ -606,28 +606,11 @@ footer.sidebar-issues-panel__mentions-note {
color: var(--accent-foreground);
}
.sidebar-mention-row__summary {
align-items: flex-start;
.sidebar-issues-panel__notification-meta .sidebar-issues-panel__state {
flex: 0 1 auto;
}
.sidebar-mention-row__avatar {
flex: none;
margin-block-start: 2px;
}
.sidebar-mention-row__header {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
}
.sidebar-mention-row__header .sidebar-issues-panel__entity {
min-width: 0;
flex: 1 1 auto;
}
.sidebar-mention-row__age {
.sidebar-issues-panel__age {
flex: none;
color: var(--muted);
font-size: var(--control-ui-text-xs);