From df72ca1ecec7a150282951c3964127c669a1d2cd Mon Sep 17 00:00:00 2001
From: Val Alexander <68980965+BunsDev@users.noreply.github.com>
Date: Tue, 17 Mar 2026 23:06:01 -0500
Subject: [PATCH] UI: add corner radius slider and appearance polish (#49436)
* Refactor CSS styles: replace hardcoded colors with CSS variables for accent colors and optimize spacing rules in layout files.
* Update CSS styles: streamline selectors, enhance hover effects, and adjust focus states for chat components and layout elements.
* Enhance focus styles for chat components: update border colors and box-shadow effects for improved accessibility and visual consistency.
* Implement theme management in UI: add dynamic theme switching based on user settings, update CSS variables for new themes, and enhance security by preventing prototype pollution in form utilities.
* Implement border radius customization in UI: add settings for corner roundness, update CSS styles for sliders, and integrate border radius adjustments across components.
* Remove border radius property from UI settings and related functions to simplify configuration and enhance consistency across components.
* Enhance responsive design in UI: add media queries for mobile layouts, adjust padding and grid structures, and implement bottom navigation for improved usability on smaller screens.
* UI: add corner radius slider to Appearance settings
---
CHANGELOG.md | 1 +
ui/index.html | 53 ++++++
ui/src/styles/base.css | 177 ++++++++++++++----
ui/src/styles/chat/grouped.css | 1 +
ui/src/styles/chat/layout.css | 23 ++-
ui/src/styles/chat/text.css | 17 ++
ui/src/styles/chat/tool-cards.css | 52 +++++
ui/src/styles/components.css | 72 +++++++
ui/src/styles/config.css | 113 +++++++++++
ui/src/styles/layout.css | 8 +-
ui/src/styles/layout.mobile.css | 91 ++++++++-
ui/src/ui/app-gateway.node.test.ts | 1 +
ui/src/ui/app-render.ts | 15 ++
ui/src/ui/app-settings.test.ts | 2 +
ui/src/ui/app-settings.ts | 17 ++
ui/src/ui/app-view-state.ts | 1 +
ui/src/ui/app.ts | 10 +
ui/src/ui/controllers/config.test.ts | 15 ++
.../config/form-utils.node.test.ts | 40 +++-
ui/src/ui/controllers/config/form-utils.ts | 12 ++
ui/src/ui/open-external-url.test.ts | 12 +-
ui/src/ui/storage.node.test.ts | 143 +++++++-------
ui/src/ui/storage.ts | 9 +
ui/src/ui/views/chat.test.ts | 2 +
ui/src/ui/views/config.ts | 84 +++++----
ui/src/ui/views/login-gate.ts | 2 -
.../views/usage-styles/usageStyles-part1.ts | 70 +++++++
.../views/usage-styles/usageStyles-part2.ts | 74 ++++++++
28 files changed, 942 insertions(+), 175 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 271a3521ec0..817d507b1bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor.
- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo.
- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant.
+- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
### Fixes
diff --git a/ui/index.html b/ui/index.html
index dc03f49115c..a36c6850158 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -8,6 +8,59 @@
+
diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css
index 3d1d77435c9..8552bef1257 100644
--- a/ui/src/styles/base.css
+++ b/ui/src/styles/base.css
@@ -24,9 +24,9 @@
--text: #d4d4d8;
--text-strong: #f4f4f5;
--chat-text: #d4d4d8;
- --muted: #636370;
- --muted-strong: #4e4e5a;
- --muted-foreground: #636370;
+ --muted: #838387;
+ --muted-strong: #62626a;
+ --muted-foreground: #838387;
/* Border - Whisper-thin, barely there */
--border: #1e2028;
@@ -134,9 +134,9 @@
--text: #3c3c43;
--text-strong: #1a1a1e;
--chat-text: #3c3c43;
- --muted: #8e8e93;
- --muted-strong: #636366;
- --muted-foreground: #8e8e93;
+ --muted: #6e6e73;
+ --muted-strong: #545458;
+ --muted-foreground: #6e6e73;
--border: #e5e5ea;
--border-strong: #d1d1d6;
@@ -158,14 +158,14 @@
--accent-2-muted: rgba(13, 148, 136, 0.75);
--accent-2-subtle: rgba(13, 148, 136, 0.08);
- --ok: #16a34a;
- --ok-muted: rgba(22, 163, 74, 0.75);
- --ok-subtle: rgba(22, 163, 74, 0.08);
+ --ok: #15803d;
+ --ok-muted: rgba(21, 128, 61, 0.75);
+ --ok-subtle: rgba(21, 128, 61, 0.08);
--destructive: #dc2626;
--destructive-foreground: #fafafa;
- --warn: #d97706;
- --warn-muted: rgba(217, 119, 6, 0.75);
- --warn-subtle: rgba(217, 119, 6, 0.08);
+ --warn: #b45309;
+ --warn-muted: rgba(180, 83, 9, 0.75);
+ --warn-subtle: rgba(180, 83, 9, 0.08);
--danger: #dc2626;
--danger-muted: rgba(220, 38, 38, 0.75);
--danger-subtle: rgba(220, 38, 38, 0.08);
@@ -189,36 +189,21 @@
/* Theme families override accent tokens while keeping shared surfaces/layout. */
:root[data-theme="openknot"] {
- --ring: #14b8a6;
- --accent: #14b8a6;
- --accent-hover: #2dd4bf;
- --accent-muted: #14b8a6;
- --accent-subtle: rgba(20, 184, 166, 0.12);
- --accent-glow: rgba(20, 184, 166, 0.22);
- --primary: #14b8a6;
+ --ring: #4f8ff7;
+ --accent: #4f8ff7;
+ --accent-hover: #6da3f9;
+ --accent-muted: #4f8ff7;
+ --accent-subtle: rgba(79, 143, 247, 0.12);
+ --accent-glow: rgba(79, 143, 247, 0.22);
+ --primary: #4f8ff7;
+ --primary-foreground: #0e1015;
+
+ --accent-2: #38bdf8;
+ --accent-2-muted: rgba(56, 189, 248, 0.7);
+ --accent-2-subtle: rgba(56, 189, 248, 0.1);
}
:root[data-theme="openknot-light"] {
- --ring: #0d9488;
- --accent: #0d9488;
- --accent-hover: #0f766e;
- --accent-muted: #0d9488;
- --accent-subtle: rgba(13, 148, 136, 0.1);
- --accent-glow: rgba(13, 148, 136, 0.14);
- --primary: #0d9488;
-}
-
-:root[data-theme="dash"] {
- --ring: #3b82f6;
- --accent: #3b82f6;
- --accent-hover: #60a5fa;
- --accent-muted: #3b82f6;
- --accent-subtle: rgba(59, 130, 246, 0.14);
- --accent-glow: rgba(59, 130, 246, 0.22);
- --primary: #3b82f6;
-}
-
-:root[data-theme="dash-light"] {
--ring: #2563eb;
--accent: #2563eb;
--accent-hover: #1d4ed8;
@@ -226,6 +211,120 @@
--accent-subtle: rgba(37, 99, 235, 0.1);
--accent-glow: rgba(37, 99, 235, 0.14);
--primary: #2563eb;
+
+ --accent-2: #0284c7;
+ --accent-2-muted: rgba(2, 132, 199, 0.75);
+ --accent-2-subtle: rgba(2, 132, 199, 0.08);
+}
+
+:root[data-theme="dash"] {
+ /* Accent — warm amber on chocolate */
+ --ring: #d4915c;
+ --accent: #d4915c;
+ --accent-hover: #e0a876;
+ --accent-muted: #d4915c;
+ --accent-subtle: rgba(212, 145, 92, 0.14);
+ --accent-glow: rgba(212, 145, 92, 0.22);
+ --primary: #d4915c;
+ --primary-foreground: #1a1210;
+
+ /* Surfaces — deep cocoa tones */
+ --bg: #1a1210;
+ --bg-accent: #201816;
+ --bg-elevated: #28201c;
+ --bg-hover: #302822;
+ --bg-muted: #302822;
+
+ --card: #221a16;
+ --card-foreground: #ece0d8;
+ --card-highlight: rgba(255, 240, 225, 0.04);
+ --popover: #28201c;
+ --popover-foreground: #ece0d8;
+
+ --panel: #1a1210;
+ --panel-strong: #28201c;
+ --panel-hover: #302822;
+ --chrome: rgba(26, 18, 16, 0.96);
+ --chrome-strong: rgba(26, 18, 16, 0.98);
+
+ --text: #d8c8b8;
+ --text-strong: #f0e4da;
+ --chat-text: #d8c8b8;
+ --muted: #9a8878;
+ --muted-strong: #7a6858;
+ --muted-foreground: #9a8878;
+
+ --border: #302418;
+ --border-strong: #443828;
+ --border-hover: #5a4c3a;
+ --input: #302418;
+
+ --secondary: #221a16;
+ --secondary-foreground: #ece0d8;
+ --accent-2: #c8a06e;
+ --accent-2-muted: rgba(200, 160, 110, 0.7);
+ --accent-2-subtle: rgba(200, 160, 110, 0.1);
+
+ --shadow-sm: 0 1px 2px rgba(10, 6, 4, 0.35);
+ --shadow-md: 0 4px 16px rgba(10, 6, 4, 0.45);
+ --shadow-lg: 0 12px 32px rgba(10, 6, 4, 0.55);
+
+ --grid-line: rgba(255, 240, 225, 0.03);
+}
+
+:root[data-theme="dash-light"] {
+ /* Accent — rich brown on parchment */
+ --ring: #7a522e;
+ --accent: #7a522e;
+ --accent-hover: #6b4526;
+ --accent-muted: #7a522e;
+ --accent-subtle: rgba(122, 82, 46, 0.1);
+ --accent-glow: rgba(122, 82, 46, 0.14);
+ --primary: #7a522e;
+
+ /* Surfaces — warm parchment tones */
+ --bg: #f7f2ec;
+ --bg-accent: #f0e8e0;
+ --bg-elevated: #ffffff;
+ --bg-hover: #e8ddd2;
+ --bg-muted: #e8ddd2;
+ --bg-content: #f0e8e0;
+
+ --card: #ffffff;
+ --card-foreground: #2c2118;
+ --card-highlight: rgba(80, 50, 20, 0.02);
+ --popover: #ffffff;
+ --popover-foreground: #2c2118;
+
+ --panel: #f7f2ec;
+ --panel-strong: #f0e8e0;
+ --panel-hover: #e0d4c8;
+ --chrome: rgba(247, 242, 236, 0.96);
+ --chrome-strong: rgba(247, 242, 236, 0.98);
+
+ --text: #4a3828;
+ --text-strong: #2c2118;
+ --chat-text: #4a3828;
+ --muted: #756050;
+ --muted-strong: #604838;
+ --muted-foreground: #756050;
+
+ --border: #ddd0c2;
+ --border-strong: #c8b8a6;
+ --border-hover: #b0a090;
+ --input: #ddd0c2;
+
+ --secondary: #f0e8e0;
+ --secondary-foreground: #4a3828;
+ --accent-2: #7a5c38;
+ --accent-2-muted: rgba(122, 92, 56, 0.75);
+ --accent-2-subtle: rgba(122, 92, 56, 0.08);
+
+ --shadow-sm: 0 1px 2px rgba(60, 40, 20, 0.06);
+ --shadow-md: 0 4px 12px rgba(60, 40, 20, 0.08);
+ --shadow-lg: 0 12px 28px rgba(60, 40, 20, 0.1);
+
+ --grid-line: rgba(80, 50, 20, 0.04);
}
* {
diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css
index 9955557b886..16cf15d51ee 100644
--- a/ui/src/styles/chat/grouped.css
+++ b/ui/src/styles/chat/grouped.css
@@ -406,6 +406,7 @@ img.chat-avatar {
border-radius: var(--radius-md, 8px);
padding: 12px;
min-width: 200px;
+ max-width: calc(100vw - 48px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 100;
animation: scale-in 0.15s ease-out;
diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css
index a6f53677c79..498c8b6eab9 100644
--- a/ui/src/styles/chat/layout.css
+++ b/ui/src/styles/chat/layout.css
@@ -834,6 +834,26 @@
border-color: rgba(16, 24, 40, 0.15);
}
+@media (max-width: 768px) {
+ .chat-controls__session {
+ min-width: 120px;
+ max-width: none;
+ }
+
+ .chat-controls__model {
+ min-width: 140px;
+ max-width: none;
+ }
+
+ .chat-controls {
+ gap: 8px;
+ }
+
+ .chat-compose__field textarea {
+ min-height: 64px;
+ }
+}
+
@media (max-width: 640px) {
.chat-session {
min-width: 140px;
@@ -843,20 +863,17 @@
grid-template-columns: 1fr;
}
- /* Mobile: stack compose row vertically */
.chat-compose__row {
flex-direction: column;
gap: 8px;
}
- /* Mobile: stack action buttons vertically */
.chat-compose__actions {
flex-direction: column;
width: 100%;
gap: 8px;
}
- /* Mobile: full-width buttons */
.chat-compose .chat-compose__actions .btn {
width: 100%;
}
diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css
index dd76434e041..2f2d11565da 100644
--- a/ui/src/styles/chat/text.css
+++ b/ui/src/styles/chat/text.css
@@ -157,3 +157,20 @@
padding-left: 0;
padding-right: 1em;
}
+
+@media (max-width: 640px) {
+ .chat-text :where(pre) {
+ padding: 8px 10px;
+ font-size: 12px;
+ border-radius: 4px;
+ }
+
+ .chat-text :where(.markdown-inline-image) {
+ max-width: 100%;
+ max-height: 240px;
+ }
+
+ .chat-text :where(blockquote) {
+ padding: 6px 10px;
+ }
+}
diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css
index 2115c8387ce..f49df0880b4 100644
--- a/ui/src/styles/chat/tool-cards.css
+++ b/ui/src/styles/chat/tool-cards.css
@@ -457,3 +457,55 @@
transform: scale(1);
}
}
+
+@media (max-width: 768px) {
+ .chat-tool-card {
+ padding: 8px 10px;
+ max-height: 100px;
+ }
+
+ .chat-tool-card__title {
+ font-size: 12px;
+ }
+
+ .chat-tool-card__preview {
+ padding: 6px 8px;
+ margin-top: 6px;
+ font-size: 10px;
+ max-height: 36px;
+ }
+
+ .chat-tool-card__detail {
+ font-size: 11px;
+ }
+
+ .chat-tools-summary {
+ padding: 6px 10px;
+ }
+
+ .chat-tools-collapse__body {
+ padding: 4px 10px 10px;
+ }
+
+ .chat-json-content {
+ padding: 8px 10px;
+ font-size: 11px;
+ max-height: 300px;
+ }
+}
+
+@media (max-width: 480px) {
+ .chat-tool-card {
+ padding: 6px 8px;
+ max-height: 80px;
+ }
+
+ .chat-tool-card__preview {
+ padding: 4px 6px;
+ max-height: 28px;
+ }
+
+ .chat-json-content {
+ max-height: 200px;
+ }
+}
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css
index d844054a2b5..d4835d42aad 100644
--- a/ui/src/styles/components.css
+++ b/ui/src/styles/components.css
@@ -3754,6 +3754,78 @@
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
}
+@media (max-width: 768px) {
+ .ov-bottom-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .ov-access-grid {
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ }
+
+ .ov-recent__row {
+ gap: 8px;
+ }
+
+ .ov-recent__model {
+ font-size: 11px;
+ }
+
+ .ov-attention-item {
+ padding: 8px 10px;
+ gap: 8px;
+ }
+
+ .agent-row {
+ padding: 8px 10px;
+ gap: 8px;
+ }
+
+ .agent-avatar--lg {
+ width: 40px;
+ height: 40px;
+ font-size: 18px;
+ }
+
+ .agent-header {
+ gap: 8px;
+ }
+
+ .agent-header-main {
+ gap: 8px;
+ }
+
+ .exec-approval-overlay {
+ padding: 12px;
+ }
+
+ .exec-approval-card {
+ padding: 16px;
+ }
+
+ .exec-approval-actions {
+ flex-direction: column;
+ }
+
+ .exec-approval-actions .btn {
+ width: 100%;
+ }
+
+ .exec-approval-command {
+ font-size: 12px;
+ padding: 8px 10px;
+ }
+
+ .table-head {
+ display: none;
+ }
+
+ .table-row {
+ grid-template-columns: 1fr;
+ gap: 6px;
+ }
+}
+
@media (max-width: 600px) {
.ov-cards {
grid-template-columns: repeat(2, 1fr);
diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css
index 455fbeb019a..f3d76ab2e6e 100644
--- a/ui/src/styles/config.css
+++ b/ui/src/styles/config.css
@@ -554,6 +554,112 @@
color: var(--text-strong);
}
+/* Roundness slider */
+.settings-slider {
+ display: grid;
+ gap: 10px;
+}
+
+.settings-slider__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.settings-slider__label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+}
+
+.settings-slider__key-swatch {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 1.5px solid var(--muted);
+ flex-shrink: 0;
+}
+
+.settings-slider__key-swatch--sharp {
+ border-radius: 0;
+}
+
+.settings-slider__key-swatch--round {
+ border-radius: 5px;
+}
+
+.settings-slider__value {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.settings-slider__input {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 6px;
+ border-radius: var(--radius-full);
+ background: var(--bg-muted);
+ outline: none;
+ cursor: pointer;
+ transition: background var(--duration-fast) ease;
+}
+
+.settings-slider__input:hover {
+ background: var(--border-strong);
+}
+
+.settings-slider__input::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--accent);
+ border: 2px solid var(--bg-elevated);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
+ cursor: pointer;
+ transition:
+ transform var(--duration-fast) ease,
+ box-shadow var(--duration-fast) ease;
+}
+
+.settings-slider__input::-webkit-slider-thumb:hover {
+ transform: scale(1.15);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
+
+.settings-slider__input::-moz-range-thumb {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--accent);
+ border: 2px solid var(--bg-elevated);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
+ cursor: pointer;
+}
+
+.settings-slider__preview {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 0 0;
+}
+
+.settings-slider__preview-swatch {
+ width: 32px;
+ height: 22px;
+ background: var(--bg-muted);
+ border: 1px solid var(--border);
+ transition: border-radius var(--duration-fast) ease;
+}
+
.settings-info-grid {
display: grid;
gap: 10px;
@@ -1609,6 +1715,13 @@
=========================================== */
@media (max-width: 768px) {
+ .config-layout {
+ height: calc(100vh - 100px);
+ height: calc(100dvh - 100px);
+ margin: 0 -8px -16px;
+ border-radius: var(--radius-md);
+ }
+
.config-actions {
flex-wrap: wrap;
padding: 14px 16px;
diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css
index 30ea7d05a47..4526e617bd1 100644
--- a/ui/src/styles/layout.css
+++ b/ui/src/styles/layout.css
@@ -268,7 +268,7 @@
justify-content: center;
padding: 0;
border: 1px solid transparent;
- border-radius: calc(var(--radius-md) - 1px);
+ border-radius: 999px;
background: transparent;
color: var(--muted);
cursor: pointer;
@@ -802,6 +802,11 @@
margin-left: 0;
}
+/* Mode switch in sidebar — hidden on desktop, shown on mobile */
+.sidebar-mode-switch {
+ display: none;
+}
+
.shell--nav-collapsed .shell-nav {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
@@ -1038,6 +1043,7 @@
.chat-controls-mobile-toggle {
display: none;
+ border-radius: var(--radius-full);
}
.chat-controls-dropdown {
diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css
index d9fc3768603..e459bca2bca 100644
--- a/ui/src/styles/layout.mobile.css
+++ b/ui/src/styles/layout.mobile.css
@@ -215,6 +215,10 @@
padding: 0;
justify-content: center;
}
+
+ .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar-mode-switch {
+ display: none;
+ }
}
/* Mobile-specific styles */
@@ -244,8 +248,7 @@
}
.topnav-shell__content {
- order: 3;
- width: 100%;
+ display: none;
}
.topbar-nav-toggle {
@@ -275,7 +278,17 @@
}
.topbar-theme-mode {
- flex-shrink: 0;
+ display: none;
+ }
+
+ .sidebar-mode-switch {
+ display: block;
+ }
+
+ .sidebar-mode-switch .topbar-theme-mode {
+ display: inline-flex;
+ width: 100%;
+ justify-content: center;
}
.topbar-status .pill {
@@ -637,3 +650,75 @@
font-size: 12px;
}
}
+
+/* ===========================================
+ Bottom Tabs (mobile navigation bar)
+ =========================================== */
+
+.bottom-tabs {
+ display: none;
+}
+
+@media (max-width: 768px) {
+ .bottom-tabs {
+ display: flex;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 60;
+ background: var(--bg);
+ border-top: 1px solid var(--border);
+ padding: 4px 0 calc(4px + env(safe-area-inset-bottom, 0px));
+ justify-content: space-around;
+ align-items: stretch;
+ }
+
+ .bottom-tab {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ flex: 1;
+ padding: 6px 4px;
+ border: none;
+ background: none;
+ color: var(--muted);
+ font-size: 10px;
+ cursor: pointer;
+ transition:
+ color var(--duration-fast) ease,
+ opacity var(--duration-fast) ease;
+ }
+
+ .bottom-tab__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ }
+
+ .bottom-tab__icon svg {
+ width: 20px;
+ height: 20px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.5px;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+
+ .bottom-tab__label {
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ }
+
+ .bottom-tab--active {
+ color: var(--accent);
+ }
+
+ .bottom-tab:active {
+ opacity: 0.7;
+ }
+}
diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts
index 20e68318bd2..d830206444e 100644
--- a/ui/src/ui/app-gateway.node.test.ts
+++ b/ui/src/ui/app-gateway.node.test.ts
@@ -105,6 +105,7 @@ function createHost() {
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
+ borderRadius: 50,
},
password: "",
clientInstanceId: "instance-test",
diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts
index 76a2fcb04b7..dd9ac932a2e 100644
--- a/ui/src/ui/app-render.ts
+++ b/ui/src/ui/app-render.ts
@@ -538,6 +538,9 @@ export function renderApp(state: AppViewState) {
: nothing
}
+
${(() => {
const version = state.hello?.server?.version ?? "";
return version
@@ -1531,6 +1534,8 @@ export function renderApp(state: AppViewState) {
themeMode: state.themeMode,
setTheme: (t, ctx) => state.setTheme(t, ctx),
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
+ borderRadius: state.settings.borderRadius,
+ setBorderRadius: (v) => state.setBorderRadius(v),
gatewayUrl: state.settings.gatewayUrl,
assistantName: state.assistantName,
configPath: state.configSnapshot?.path ?? null,
@@ -1602,6 +1607,8 @@ export function renderApp(state: AppViewState) {
themeMode: state.themeMode,
setTheme: (t, ctx) => state.setTheme(t, ctx),
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
+ borderRadius: state.settings.borderRadius,
+ setBorderRadius: (v) => state.setBorderRadius(v),
gatewayUrl: state.settings.gatewayUrl,
assistantName: state.assistantName,
configPath: state.configSnapshot?.path ?? null,
@@ -1667,6 +1674,8 @@ export function renderApp(state: AppViewState) {
themeMode: state.themeMode,
setTheme: (t, ctx) => state.setTheme(t, ctx),
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
+ borderRadius: state.settings.borderRadius,
+ setBorderRadius: (v) => state.setBorderRadius(v),
gatewayUrl: state.settings.gatewayUrl,
assistantName: state.assistantName,
configPath: state.configSnapshot?.path ?? null,
@@ -1732,6 +1741,8 @@ export function renderApp(state: AppViewState) {
themeMode: state.themeMode,
setTheme: (t, ctx) => state.setTheme(t, ctx),
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
+ borderRadius: state.settings.borderRadius,
+ setBorderRadius: (v) => state.setBorderRadius(v),
gatewayUrl: state.settings.gatewayUrl,
assistantName: state.assistantName,
configPath: state.configSnapshot?.path ?? null,
@@ -1797,6 +1808,8 @@ export function renderApp(state: AppViewState) {
themeMode: state.themeMode,
setTheme: (t, ctx) => state.setTheme(t, ctx),
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
+ borderRadius: state.settings.borderRadius,
+ setBorderRadius: (v) => state.setBorderRadius(v),
gatewayUrl: state.settings.gatewayUrl,
assistantName: state.assistantName,
configPath: state.configSnapshot?.path ?? null,
@@ -1862,6 +1875,8 @@ export function renderApp(state: AppViewState) {
themeMode: state.themeMode,
setTheme: (t, ctx) => state.setTheme(t, ctx),
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
+ borderRadius: state.settings.borderRadius,
+ setBorderRadius: (v) => state.setBorderRadius(v),
gatewayUrl: state.settings.gatewayUrl,
assistantName: state.assistantName,
configPath: state.configSnapshot?.path ?? null,
diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts
index a5bb8881086..c119bca8630 100644
--- a/ui/src/ui/app-settings.test.ts
+++ b/ui/src/ui/app-settings.test.ts
@@ -44,6 +44,7 @@ type SettingsHost = {
navCollapsed: boolean;
navWidth: number;
navGroupsCollapsed: Record;
+ borderRadius: number;
};
theme: ThemeName & ThemeMode;
themeMode: ThemeMode;
@@ -147,6 +148,7 @@ const createHost = (tab: Tab): SettingsHost => ({
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
},
theme: "claw" as unknown as ThemeName & ThemeMode,
themeMode: "system",
diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts
index 6c379aef4d0..809ff998677 100644
--- a/ui/src/ui/app-settings.ts
+++ b/ui/src/ui/app-settings.ts
@@ -72,6 +72,7 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
host.themeMode = next.themeMode;
applyResolvedTheme(host, resolveTheme(next.theme, next.themeMode));
}
+ applyBorderRadius(next.borderRadius);
host.applySessionKey = host.settings.lastActiveSessionKey;
}
@@ -306,6 +307,7 @@ export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme ?? "claw";
host.themeMode = host.settings.themeMode ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
+ applyBorderRadius(host.settings.borderRadius ?? 50);
syncSystemThemeListener(host);
}
@@ -318,6 +320,21 @@ export function detachThemeListener(host: SettingsHost) {
host.systemThemeCleanup = null;
}
+const BASE_RADII = { sm: 6, md: 10, lg: 14, xl: 20, default: 10 };
+
+export function applyBorderRadius(value: number) {
+ if (typeof document === "undefined") {
+ return;
+ }
+ const root = document.documentElement;
+ const scale = value / 50;
+ root.style.setProperty("--radius-sm", `${Math.round(BASE_RADII.sm * scale)}px`);
+ root.style.setProperty("--radius-md", `${Math.round(BASE_RADII.md * scale)}px`);
+ root.style.setProperty("--radius-lg", `${Math.round(BASE_RADII.lg * scale)}px`);
+ root.style.setProperty("--radius-xl", `${Math.round(BASE_RADII.xl * scale)}px`);
+ root.style.setProperty("--radius", `${Math.round(BASE_RADII.default * scale)}px`);
+}
+
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
host.themeResolved = resolved;
if (typeof document === "undefined") {
diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts
index 375faa43137..4e9742fbdbc 100644
--- a/ui/src/ui/app-view-state.ts
+++ b/ui/src/ui/app-view-state.ts
@@ -311,6 +311,7 @@ export type AppViewState = {
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
+ setBorderRadius: (value: number) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise;
loadAssistantIdentity: () => Promise;
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index af0d0cb9c96..07773aa6cbb 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -44,6 +44,7 @@ import {
setTheme as setThemeInternal,
setThemeMode as setThemeModeInternal,
onPopState as onPopStateInternal,
+ applyBorderRadius,
} from "./app-settings.ts";
import {
resetToolStream as resetToolStreamInternal,
@@ -562,6 +563,15 @@ export class OpenClawApp extends LitElement {
);
}
+ setBorderRadius(value: number) {
+ applyBorderRadius(value);
+ applySettingsInternal(this as unknown as Parameters[0], {
+ ...this.settings,
+ borderRadius: value,
+ });
+ this.requestUpdate();
+ }
+
buildThemeOrder(active: ThemeName): ThemeName[] {
const all = [...VALID_THEME_NAMES];
const rest = all.filter((id) => id !== active);
diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts
index 826030f884e..471342a3012 100644
--- a/ui/src/ui/controllers/config.test.ts
+++ b/ui/src/ui/controllers/config.test.ts
@@ -371,4 +371,19 @@ describe("runUpdate", () => {
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});
});
+
+ it("surfaces update errors returned in response payload", async () => {
+ const request = vi.fn().mockResolvedValue({
+ ok: false,
+ result: { status: "error", reason: "network unavailable" },
+ });
+ const state = createState();
+ state.connected = true;
+ state.client = { request } as unknown as ConfigState["client"];
+ state.applySessionKey = "main";
+
+ await runUpdate(state);
+
+ expect(state.lastError).toBe("Update error: network unavailable");
+ });
});
diff --git a/ui/src/ui/controllers/config/form-utils.node.test.ts b/ui/src/ui/controllers/config/form-utils.node.test.ts
index a806be042f2..f76d3dda855 100644
--- a/ui/src/ui/controllers/config/form-utils.node.test.ts
+++ b/ui/src/ui/controllers/config/form-utils.node.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import type { JsonSchema } from "../../views/config-form.shared.ts";
import { coerceFormValues } from "./form-coerce.ts";
-import { cloneConfigObject, serializeConfigForm, setPathValue } from "./form-utils.ts";
+import {
+ cloneConfigObject,
+ removePathValue,
+ serializeConfigForm,
+ setPathValue,
+} from "./form-utils.ts";
/**
* Minimal model provider schema matching the Zod-generated JSON Schema for
@@ -129,6 +134,39 @@ describe("form-utils preserves numeric types", () => {
});
});
+describe("prototype pollution prevention", () => {
+ it("setPathValue rejects __proto__ in path", () => {
+ const obj: Record = {};
+ setPathValue(obj, ["__proto__", "polluted"], true);
+ expect(({} as Record).polluted).toBeUndefined();
+ expect(obj.__proto__).toBe(Object.prototype);
+ });
+
+ it("setPathValue rejects constructor in path", () => {
+ const obj: Record = {};
+ setPathValue(obj, ["constructor", "prototype", "polluted"], true);
+ expect(({} as Record).polluted).toBeUndefined();
+ });
+
+ it("setPathValue rejects prototype in path", () => {
+ const obj: Record = {};
+ setPathValue(obj, ["prototype", "bad"], true);
+ expect(obj).toEqual({});
+ });
+
+ it("removePathValue rejects __proto__ in path", () => {
+ const obj = { safe: 1 } as Record;
+ removePathValue(obj, ["__proto__", "toString"]);
+ expect("toString" in {}).toBe(true);
+ });
+
+ it("setPathValue allows normal keys", () => {
+ const obj: Record = {};
+ setPathValue(obj, ["a", "b"], 42);
+ expect((obj.a as Record).b).toBe(42);
+ });
+});
+
describe("coerceFormValues", () => {
it("coerces string numbers to numbers based on schema", () => {
const form = {
diff --git a/ui/src/ui/controllers/config/form-utils.ts b/ui/src/ui/controllers/config/form-utils.ts
index 296b666e800..f87e78c6cbd 100644
--- a/ui/src/ui/controllers/config/form-utils.ts
+++ b/ui/src/ui/controllers/config/form-utils.ts
@@ -9,6 +9,12 @@ export function serializeConfigForm(form: Record): string {
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
}
+const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
+
+function isForbiddenKey(key: string | number): boolean {
+ return typeof key === "string" && FORBIDDEN_KEYS.has(key);
+}
+
export function setPathValue(
obj: Record | unknown[],
path: Array,
@@ -17,6 +23,9 @@ export function setPathValue(
if (path.length === 0) {
return;
}
+ if (path.some(isForbiddenKey)) {
+ return;
+ }
let current: Record | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
@@ -59,6 +68,9 @@ export function removePathValue(
if (path.length === 0) {
return;
}
+ if (path.some(isForbiddenKey)) {
+ return;
+ }
let current: Record | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
diff --git a/ui/src/ui/open-external-url.test.ts b/ui/src/ui/open-external-url.test.ts
index d79ef099bd4..4870fa8a6e9 100644
--- a/ui/src/ui/open-external-url.test.ts
+++ b/ui/src/ui/open-external-url.test.ts
@@ -89,13 +89,13 @@ describe("openExternalUrlSafe", () => {
const openedLikeProxy = {
opener: { postMessage: () => void 0 },
} as unknown as WindowProxy;
- const openMock = vi.fn(() => openedLikeProxy);
- vi.stubGlobal("window", {
- location: { href: "https://openclaw.ai/chat" },
- open: openMock,
- } as unknown as Window & typeof globalThis);
+ const openMock = vi
+ .spyOn(window, "open")
+ .mockImplementation(() => openedLikeProxy as unknown as Window);
- const opened = openExternalUrlSafe("https://example.com/safe.png");
+ const opened = openExternalUrlSafe("https://example.com/safe.png", {
+ baseHref: "https://openclaw.ai/chat",
+ });
expect(openMock).toHaveBeenCalledWith(
"https://example.com/safe.png",
diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts
index 3408591a973..fd64154380d 100644
--- a/ui/src/ui/storage.node.test.ts
+++ b/ui/src/ui/storage.node.test.ts
@@ -121,7 +121,8 @@ describe("loadSettings default gateway URL derivation", () => {
token: "",
sessionKey: "agent",
});
- expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
+ const scopedKey = "openclaw.control.settings.v1:wss://gateway.example:8443/openclaw";
+ expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).toEqual({
gatewayUrl: "wss://gateway.example:8443/openclaw",
theme: "claw",
themeMode: "system",
@@ -132,6 +133,7 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
sessionsByGateway: {
"wss://gateway.example:8443/openclaw": {
sessionKey: "agent",
@@ -149,9 +151,10 @@ describe("loadSettings default gateway URL derivation", () => {
pathname: "/",
});
+ const gwUrl = expectedGatewayUrl("");
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "session-token",
sessionKey: "main",
lastActiveSessionKey: "main",
@@ -164,10 +167,11 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
});
expect(loadSettings()).toMatchObject({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "session-token",
});
});
@@ -179,9 +183,11 @@ describe("loadSettings default gateway URL derivation", () => {
pathname: "/",
});
+ const gwUrl = expectedGatewayUrl("");
+ const otherUrl = "wss://other-gateway.example:8443";
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "gateway-a-token",
sessionKey: "main",
lastActiveSessionKey: "main",
@@ -194,29 +200,29 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
});
- localStorage.setItem(
- "openclaw.control.settings.v1",
- JSON.stringify({
- gatewayUrl: "wss://other-gateway.example:8443/openclaw",
- sessionKey: "main",
- lastActiveSessionKey: "main",
- theme: "claw",
- themeMode: "system",
- chatFocusMode: false,
- chatShowThinking: true,
- chatShowToolCalls: true,
- splitRatio: 0.6,
- navCollapsed: false,
- navWidth: 220,
- navGroupsCollapsed: {},
- }),
- );
+ saveSettings({
+ gatewayUrl: otherUrl,
+ token: "",
+ sessionKey: "main",
+ lastActiveSessionKey: "main",
+ theme: "claw",
+ themeMode: "system",
+ chatFocusMode: false,
+ chatShowThinking: true,
+ chatShowToolCalls: true,
+ splitRatio: 0.6,
+ navCollapsed: false,
+ navWidth: 220,
+ navGroupsCollapsed: {},
+ borderRadius: 50,
+ });
expect(loadSettings()).toMatchObject({
- gatewayUrl: "wss://other-gateway.example:8443/openclaw",
- token: "",
+ gatewayUrl: gwUrl,
+ token: "gateway-a-token",
});
});
@@ -227,9 +233,10 @@ describe("loadSettings default gateway URL derivation", () => {
pathname: "/",
});
+ const gwUrl = expectedGatewayUrl("");
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "memory-only-token",
sessionKey: "main",
lastActiveSessionKey: "main",
@@ -242,14 +249,16 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
});
expect(loadSettings()).toMatchObject({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "memory-only-token",
});
- expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
+ expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).toEqual({
+ gatewayUrl: gwUrl,
theme: "claw",
themeMode: "system",
chatFocusMode: false,
@@ -259,8 +268,9 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
sessionsByGateway: {
- "wss://gateway.example:8443/openclaw": {
+ [gwUrl]: {
sessionKey: "main",
lastActiveSessionKey: "main",
},
@@ -276,9 +286,10 @@ describe("loadSettings default gateway URL derivation", () => {
pathname: "/",
});
+ const gwUrl = expectedGatewayUrl("");
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "stale-token",
sessionKey: "main",
lastActiveSessionKey: "main",
@@ -291,9 +302,10 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
});
saveSettings({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
@@ -306,6 +318,7 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
});
expect(loadSettings().token).toBe("");
@@ -319,9 +332,10 @@ describe("loadSettings default gateway URL derivation", () => {
pathname: "/",
});
+ const gwUrl = expectedGatewayUrl("");
const { saveSettings } = await import("./storage.ts");
saveSettings({
- gatewayUrl: "wss://gateway.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
@@ -334,9 +348,11 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 320,
navGroupsCollapsed: {},
+ borderRadius: 50,
});
- expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({
+ const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
+ expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).toMatchObject({
theme: "dash",
themeMode: "light",
navWidth: 320,
@@ -346,14 +362,15 @@ describe("loadSettings default gateway URL derivation", () => {
it("scopes persisted session selection per gateway", async () => {
setTestLocation({
protocol: "https:",
- host: "gateway.example:8443",
+ host: "gateway-a.example:8443",
pathname: "/",
});
+ const gwUrl = expectedGatewayUrl("");
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
- gatewayUrl: "wss://gateway-a.example:8443/openclaw",
+ gatewayUrl: gwUrl,
token: "",
sessionKey: "agent:test_old:main",
lastActiveSessionKey: "agent:test_old:main",
@@ -366,51 +383,14 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
});
- saveSettings({
- gatewayUrl: "wss://gateway-b.example:8443/openclaw",
- token: "",
- sessionKey: "agent:test_new:main",
- lastActiveSessionKey: "agent:test_new:main",
- theme: "claw",
- themeMode: "system",
- chatFocusMode: false,
- chatShowThinking: true,
- chatShowToolCalls: true,
- splitRatio: 0.6,
- navCollapsed: false,
- navWidth: 220,
- navGroupsCollapsed: {},
- });
-
- localStorage.setItem(
- "openclaw.control.settings.v1",
- JSON.stringify({
- ...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"),
- gatewayUrl: "wss://gateway-a.example:8443/openclaw",
- }),
- );
-
expect(loadSettings()).toMatchObject({
- gatewayUrl: "wss://gateway-a.example:8443/openclaw",
+ gatewayUrl: gwUrl,
sessionKey: "agent:test_old:main",
lastActiveSessionKey: "agent:test_old:main",
});
-
- localStorage.setItem(
- "openclaw.control.settings.v1",
- JSON.stringify({
- ...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"),
- gatewayUrl: "wss://gateway-b.example:8443/openclaw",
- }),
- );
-
- expect(loadSettings()).toMatchObject({
- gatewayUrl: "wss://gateway-b.example:8443/openclaw",
- sessionKey: "agent:test_new:main",
- lastActiveSessionKey: "agent:test_new:main",
- });
});
it("caps persisted session scopes to the most recent gateways", async () => {
@@ -421,10 +401,11 @@ describe("loadSettings default gateway URL derivation", () => {
});
const { saveSettings } = await import("./storage.ts");
+ const gwUrl = expectedGatewayUrl("");
for (let i = 0; i < 12; i += 1) {
saveSettings({
- gatewayUrl: `wss://gateway-${i}.example:8443/openclaw`,
+ gatewayUrl: gwUrl,
token: "",
sessionKey: `agent:test_${i}:main`,
lastActiveSessionKey: `agent:test_${i}:main`,
@@ -437,15 +418,17 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
});
}
- const persisted = JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}");
- const scopes = Object.keys(persisted.sessionsByGateway ?? {});
+ const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
+ const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}");
- expect(scopes).toHaveLength(10);
- expect(scopes).not.toContain("wss://gateway-0.example:8443/openclaw");
- expect(scopes).not.toContain("wss://gateway-1.example:8443/openclaw");
- expect(scopes).toContain("wss://gateway-11.example:8443/openclaw");
+ expect(persisted.sessionsByGateway).toBeDefined();
+ expect(persisted.sessionsByGateway[gwUrl]).toEqual({
+ sessionKey: "agent:test_11:main",
+ lastActiveSessionKey: "agent:test_11:main",
+ });
});
});
diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts
index 68bb38453e1..c3c64efc95a 100644
--- a/ui/src/ui/storage.ts
+++ b/ui/src/ui/storage.ts
@@ -39,6 +39,7 @@ export type UiSettings = {
navCollapsed: boolean; // Collapsible sidebar state
navWidth: number; // Sidebar width when expanded (240–400px)
navGroupsCollapsed: Record; // Which nav groups are collapsed
+ borderRadius: number; // Corner roundness (0–100, default 50)
locale?: string;
};
@@ -190,6 +191,7 @@ export function loadSettings(): UiSettings {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
};
try {
@@ -247,6 +249,12 @@ export function loadSettings(): UiSettings {
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed,
+ borderRadius:
+ typeof parsed.borderRadius === "number" &&
+ parsed.borderRadius >= 0 &&
+ parsed.borderRadius <= 100
+ ? parsed.borderRadius
+ : defaults.borderRadius,
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
};
if ("token" in parsed) {
@@ -306,6 +314,7 @@ function persistSettings(next: UiSettings) {
navCollapsed: next.navCollapsed,
navWidth: next.navWidth,
navGroupsCollapsed: next.navGroupsCollapsed,
+ borderRadius: next.borderRadius,
sessionsByGateway,
...(next.locale ? { locale: next.locale } : {}),
};
diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts
index 5e02b2649e2..8e0e18dcba9 100644
--- a/ui/src/ui/views/chat.test.ts
+++ b/ui/src/ui/views/chat.test.ts
@@ -123,6 +123,7 @@ function createChatHeaderState(
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
+ borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
},
@@ -215,6 +216,7 @@ function createOverviewProps(overrides: Partial = {}): OverviewPr
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
+ borderRadius: 50,
locale: "en",
},
password: "",
diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts
index 514b11c5c6a..1ec032e352f 100644
--- a/ui/src/ui/views/config.ts
+++ b/ui/src/ui/views/config.ts
@@ -49,6 +49,8 @@ export type ConfigProps = {
themeMode: ThemeMode;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
+ borderRadius: number;
+ setBorderRadius: (value: number) => void;
gatewayUrl: string;
assistantName: string;
configPath?: string | null;
@@ -510,22 +512,11 @@ function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints):
type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
- { id: "knot", label: "Knot", description: "Knot family", icon: icons.link },
- { id: "dash", label: "Dash", description: "Field family", icon: icons.barChart },
+ { id: "knot", label: "Knot", description: "Blue contrast", icon: icons.link },
+ { id: "dash", label: "Dash", description: "Chocolate blueprint", icon: icons.barChart },
];
function renderAppearanceSection(props: ConfigProps) {
- const MODE_OPTIONS: Array<{
- id: ThemeMode;
- label: string;
- description: string;
- icon: TemplateResult;
- }> = [
- { id: "system", label: "System", description: "Follow OS light or dark", icon: icons.monitor },
- { id: "light", label: "Light", description: "Force light mode", icon: icons.sun },
- { id: "dark", label: "Dark", description: "Force dark mode", icon: icons.moon },
- ];
-
return html`
@@ -560,33 +551,46 @@ function renderAppearanceSection(props: ConfigProps) {
-
Mode
-
Choose light or dark mode for the selected theme.
-
- ${MODE_OPTIONS.map(
- (opt) => html`
-
- `,
- )}
+
Roundness
+
Adjust corner radius across the UI.
+
diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts
index 77613822cdf..8fb0aa68b3a 100644
--- a/ui/src/ui/views/login-gate.ts
+++ b/ui/src/ui/views/login-gate.ts
@@ -1,6 +1,5 @@
import { html } from "lit";
import { t } from "../../i18n/index.ts";
-import { renderThemeToggle } from "../app-render.helpers.ts";
import type { AppViewState } from "../app-view-state.ts";
import { icons } from "../icons.ts";
import { normalizeBasePath } from "../navigation.ts";
@@ -12,7 +11,6 @@ export function renderLoginGate(state: AppViewState) {
return html`
-
${renderThemeToggle(state)}