Skip to content

Commit 2ac7232

Browse files
authored
refactor(ui): unified settings design language across all Control UI settings surfaces (#106470)
* feat(ui): add settings design-language foundation (tokens, settings.css, helpers) * refactor(ui): move settings workspace shell styles into settings.css * refactor(ui): migrate all settings surfaces to the unified settings design language Every settings surface (General quick+advanced schema form, Appearance, Communications, Automation, MCP, Infrastructure, AI Agents, Channels, Connection, About, Profile, Model Providers, Plugins, Skills, Skill Workshop chrome, Devices, Worktrees, Debug, Logs, Activity, Automations, Sessions, Usage chrome) now renders through the settings-ui helpers and settings.css: sections as plain uppercase headings outside one flat group surface, hairline-divided rows, a single toggle/segmented/status vocabulary, status dots instead of pills, and no cards-in-cards or enter animations. Deletes the qs-*/cfg-*/config-section-card/account-card/ plugins-row/cron-editor-card/session-badge/agent-pill families and orphaned shared rules. * style(ui-docs): format design-system docs * fix(ui): make activity statusKind lookup exhaustive for consistent-return lint * fix(ui): address settings design-language review findings Label-wrapped toggle rows with required accessible names, named selects and cron form controls, busy-disabled segmented controls with restored theme-transition anchors, collapsible skill groups, semantic plugin headings, gateway connected status, paired-devices empty state, logs subtitle, cron prompt help, and keyboard-accessible session goal objectives. * fix(ui): harden settings primitives per review round two Wrap row action clusters, fully-inset nav focus ring, restore number steppers in the schema form, cap node capability lines, keep the named Activity region landmark, and associate cron help text via the row label. * fix(ui): skip empty section headings and fix the toggle doc example * docs(ui): import renderSettingsToggleRow in the settings example * fix(ui): rename number stepper delta to avoid shadowing
1 parent 37b3f41 commit 2ac7232

107 files changed

Lines changed: 9594 additions & 12782 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ui/docs/design-system/README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ OpenClaw's visual language is a **dark-first, glass-surface system** built aroun
44

55
## Contents
66

7-
| File | What it covers |
8-
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
9-
| [glass-surfaces.md](./glass-surfaces.md) | Two glass tiers, exact CSS values, no-solid-panels rule, `@supports` fallback |
10-
| [color-tokens.md](./color-tokens.md) | All design tokens with values, usage, contrast ratios, and anti-patterns |
11-
| [motion.md](./motion.md) | Duration scale, easing functions, `prefers-reduced-motion` pattern, animation inventory |
12-
| [accessibility.md](./accessibility.md) | WCAG checklist: contrast, focus, tap targets, ARIA, skip link, focus trap |
7+
| File | What it covers |
8+
| ------------------------------------------ | --------------------------------------------------------------------------------------- |
9+
| [glass-surfaces.md](./glass-surfaces.md) | Two glass tiers, exact CSS values, no-solid-panels rule, `@supports` fallback |
10+
| [color-tokens.md](./color-tokens.md) | All design tokens with values, usage, contrast ratios, and anti-patterns |
11+
| [motion.md](./motion.md) | Duration scale, easing functions, `prefers-reduced-motion` pattern, animation inventory |
12+
| [accessibility.md](./accessibility.md) | WCAG checklist: contrast, focus, tap targets, ARIA, skip link, focus trap |
13+
| [settings-design.md](./settings-design.md) | Settings page anatomy: sections, groups, rows, controls, status dots, migration rules |
1314

1415
## Guiding Principles
1516

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Settings Design Language
2+
3+
Every settings surface (the `/settings` takeover pages plus the Plugins/Skills hubs) uses one structural pattern. Styles live in `ui/src/styles/settings.css`; templates are built through the helpers in `ui/src/components/settings-ui.ts`.
4+
5+
## Anatomy
6+
7+
```
8+
.settings-page ← single column, 760px (or --wide: 1120px)
9+
.settings-section ← repeated per topic
10+
.settings-section__heading ← plain uppercase text label, outside any surface
11+
.settings-group ← the ONLY surface (card bg, border, radius-lg)
12+
.settings-row ← title/description left, one control right
13+
.settings-row ← hairline divider between rows
14+
```
15+
16+
- **Sections are typography, not chrome.** Grouping comes from whitespace + a small uppercase heading — never a card header.
17+
- **Exactly one level of elevation.** A group never contains another card, callout, or bordered box. Nested detail uses `.settings-subrows` (indented rows), a stacked row, or a drill-in nav row.
18+
- **Row anatomy:** left is title (`--control-ui-text-md`, weight 500) over an optional one-line description (muted, sm). Right is exactly one control: toggle, select, segmented, button, plain value, or chevron (nav). Wide editors use the `stacked` variant.
19+
- **Lists are rows too.** An entity list (plugin, device, session) is a group whose rows carry an action cluster in the control slot — same anatomy as a toggle row.
20+
21+
## Rules
22+
23+
- **No status pills.** Status is `renderSettingsStatus` — a dot + plain text (`● Connected`). Badges (`.settings-count`) exist only for genuine counts.
24+
- **Spacing uses `--space-*` tokens** (`base.css`); no hardcoded paddings/gaps.
25+
- **Motion budget:** color/background transitions only. No enter animations, staggered reveals, or hover glows.
26+
- **Buttons:** default `.btn` (quiet). `--accent` primary at most once per view. Danger actions live in a `danger: true` section at the page bottom.
27+
- **One control set.** Use `renderSettingsToggleRow` (preferred: label-wrapped, whole row clickable, accessible name for free) or `renderSettingsToggle` with a required `ariaLabel`, `renderSettingsSegmented`, `.settings-select`, `.settings-input`. Do not add another toggle or badge variant.
28+
- **Every control needs an accessible name.** Row titles are plain text, not `<label>`s — selects/inputs in a control slot must carry `aria-label` (usually the row title string).
29+
- **No new page CSS files for settings surfaces.** Page-specific styles belong in `settings.css` only when a primitive is genuinely missing — extend the system, don't fork it.
30+
31+
## Helpers
32+
33+
```ts
34+
import {
35+
renderSettingsPage,
36+
renderSettingsSection,
37+
renderSettingsRow,
38+
renderSettingsNavRow,
39+
renderSettingsToggleRow,
40+
renderSettingsSegmented,
41+
renderSettingsStatus,
42+
renderSettingsValue,
43+
renderSettingsEmpty,
44+
} from "../../components/settings-ui.ts";
45+
46+
renderSettingsPage([
47+
renderSettingsSection({ title: t("settings.notifications") }, [
48+
renderSettingsToggleRow({
49+
title: t("settings.systemNotifications"),
50+
description: t("settings.systemNotificationsDesc"),
51+
checked,
52+
onChange,
53+
}),
54+
]),
55+
]);
56+
```
57+
58+
Custom content inside a group (tables, meters) is allowed as an escape hatch — keep it inside one `.settings-group` and match row paddings (`--space-3 --space-4`).

ui/src/components/config-form.browser.test.ts

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe("config form renderer", () => {
6464

6565
const tokenInput = expectElement(
6666
container.querySelector<HTMLInputElement>(
67-
'#config-section-gateway input.cfg-input[type="text"]',
67+
'#config-section-gateway input.settings-input[type="text"]',
6868
),
6969
"gateway token input",
7070
);
@@ -73,7 +73,7 @@ describe("config form renderer", () => {
7373
expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123");
7474

7575
const tokenButton = expectElement(
76-
Array.from(container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn")).find(
76+
Array.from(container.querySelectorAll<HTMLButtonElement>(".settings-segmented__btn")).find(
7777
(btn) => btn.textContent?.trim() === "token",
7878
),
7979
"token segmented button",
@@ -89,19 +89,24 @@ describe("config form renderer", () => {
8989
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
9090
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
9191

92-
const addButton = expectElement(container.querySelector(".cfg-array__add"), "array add button");
92+
const addButton = expectElement(
93+
Array.from(container.querySelectorAll<HTMLButtonElement>(".cfg-array button")).find(
94+
(btn) => btn.textContent?.trim() === "Add",
95+
),
96+
"array add button",
97+
);
9398
addButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
9499
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
95100

96101
const removeButton = expectElement(
97-
container.querySelector(".cfg-array__item-remove"),
102+
container.querySelector(".cfg-array button[aria-label='Remove item']"),
98103
"array remove button",
99104
);
100105
removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
101106
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
102107

103108
const tailnetButton = expectElement(
104-
Array.from(container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn")).find(
109+
Array.from(container.querySelectorAll<HTMLButtonElement>(".settings-segmented__btn")).find(
105110
(btn) => btn.textContent?.trim() === "tailnet",
106111
),
107112
"tailnet segmented button",
@@ -110,6 +115,74 @@ describe("config form renderer", () => {
110115
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
111116
});
112117

118+
it("renders subsection labels exactly once", () => {
119+
const container = document.createElement("div");
120+
render(
121+
renderConfigForm({
122+
schema: rootAnalysis.schema,
123+
uiHints: {},
124+
unsupportedPaths: rootAnalysis.unsupportedPaths,
125+
value: {},
126+
activeSection: "gateway",
127+
activeSubsection: "auth",
128+
onPatch: vi.fn(),
129+
}),
130+
container,
131+
);
132+
133+
const headings = Array.from(container.querySelectorAll(".settings-section__heading")).map(
134+
(node) => node.textContent?.trim(),
135+
);
136+
expect(headings).toEqual(["Auth"]);
137+
// The subsection object emits its fields directly; no nested details block
138+
// repeats the subsection title inside the group.
139+
expect(container.querySelector(".cfg-object")).toBeNull();
140+
const rowTitles = Array.from(container.querySelectorAll(".settings-row__title")).map((node) =>
141+
node.textContent?.trim(),
142+
);
143+
expect(rowTitles).toEqual(["Token"]);
144+
});
145+
146+
it("renders boolean fields as label-wrapped toggle rows", () => {
147+
const onPatch = vi.fn();
148+
const container = document.createElement("div");
149+
const analysis = analyzeConfigSchema({
150+
type: "object",
151+
properties: {
152+
features: {
153+
type: "object",
154+
properties: {
155+
beta: { type: "boolean", description: "Enable beta features" },
156+
},
157+
},
158+
},
159+
});
160+
render(
161+
renderConfigForm({
162+
schema: analysis.schema,
163+
uiHints: {},
164+
unsupportedPaths: analysis.unsupportedPaths,
165+
value: { features: { beta: false } },
166+
onPatch,
167+
}),
168+
container,
169+
);
170+
171+
const checkbox = expectElement(
172+
container.querySelector<HTMLInputElement>("input[type='checkbox']"),
173+
"beta checkbox",
174+
);
175+
const row = expectElement(checkbox.closest(".settings-row"), "beta toggle row");
176+
expect(row.tagName).toBe("LABEL");
177+
expect(row.querySelector(".settings-row__title")?.textContent?.trim()).toBe("Beta");
178+
expect(row.querySelector(".settings-row__desc")?.textContent?.trim()).toBe(
179+
"Enable beta features",
180+
);
181+
checkbox.checked = true;
182+
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
183+
expect(onPatch).toHaveBeenCalledWith(["features", "beta"], true);
184+
});
185+
113186
it("keeps dropdown selects on their configured value after options render", () => {
114187
const onPatch = vi.fn();
115188
const container = document.createElement("div");
@@ -145,7 +218,7 @@ describe("config form renderer", () => {
145218
container,
146219
);
147220

148-
const selects = container.querySelectorAll<HTMLSelectElement>("select.cfg-select");
221+
const selects = container.querySelectorAll<HTMLSelectElement>("select.settings-select");
149222
expect(selects).toHaveLength(2);
150223
const selectedLabels = Array.from(selects).map((select) =>
151224
select.selectedOptions[0]?.textContent?.trim(),
@@ -180,7 +253,7 @@ describe("config form renderer", () => {
180253
);
181254

182255
const removeButton = expectElement(
183-
container.querySelector(".cfg-map__item-remove"),
256+
container.querySelector(".cfg-map button[aria-label='Remove entry']"),
184257
"map remove button",
185258
);
186259
removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -224,7 +297,9 @@ describe("config form renderer", () => {
224297
);
225298

226299
const label = expectElement(
227-
container.querySelector(".cfg-toggle-row__label"),
300+
Array.from(container.querySelectorAll(".settings-row__title")).find(
301+
(node) => node.textContent?.trim() === "Plugin Enabled",
302+
),
228303
"plugin enabled label",
229304
);
230305
expect(label.textContent?.trim()).toBe("Plugin Enabled");
@@ -267,20 +342,23 @@ describe("config form renderer", () => {
267342
);
268343

269344
const sectionTitle = expectElement(
270-
container.querySelector(".config-section-card__title"),
345+
container.querySelector(".settings-section__heading"),
271346
"tag-filtered section title",
272347
);
273348
expect(sectionTitle.textContent?.trim()).toBe("Gateway");
274349
const fieldLabel = expectElement(
275-
container.querySelector(".cfg-field__label"),
350+
Array.from(container.querySelectorAll(".settings-row__title")).find(
351+
(node) => node.textContent?.trim() === "Token",
352+
),
276353
"tag-filtered field label",
277354
);
278355
expect(fieldLabel.textContent?.trim()).toBe("Token");
356+
// Only the Auth subtree (matching the tag filter) renders rows.
279357
expect(
280-
Array.from(container.querySelectorAll(".cfg-field__label")).map((label) =>
358+
Array.from(container.querySelectorAll(".settings-row__title")).map((label) =>
281359
label.textContent?.trim(),
282360
),
283-
).toEqual(["Token"]);
361+
).toEqual(["Auth", "Token"]);
284362
});
285363

286364
it("supports SecretInput unions in additionalProperties maps", () => {
@@ -351,9 +429,11 @@ describe("config form renderer", () => {
351429
);
352430

353431
const apiKeyInput = expectElement(
354-
container.querySelector<HTMLInputElement>(
355-
"#config-section-models .cfg-map__item-value input.cfg-input[type='text']",
356-
),
432+
Array.from(
433+
container.querySelectorAll<HTMLInputElement>(
434+
"#config-section-models .cfg-map input.settings-input[type='text']",
435+
),
436+
).find((input) => input.value === "old"),
357437
"provider api key input",
358438
);
359439
apiKeyInput.value = "new-key";
@@ -496,7 +576,7 @@ describe("config form renderer", () => {
496576
);
497577

498578
const removeButton = expectElement(
499-
container.querySelector(".cfg-map__item-remove"),
579+
container.querySelector(".cfg-map button[aria-label='Remove entry']"),
500580
"accounts remove button",
501581
);
502582
removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));

0 commit comments

Comments
 (0)