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:
Val Alexander
2026-03-17 23:06:01 -05:00
committed by GitHub
parent 1a9114a169
commit df72ca1ece
28 changed files with 942 additions and 175 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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);
}
* {

View File

@@ -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;

View File

@@ -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%;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -105,6 +105,7 @@ function createHost() {
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
},
password: "",
clientInstanceId: "instance-test",

View File

@@ -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,

View File

@@ -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",

View File

@@ -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") {

View File

@@ -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>;

View File

@@ -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);

View File

@@ -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");
});
});

View File

@@ -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 = {

View File

@@ -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];

View File

@@ -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",

View File

@@ -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",
});
});
});

View File

@@ -39,6 +39,7 @@ export type UiSettings = {
navCollapsed: boolean; // Collapsible sidebar state
navWidth: number; // Sidebar width when expanded (240400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
borderRadius: number; // Corner roundness (0100, 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 } : {}),
};

View File

@@ -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: "",

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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;
}
}
`;

View File

@@ -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;
}
}
`;