mirror of https://github.com/openclaw/openclaw.git
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
This commit is contained in:
parent
1a9114a169
commit
df72ca1ece
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,59 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<script>
|
||||
(function () {
|
||||
var THEMES = { claw: 1, knot: 1, dash: 1 };
|
||||
var MODES = { system: 1, light: 1, dark: 1 };
|
||||
var LEGACY = {
|
||||
dark: "claw:dark",
|
||||
light: "claw:light",
|
||||
openknot: "knot:dark",
|
||||
fieldmanual: "dash:dark",
|
||||
clawdash: "dash:light",
|
||||
system: "claw:system",
|
||||
};
|
||||
try {
|
||||
var keys = Object.keys(localStorage);
|
||||
var raw;
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (keys[i].indexOf("openclaw.control.settings.v1") === 0) {
|
||||
raw = localStorage.getItem(keys[i]);
|
||||
if (raw) break;
|
||||
}
|
||||
}
|
||||
if (!raw) return;
|
||||
var s = JSON.parse(raw);
|
||||
var t = s && s.theme;
|
||||
var m = s && s.themeMode;
|
||||
if (typeof t !== "string") t = "";
|
||||
if (typeof m !== "string") m = "";
|
||||
var legacy = LEGACY[t];
|
||||
var theme = THEMES[t] ? t : legacy ? legacy.split(":")[0] : "claw";
|
||||
var mode = MODES[m] ? m : legacy ? legacy.split(":")[1] : "system";
|
||||
if (mode === "system") {
|
||||
mode = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
}
|
||||
var resolved =
|
||||
theme === "knot"
|
||||
? mode === "light"
|
||||
? "openknot-light"
|
||||
: "openknot"
|
||||
: theme === "dash"
|
||||
? mode === "light"
|
||||
? "dash-light"
|
||||
: "dash"
|
||||
: mode === "light"
|
||||
? "light"
|
||||
: "dark";
|
||||
document.documentElement.setAttribute("data-theme", resolved);
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme-mode",
|
||||
resolved.indexOf("light") !== -1 ? "light" : "dark",
|
||||
);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<openclaw-app></openclaw-app>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ function createHost() {
|
|||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
},
|
||||
password: "",
|
||||
clientInstanceId: "instance-test",
|
||||
|
|
|
|||
|
|
@ -538,6 +538,9 @@ export function renderApp(state: AppViewState) {
|
|||
: nothing
|
||||
}
|
||||
</a>
|
||||
<div class="sidebar-mode-switch">
|
||||
${renderTopbarThemeModeToggle(state)}
|
||||
</div>
|
||||
${(() => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type SettingsHost = {
|
|||
navCollapsed: boolean;
|
||||
navWidth: number;
|
||||
navGroupsCollapsed: Record<string, boolean>;
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
loadAssistantIdentity: () => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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<typeof applySettingsInternal>[0], {
|
||||
...this.settings,
|
||||
borderRadius: value,
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
buildThemeOrder(active: ThemeName): ThemeName[] {
|
||||
const all = [...VALID_THEME_NAMES];
|
||||
const rest = all.filter((id) => id !== active);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {};
|
||||
setPathValue(obj, ["__proto__", "polluted"], true);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect(obj.__proto__).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it("setPathValue rejects constructor in path", () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setPathValue(obj, ["constructor", "prototype", "polluted"], true);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setPathValue rejects prototype in path", () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setPathValue(obj, ["prototype", "bad"], true);
|
||||
expect(obj).toEqual({});
|
||||
});
|
||||
|
||||
it("removePathValue rejects __proto__ in path", () => {
|
||||
const obj = { safe: 1 } as Record<string, unknown>;
|
||||
removePathValue(obj, ["__proto__", "toString"]);
|
||||
expect("toString" in {}).toBe(true);
|
||||
});
|
||||
|
||||
it("setPathValue allows normal keys", () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setPathValue(obj, ["a", "b"], 42);
|
||||
expect((obj.a as Record<string, unknown>).b).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceFormValues", () => {
|
||||
it("coerces string numbers to numbers based on schema", () => {
|
||||
const form = {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ export function serializeConfigForm(form: Record<string, unknown>): 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<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
|
|
@ -17,6 +23,9 @@ export function setPathValue(
|
|||
if (path.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (path.some(isForbiddenKey)) {
|
||||
return;
|
||||
}
|
||||
let current: Record<string, unknown> | 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<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export type UiSettings = {
|
|||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navWidth: number; // Sidebar width when expanded (240–400px)
|
||||
navGroupsCollapsed: Record<string, boolean>; // 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 } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<OverviewProps> = {}): OverviewPr
|
|||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
locale: "en",
|
||||
},
|
||||
password: "",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<div class="settings-appearance">
|
||||
<div class="settings-appearance__section">
|
||||
|
|
@ -560,33 +551,46 @@ function renderAppearanceSection(props: ConfigProps) {
|
|||
</div>
|
||||
|
||||
<div class="settings-appearance__section">
|
||||
<h3 class="settings-appearance__heading">Mode</h3>
|
||||
<p class="settings-appearance__hint">Choose light or dark mode for the selected theme.</p>
|
||||
<div class="settings-theme-grid">
|
||||
${MODE_OPTIONS.map(
|
||||
(opt) => html`
|
||||
<button
|
||||
class="settings-theme-card ${opt.id === props.themeMode ? "settings-theme-card--active" : ""}"
|
||||
title=${opt.description}
|
||||
@click=${(e: Event) => {
|
||||
if (opt.id !== props.themeMode) {
|
||||
const context: ThemeTransitionContext = {
|
||||
element: (e.currentTarget as HTMLElement) ?? undefined,
|
||||
};
|
||||
props.setThemeMode(opt.id, context);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="settings-theme-card__icon" aria-hidden="true">${opt.icon}</span>
|
||||
<span class="settings-theme-card__label">${opt.label}</span>
|
||||
${
|
||||
opt.id === props.themeMode
|
||||
? html`<span class="settings-theme-card__check" aria-hidden="true">${icons.check}</span>`
|
||||
: nothing
|
||||
}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
<h3 class="settings-appearance__heading">Roundness</h3>
|
||||
<p class="settings-appearance__hint">Adjust corner radius across the UI.</p>
|
||||
<div class="settings-slider">
|
||||
<div class="settings-slider__header">
|
||||
<span class="settings-slider__label">
|
||||
<span class="settings-slider__key-swatch settings-slider__key-swatch--sharp"></span>
|
||||
Square
|
||||
</span>
|
||||
<span class="settings-slider__value">${props.borderRadius}%</span>
|
||||
<span class="settings-slider__label">
|
||||
Round
|
||||
<span class="settings-slider__key-swatch settings-slider__key-swatch--round"></span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
class="settings-slider__input"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
.value=${String(props.borderRadius)}
|
||||
@input=${(e: Event) => {
|
||||
const v = Number((e.target as HTMLInputElement).value);
|
||||
props.setBorderRadius(v);
|
||||
}}
|
||||
/>
|
||||
<div class="settings-slider__preview">
|
||||
<div
|
||||
class="settings-slider__preview-swatch"
|
||||
style="border-radius: ${Math.round(10 * (props.borderRadius / 50))}px"
|
||||
></div>
|
||||
<div
|
||||
class="settings-slider__preview-swatch"
|
||||
style="border-radius: ${Math.round(14 * (props.borderRadius / 50))}px"
|
||||
></div>
|
||||
<div
|
||||
class="settings-slider__preview-swatch"
|
||||
style="border-radius: ${Math.round(20 * (props.borderRadius / 50))}px"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<div class="login-gate">
|
||||
<div class="login-gate__theme">${renderThemeToggle(state)}</div>
|
||||
<div class="login-gate__card">
|
||||
<div class="login-gate__header">
|
||||
<img class="login-gate__logo" src=${faviconSrc} alt="OpenClaw" />
|
||||
|
|
|
|||
|
|
@ -698,4 +698,74 @@ export const usageStylesPart1 = `
|
|||
.usage-list-item.button:hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 768px) {
|
||||
.usage-page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
.usage-query-bar {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.usage-query-input {
|
||||
min-width: 0;
|
||||
}
|
||||
.usage-query-actions {
|
||||
justify-self: stretch;
|
||||
}
|
||||
.usage-filters-inline input[type="text"] {
|
||||
min-width: 140px;
|
||||
}
|
||||
.usage-filter-popover {
|
||||
min-width: 180px;
|
||||
}
|
||||
.usage-mosaic-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.usage-hour-grid {
|
||||
grid-template-columns: repeat(12, minmax(8px, 1fr));
|
||||
}
|
||||
.usage-summary-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
}
|
||||
.usage-daypart-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.usage-page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.usage-filters-inline input[type="text"] {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
details.usage-filter-select {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.usage-filter-row {
|
||||
gap: 6px;
|
||||
}
|
||||
.usage-hour-grid {
|
||||
grid-template-columns: repeat(8, minmax(6px, 1fr));
|
||||
}
|
||||
.usage-hour-cell {
|
||||
height: 22px;
|
||||
}
|
||||
.usage-daypart-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.usage-summary-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.usage-summary-card {
|
||||
padding: 10px;
|
||||
}
|
||||
.usage-summary-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -699,4 +699,78 @@ export const usageStylesPart2 = `
|
|||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 768px) {
|
||||
.usage-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.usage-insights-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.usage-meta-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.daily-chart-bars {
|
||||
height: 180px;
|
||||
gap: 3px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.session-bar-row {
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.session-bar-track {
|
||||
flex: 0 0 60px;
|
||||
}
|
||||
.session-bar-value {
|
||||
flex: 0 0 55px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.cost-breakdown {
|
||||
padding: 12px;
|
||||
}
|
||||
.cost-breakdown-legend {
|
||||
gap: 10px;
|
||||
}
|
||||
.session-log-content {
|
||||
max-height: 160px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.context-weight-breakdown {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.usage-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.usage-insights-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
.usage-insight-card {
|
||||
padding: 10px;
|
||||
}
|
||||
.daily-chart-bars {
|
||||
height: 150px;
|
||||
gap: 2px;
|
||||
}
|
||||
.daily-bar-label {
|
||||
font-size: 8px;
|
||||
bottom: -30px;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.session-bar-track {
|
||||
display: none;
|
||||
}
|
||||
.session-bar-value {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.legend-item {
|
||||
font-size: 11px;
|
||||
}
|
||||
.session-log-content {
|
||||
max-height: 120px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue