Skip to content

Commit ead8be9

Browse files
authored
Add tweakcn custom theme import
Adds a browser-local custom tweakcn theme slot while preserving the existing built-in themes. Includes: - tweakcn share-link import, validation, persistence, and custom theme rendering - Custom option in Appearance and Quick Settings - responsive/config toolbar and chat tool-card polish from follow-up review - security hardening for bounded fetches, CSS token validation, redirect handling, and fail-closed unreadable payloads Verification: - OPENCLAW_LOCAL_CHECK=0 pnpm check:changed - GitHub CI clean on 6ff13a1
1 parent 835c4e0 commit ead8be9

23 files changed

Lines changed: 2199 additions & 55 deletions
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
# Tweakcn Custom Theme Import Design
2+
3+
Status: approved in terminal on 2026-04-22
4+
5+
## Summary
6+
7+
Add exactly one browser-local custom Control UI theme slot that can be imported from a tweakcn share link. The existing built-in theme families remain `claw`, `knot`, and `dash`. The new `custom` family behaves like a normal OpenClaw theme family and supports `light`, `dark`, and `system` mode when the imported tweakcn payload includes both light and dark token sets.
8+
9+
The imported theme is stored only in the current browser profile with the rest of the Control UI settings. It is not written to gateway config and does not sync across devices or browsers.
10+
11+
## Problem
12+
13+
The Control UI theme system is currently closed over three hard-coded theme families:
14+
15+
- `ui/src/ui/theme.ts`
16+
- `ui/src/ui/views/config.ts`
17+
- `ui/src/styles/base.css`
18+
19+
Users can switch among built-in families and mode variants, but they cannot bring in a theme from tweakcn without editing repo CSS. The requested outcome is smaller than a general theming system: keep the three built-ins and add one user-controlled imported slot that can be replaced from a tweakcn link.
20+
21+
## Goals
22+
23+
- Keep the existing built-in theme families unchanged.
24+
- Add exactly one imported custom slot, not a theme library.
25+
- Accept a tweakcn share link or a direct `https://tweakcn.com/r/themes/{id}` URL.
26+
- Persist the imported theme in browser local storage only.
27+
- Make the imported slot work with existing `light`, `dark`, and `system` mode controls.
28+
- Keep failure behavior safe: a bad import never breaks the active UI theme.
29+
30+
## Non goals
31+
32+
- No multi-theme library or browser-local list of imports.
33+
- No gateway-side persistence or cross-device sync.
34+
- No arbitrary CSS editor or raw theme JSON editor.
35+
- No automatic loading of remote font assets from tweakcn.
36+
- No attempt to support tweakcn payloads that only expose one mode.
37+
- No repo-wide theming refactor beyond the seams required for the Control UI.
38+
39+
## User decisions already made
40+
41+
- Keep the three built-in themes.
42+
- Add one tweakcn-powered import slot.
43+
- Store the imported theme in the browser, not gateway config.
44+
- Support `light`, `dark`, and `system` for the imported slot.
45+
- Overwriting the custom slot with the next import is the intended behavior.
46+
47+
## Recommended approach
48+
49+
Add a fourth theme family id, `custom`, to the Control UI theme model. The `custom` family becomes selectable only when a valid tweakcn import is present. The imported payload is normalized into an OpenClaw-specific custom theme record and stored in browser local storage with the rest of the UI settings.
50+
51+
At runtime, OpenClaw renders a managed `<style>` tag that defines the resolved custom CSS variable blocks:
52+
53+
```css
54+
:root[data-theme="custom"] { ... }
55+
:root[data-theme="custom-light"] { ... }
56+
```
57+
58+
This keeps custom theme variables scoped to the `custom` family and avoids leaking inline CSS variables into the built-in families.
59+
60+
## Architecture
61+
62+
### Theme model
63+
64+
Update `ui/src/ui/theme.ts`:
65+
66+
- Extend `ThemeName` to include `custom`.
67+
- Extend `ResolvedTheme` to include `custom` and `custom-light`.
68+
- Extend `VALID_THEME_NAMES`.
69+
- Update `resolveTheme()` so `custom` mirrors the existing family behavior:
70+
- `custom + dark` -> `custom`
71+
- `custom + light` -> `custom-light`
72+
- `custom + system` -> `custom` or `custom-light` based on OS preference
73+
74+
No legacy aliases are added for `custom`.
75+
76+
### Persistence model
77+
78+
Extend `UiSettings` persistence in `ui/src/ui/storage.ts` with one optional custom-theme payload:
79+
80+
- `customTheme?: ImportedCustomTheme`
81+
82+
Recommended stored shape:
83+
84+
```ts
85+
type ImportedCustomTheme = {
86+
sourceUrl: string;
87+
themeId: string;
88+
label: string;
89+
importedAt: string;
90+
light: Record<string, string>;
91+
dark: Record<string, string>;
92+
};
93+
```
94+
95+
Notes:
96+
97+
- `sourceUrl` stores the original user input after normalization.
98+
- `themeId` is the tweakcn theme id extracted from the URL.
99+
- `label` is the tweakcn `name` field when present, else `Custom`.
100+
- `light` and `dark` are already normalized OpenClaw token maps, not raw tweakcn payloads.
101+
- The imported payload lives beside other browser-local settings and is serialized in the same local-storage document.
102+
- If stored custom-theme data is missing or invalid on load, ignore the payload and fall back to `theme: "claw"` when the persisted family was `custom`.
103+
104+
### Runtime application
105+
106+
Add a narrow custom-theme stylesheet manager in the Control UI runtime, owned near `ui/src/ui/app-settings.ts` and `ui/src/ui/theme.ts`.
107+
108+
Responsibilities:
109+
110+
- Create or update one stable `<style id="openclaw-custom-theme">` tag in `document.head`.
111+
- Emit CSS only when a valid custom theme payload exists.
112+
- Remove the style tag content when the payload is cleared.
113+
- Keep built-in family CSS in `ui/src/styles/base.css`; do not splice imported tokens into the checked-in stylesheet.
114+
115+
This manager runs whenever settings are loaded, saved, imported, or cleared.
116+
117+
### Light-mode selectors
118+
119+
Implementation should prefer `data-theme-mode="light"` for cross-family light styling rather than special-casing `custom-light`. If an existing selector is pinned to `data-theme="light"` and needs to apply to every light family, broaden it as part of this work.
120+
121+
## Import UX
122+
123+
Update `ui/src/ui/views/config.ts` in the `Appearance` section:
124+
125+
- Add a `Custom` theme card beside `Claw`, `Knot`, and `Dash`.
126+
- Show the card as disabled when no imported custom theme exists.
127+
- Add an import panel under the theme grid with:
128+
- one text input for a tweakcn share link or `/r/themes/{id}` URL
129+
- one `Import` button
130+
- one `Replace` path when a custom payload already exists
131+
- one `Clear` action when a custom payload already exists
132+
- Show the imported theme label and source host when a payload exists.
133+
- If the active theme is `custom`, importing a replacement applies immediately.
134+
- If the active theme is not `custom`, importing only stores the new payload until the user selects the `Custom` card.
135+
136+
The quick settings theme picker in `ui/src/ui/views/config-quick.ts` should also show `Custom` only when a payload exists.
137+
138+
## URL parsing and remote fetch
139+
140+
The browser import path accepts:
141+
142+
- `https://tweakcn.com/themes/{id}`
143+
- `https://tweakcn.com/r/themes/{id}`
144+
145+
Implementation should normalize both forms to:
146+
147+
- `https://tweakcn.com/r/themes/{id}`
148+
149+
The browser then fetches the normalized `/r/themes/{id}` endpoint directly.
150+
151+
Use a narrow schema validator for the external payload. A zod schema is preferred because this is an untrusted external boundary.
152+
153+
Required remote fields:
154+
155+
- top-level `name` as optional string
156+
- `cssVars.theme` as optional object
157+
- `cssVars.light` as object
158+
- `cssVars.dark` as object
159+
160+
If either `cssVars.light` or `cssVars.dark` is missing, reject the import. This is deliberate: the approved product behavior is full mode support, not best-effort synthesis of a missing side.
161+
162+
## Token mapping
163+
164+
Do not mirror tweakcn variables blindly. Normalize a bounded subset into OpenClaw tokens and derive the rest in a helper.
165+
166+
### Tokens imported directly
167+
168+
From each tweakcn mode block:
169+
170+
- `background`
171+
- `foreground`
172+
- `card`
173+
- `card-foreground`
174+
- `popover`
175+
- `popover-foreground`
176+
- `primary`
177+
- `primary-foreground`
178+
- `secondary`
179+
- `secondary-foreground`
180+
- `muted`
181+
- `muted-foreground`
182+
- `accent`
183+
- `accent-foreground`
184+
- `destructive`
185+
- `destructive-foreground`
186+
- `border`
187+
- `input`
188+
- `ring`
189+
- `radius`
190+
191+
From shared `cssVars.theme` when present:
192+
193+
- `font-sans`
194+
- `font-mono`
195+
196+
If a mode block overrides `font-sans`, `font-mono`, or `radius`, the mode-local value wins.
197+
198+
### Tokens derived for OpenClaw
199+
200+
The importer derives OpenClaw-only variables from the imported base colors:
201+
202+
- `--bg-accent`
203+
- `--bg-elevated`
204+
- `--bg-hover`
205+
- `--panel`
206+
- `--panel-strong`
207+
- `--panel-hover`
208+
- `--chrome`
209+
- `--chrome-strong`
210+
- `--text`
211+
- `--text-strong`
212+
- `--chat-text`
213+
- `--muted`
214+
- `--muted-strong`
215+
- `--accent-hover`
216+
- `--accent-muted`
217+
- `--accent-subtle`
218+
- `--accent-glow`
219+
- `--focus`
220+
- `--focus-ring`
221+
- `--focus-glow`
222+
- `--secondary`
223+
- `--secondary-foreground`
224+
- `--danger`
225+
- `--danger-muted`
226+
- `--danger-subtle`
227+
228+
Derivation rules live in a pure helper so they can be tested independently. Exact color-mixing formulas are an implementation detail, but the helper must satisfy two constraints:
229+
230+
- preserve readable contrast close to the imported theme intent
231+
- produce stable output for the same imported payload
232+
233+
### Tokens ignored in v1
234+
235+
These tweakcn tokens are intentionally ignored in the first version:
236+
237+
- `chart-*`
238+
- `sidebar-*`
239+
- `font-serif`
240+
- `shadow-*`
241+
- `tracking-*`
242+
- `letter-spacing`
243+
- `spacing`
244+
245+
This keeps the scope on the tokens the current Control UI actually needs.
246+
247+
### Fonts
248+
249+
Font stack strings are imported if present, but OpenClaw does not load remote font assets in v1. If the imported stack references fonts that are unavailable in the browser, normal fallback behavior applies.
250+
251+
## Failure behavior
252+
253+
Bad imports must fail closed.
254+
255+
- Invalid URL format: show inline validation error, do not fetch.
256+
- Unsupported host or path shape: show inline validation error, do not fetch.
257+
- Network failure, non-OK response, or malformed JSON: show inline error, keep current stored payload untouched.
258+
- Schema failure or missing light/dark blocks: show inline error, keep current stored payload untouched.
259+
- Clear action:
260+
- removes the stored custom payload
261+
- removes the managed custom style tag content
262+
- if `custom` is active, switches theme family back to `claw`
263+
- Invalid stored custom payload on first load:
264+
- ignore the stored payload
265+
- do not emit custom CSS
266+
- if persisted theme family was `custom`, fall back to `claw`
267+
268+
At no point should a failed import leave the active document with partial custom CSS variables applied.
269+
270+
## Files expected to change in implementation
271+
272+
Primary files:
273+
274+
- `ui/src/ui/theme.ts`
275+
- `ui/src/ui/storage.ts`
276+
- `ui/src/ui/app-settings.ts`
277+
- `ui/src/ui/views/config.ts`
278+
- `ui/src/ui/views/config-quick.ts`
279+
- `ui/src/styles/base.css`
280+
281+
Likely new helpers:
282+
283+
- `ui/src/ui/custom-theme.ts`
284+
- `ui/src/ui/custom-theme-import.ts`
285+
286+
Tests:
287+
288+
- `ui/src/ui/app-settings.test.ts`
289+
- `ui/src/ui/storage.node.test.ts`
290+
- `ui/src/ui/views/config.browser.test.ts`
291+
- new focused tests for URL parsing and payload normalization
292+
293+
## Testing
294+
295+
Minimum implementation coverage:
296+
297+
- parse share-link URL into tweakcn theme id
298+
- normalize `/themes/{id}` and `/r/themes/{id}` into the fetch URL
299+
- reject unsupported hosts and malformed ids
300+
- validate tweakcn payload shape
301+
- map a valid tweakcn payload into normalized OpenClaw light and dark token maps
302+
- load and save the custom payload in browser-local settings
303+
- resolve `custom` for `light`, `dark`, and `system`
304+
- disable `Custom` selection when no payload exists
305+
- apply imported theme immediately when `custom` is already active
306+
- fall back to `claw` when the active custom theme is cleared
307+
308+
Manual verification target:
309+
310+
- import a known tweakcn theme from Settings
311+
- switch among `light`, `dark`, and `system`
312+
- switch between `custom` and the built-in families
313+
- reload the page and confirm the imported custom theme persists locally
314+
315+
## Rollout notes
316+
317+
This feature is intentionally small. If users later ask for multiple imported themes, rename, export, or cross-device sync, treat that as a follow-on design. Do not pre-build a theme library abstraction in this implementation.

