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

Mode

-

Choose light or dark mode for the selected theme.

-
- ${MODE_OPTIONS.map( - (opt) => html` - - `, - )} +

Roundness

+

Adjust corner radius across the UI.

+
+
+ + + Square + + ${props.borderRadius}% + + Round + + +
+ { + const v = Number((e.target as HTMLInputElement).value); + props.setBorderRadius(v); + }} + /> +
+
+
+
+
diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts index 77613822cdf..8fb0aa68b3a 100644 --- a/ui/src/ui/views/login-gate.ts +++ b/ui/src/ui/views/login-gate.ts @@ -1,6 +1,5 @@ import { html } from "lit"; import { t } from "../../i18n/index.ts"; -import { renderThemeToggle } from "../app-render.helpers.ts"; import type { AppViewState } from "../app-view-state.ts"; import { icons } from "../icons.ts"; import { normalizeBasePath } from "../navigation.ts"; @@ -12,7 +11,6 @@ export function renderLoginGate(state: AppViewState) { return html`