scripts/test-projects.test-support.mjs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,7 @@ function classifyTarget(arg, cwd) {
747747
if (relative.startsWith("src/plugins/")) {
748748
return "plugin";
749749
}
750-
if (relative.startsWith("ui/src/ui/")) {
750+
if (relative.startsWith("ui/src/")) {
751751
return "ui";
752752
}
753753
if (relative.startsWith("src/utils/")) {
@@ -776,6 +776,17 @@ function resolveLightLaneIncludePatterns(kind, targetArg, cwd) {
776776
return null;
777777
}
778778

779+
function shouldUseWholeConfigTarget(kind, targetArg, cwd) {
780+
if (isVitestConfigTargetForKind(kind, targetArg, cwd)) {
781+
return true;
782+
}
783+
if (kind !== "ui") {
784+
return false;
785+
}
786+
const relative = toRepoRelativeTarget(targetArg, cwd);
787+
return relative.startsWith("ui/src/") && !relative.startsWith("ui/src/ui/");
788+
}
789+
779790
function createVitestArgs(params) {
780791
return [
781792
"exec",
@@ -956,7 +967,7 @@ export function buildVitestRunPlans(
956967
(kind === "default" &&
957968
grouped.every((targetArg) => isFileLikeTarget(toRepoRelativeTarget(targetArg, cwd))));
958969
const useWholeConfigTarget = grouped.some((targetArg) =>
959-
isVitestConfigTargetForKind(kind, targetArg, cwd),
970+
shouldUseWholeConfigTarget(kind, targetArg, cwd),
960971
);
961972
const includePatterns = useCliTargetArgs
962973
? null

test/scripts/test-projects.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,22 @@ describe("scripts/test-projects changed-target routing", () => {
259259
]);
260260
});
261261

262+
it("routes changed ui support files to the ui lane without dead include globs", () => {
263+
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
264+
"ui/src/styles/base.css",
265+
"ui/src/test-helpers/lit-warnings.setup.ts",
266+
]);
267+
268+
expect(plans).toEqual([
269+
{
270+
config: "test/vitest/vitest.ui.config.ts",
271+
forwardedArgs: [],
272+
includePatterns: null,
273+
watchMode: false,
274+
},
275+
]);
276+
});
277+
262278
it("routes auto-reply route source files to route regression tests", () => {
263279
expect(
264280
resolveChangedTestTargetPlan([
@@ -274,7 +290,6 @@ describe("scripts/test-projects changed-target routing", () => {
274290
],
275291
});
276292
});
277-
278293
it("routes changed utils and shared files to their light scoped lanes", () => {
279294
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
280295
"src/shared/string-normalization.ts",

0 commit comments

Comments
 (0)