mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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 6ff13a1b33
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
# Tweakcn Custom Theme Import Design
|
||||
|
||||
Status: approved in terminal on 2026-04-22
|
||||
|
||||
## Summary
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Problem
|
||||
|
||||
The Control UI theme system is currently closed over three hard-coded theme families:
|
||||
|
||||
- `ui/src/ui/theme.ts`
|
||||
- `ui/src/ui/views/config.ts`
|
||||
- `ui/src/styles/base.css`
|
||||
|
||||
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.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the existing built-in theme families unchanged.
|
||||
- Add exactly one imported custom slot, not a theme library.
|
||||
- Accept a tweakcn share link or a direct `https://tweakcn.com/r/themes/{id}` URL.
|
||||
- Persist the imported theme in browser local storage only.
|
||||
- Make the imported slot work with existing `light`, `dark`, and `system` mode controls.
|
||||
- Keep failure behavior safe: a bad import never breaks the active UI theme.
|
||||
|
||||
## Non goals
|
||||
|
||||
- No multi-theme library or browser-local list of imports.
|
||||
- No gateway-side persistence or cross-device sync.
|
||||
- No arbitrary CSS editor or raw theme JSON editor.
|
||||
- No automatic loading of remote font assets from tweakcn.
|
||||
- No attempt to support tweakcn payloads that only expose one mode.
|
||||
- No repo-wide theming refactor beyond the seams required for the Control UI.
|
||||
|
||||
## User decisions already made
|
||||
|
||||
- Keep the three built-in themes.
|
||||
- Add one tweakcn-powered import slot.
|
||||
- Store the imported theme in the browser, not gateway config.
|
||||
- Support `light`, `dark`, and `system` for the imported slot.
|
||||
- Overwriting the custom slot with the next import is the intended behavior.
|
||||
|
||||
## Recommended approach
|
||||
|
||||
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.
|
||||
|
||||
At runtime, OpenClaw renders a managed `<style>` tag that defines the resolved custom CSS variable blocks:
|
||||
|
||||
```css
|
||||
:root[data-theme="custom"] { ... }
|
||||
:root[data-theme="custom-light"] { ... }
|
||||
```
|
||||
|
||||
This keeps custom theme variables scoped to the `custom` family and avoids leaking inline CSS variables into the built-in families.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Theme model
|
||||
|
||||
Update `ui/src/ui/theme.ts`:
|
||||
|
||||
- Extend `ThemeName` to include `custom`.
|
||||
- Extend `ResolvedTheme` to include `custom` and `custom-light`.
|
||||
- Extend `VALID_THEME_NAMES`.
|
||||
- Update `resolveTheme()` so `custom` mirrors the existing family behavior:
|
||||
- `custom + dark` -> `custom`
|
||||
- `custom + light` -> `custom-light`
|
||||
- `custom + system` -> `custom` or `custom-light` based on OS preference
|
||||
|
||||
No legacy aliases are added for `custom`.
|
||||
|
||||
### Persistence model
|
||||
|
||||
Extend `UiSettings` persistence in `ui/src/ui/storage.ts` with one optional custom-theme payload:
|
||||
|
||||
- `customTheme?: ImportedCustomTheme`
|
||||
|
||||
Recommended stored shape:
|
||||
|
||||
```ts
|
||||
type ImportedCustomTheme = {
|
||||
sourceUrl: string;
|
||||
themeId: string;
|
||||
label: string;
|
||||
importedAt: string;
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `sourceUrl` stores the original user input after normalization.
|
||||
- `themeId` is the tweakcn theme id extracted from the URL.
|
||||
- `label` is the tweakcn `name` field when present, else `Custom`.
|
||||
- `light` and `dark` are already normalized OpenClaw token maps, not raw tweakcn payloads.
|
||||
- The imported payload lives beside other browser-local settings and is serialized in the same local-storage document.
|
||||
- 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`.
|
||||
|
||||
### Runtime application
|
||||
|
||||
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`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Create or update one stable `<style id="openclaw-custom-theme">` tag in `document.head`.
|
||||
- Emit CSS only when a valid custom theme payload exists.
|
||||
- Remove the style tag content when the payload is cleared.
|
||||
- Keep built-in family CSS in `ui/src/styles/base.css`; do not splice imported tokens into the checked-in stylesheet.
|
||||
|
||||
This manager runs whenever settings are loaded, saved, imported, or cleared.
|
||||
|
||||
### Light-mode selectors
|
||||
|
||||
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.
|
||||
|
||||
## Import UX
|
||||
|
||||
Update `ui/src/ui/views/config.ts` in the `Appearance` section:
|
||||
|
||||
- Add a `Custom` theme card beside `Claw`, `Knot`, and `Dash`.
|
||||
- Show the card as disabled when no imported custom theme exists.
|
||||
- Add an import panel under the theme grid with:
|
||||
- one text input for a tweakcn share link or `/r/themes/{id}` URL
|
||||
- one `Import` button
|
||||
- one `Replace` path when a custom payload already exists
|
||||
- one `Clear` action when a custom payload already exists
|
||||
- Show the imported theme label and source host when a payload exists.
|
||||
- If the active theme is `custom`, importing a replacement applies immediately.
|
||||
- If the active theme is not `custom`, importing only stores the new payload until the user selects the `Custom` card.
|
||||
|
||||
The quick settings theme picker in `ui/src/ui/views/config-quick.ts` should also show `Custom` only when a payload exists.
|
||||
|
||||
## URL parsing and remote fetch
|
||||
|
||||
The browser import path accepts:
|
||||
|
||||
- `https://tweakcn.com/themes/{id}`
|
||||
- `https://tweakcn.com/r/themes/{id}`
|
||||
|
||||
Implementation should normalize both forms to:
|
||||
|
||||
- `https://tweakcn.com/r/themes/{id}`
|
||||
|
||||
The browser then fetches the normalized `/r/themes/{id}` endpoint directly.
|
||||
|
||||
Use a narrow schema validator for the external payload. A zod schema is preferred because this is an untrusted external boundary.
|
||||
|
||||
Required remote fields:
|
||||
|
||||
- top-level `name` as optional string
|
||||
- `cssVars.theme` as optional object
|
||||
- `cssVars.light` as object
|
||||
- `cssVars.dark` as object
|
||||
|
||||
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.
|
||||
|
||||
## Token mapping
|
||||
|
||||
Do not mirror tweakcn variables blindly. Normalize a bounded subset into OpenClaw tokens and derive the rest in a helper.
|
||||
|
||||
### Tokens imported directly
|
||||
|
||||
From each tweakcn mode block:
|
||||
|
||||
- `background`
|
||||
- `foreground`
|
||||
- `card`
|
||||
- `card-foreground`
|
||||
- `popover`
|
||||
- `popover-foreground`
|
||||
- `primary`
|
||||
- `primary-foreground`
|
||||
- `secondary`
|
||||
- `secondary-foreground`
|
||||
- `muted`
|
||||
- `muted-foreground`
|
||||
- `accent`
|
||||
- `accent-foreground`
|
||||
- `destructive`
|
||||
- `destructive-foreground`
|
||||
- `border`
|
||||
- `input`
|
||||
- `ring`
|
||||
- `radius`
|
||||
|
||||
From shared `cssVars.theme` when present:
|
||||
|
||||
- `font-sans`
|
||||
- `font-mono`
|
||||
|
||||
If a mode block overrides `font-sans`, `font-mono`, or `radius`, the mode-local value wins.
|
||||
|
||||
### Tokens derived for OpenClaw
|
||||
|
||||
The importer derives OpenClaw-only variables from the imported base colors:
|
||||
|
||||
- `--bg-accent`
|
||||
- `--bg-elevated`
|
||||
- `--bg-hover`
|
||||
- `--panel`
|
||||
- `--panel-strong`
|
||||
- `--panel-hover`
|
||||
- `--chrome`
|
||||
- `--chrome-strong`
|
||||
- `--text`
|
||||
- `--text-strong`
|
||||
- `--chat-text`
|
||||
- `--muted`
|
||||
- `--muted-strong`
|
||||
- `--accent-hover`
|
||||
- `--accent-muted`
|
||||
- `--accent-subtle`
|
||||
- `--accent-glow`
|
||||
- `--focus`
|
||||
- `--focus-ring`
|
||||
- `--focus-glow`
|
||||
- `--secondary`
|
||||
- `--secondary-foreground`
|
||||
- `--danger`
|
||||
- `--danger-muted`
|
||||
- `--danger-subtle`
|
||||
|
||||
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:
|
||||
|
||||
- preserve readable contrast close to the imported theme intent
|
||||
- produce stable output for the same imported payload
|
||||
|
||||
### Tokens ignored in v1
|
||||
|
||||
These tweakcn tokens are intentionally ignored in the first version:
|
||||
|
||||
- `chart-*`
|
||||
- `sidebar-*`
|
||||
- `font-serif`
|
||||
- `shadow-*`
|
||||
- `tracking-*`
|
||||
- `letter-spacing`
|
||||
- `spacing`
|
||||
|
||||
This keeps the scope on the tokens the current Control UI actually needs.
|
||||
|
||||
### Fonts
|
||||
|
||||
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.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Bad imports must fail closed.
|
||||
|
||||
- Invalid URL format: show inline validation error, do not fetch.
|
||||
- Unsupported host or path shape: show inline validation error, do not fetch.
|
||||
- Network failure, non-OK response, or malformed JSON: show inline error, keep current stored payload untouched.
|
||||
- Schema failure or missing light/dark blocks: show inline error, keep current stored payload untouched.
|
||||
- Clear action:
|
||||
- removes the stored custom payload
|
||||
- removes the managed custom style tag content
|
||||
- if `custom` is active, switches theme family back to `claw`
|
||||
- Invalid stored custom payload on first load:
|
||||
- ignore the stored payload
|
||||
- do not emit custom CSS
|
||||
- if persisted theme family was `custom`, fall back to `claw`
|
||||
|
||||
At no point should a failed import leave the active document with partial custom CSS variables applied.
|
||||
|
||||
## Files expected to change in implementation
|
||||
|
||||
Primary files:
|
||||
|
||||
- `ui/src/ui/theme.ts`
|
||||
- `ui/src/ui/storage.ts`
|
||||
- `ui/src/ui/app-settings.ts`
|
||||
- `ui/src/ui/views/config.ts`
|
||||
- `ui/src/ui/views/config-quick.ts`
|
||||
- `ui/src/styles/base.css`
|
||||
|
||||
Likely new helpers:
|
||||
|
||||
- `ui/src/ui/custom-theme.ts`
|
||||
- `ui/src/ui/custom-theme-import.ts`
|
||||
|
||||
Tests:
|
||||
|
||||
- `ui/src/ui/app-settings.test.ts`
|
||||
- `ui/src/ui/storage.node.test.ts`
|
||||
- `ui/src/ui/views/config.browser.test.ts`
|
||||
- new focused tests for URL parsing and payload normalization
|
||||
|
||||
## Testing
|
||||
|
||||
Minimum implementation coverage:
|
||||
|
||||
- parse share-link URL into tweakcn theme id
|
||||
- normalize `/themes/{id}` and `/r/themes/{id}` into the fetch URL
|
||||
- reject unsupported hosts and malformed ids
|
||||
- validate tweakcn payload shape
|
||||
- map a valid tweakcn payload into normalized OpenClaw light and dark token maps
|
||||
- load and save the custom payload in browser-local settings
|
||||
- resolve `custom` for `light`, `dark`, and `system`
|
||||
- disable `Custom` selection when no payload exists
|
||||
- apply imported theme immediately when `custom` is already active
|
||||
- fall back to `claw` when the active custom theme is cleared
|
||||
|
||||
Manual verification target:
|
||||
|
||||
- import a known tweakcn theme from Settings
|
||||
- switch among `light`, `dark`, and `system`
|
||||
- switch between `custom` and the built-in families
|
||||
- reload the page and confirm the imported custom theme persists locally
|
||||
|
||||
## Rollout notes
|
||||
|
||||
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.
|
||||
@@ -747,7 +747,7 @@ function classifyTarget(arg, cwd) {
|
||||
if (relative.startsWith("src/plugins/")) {
|
||||
return "plugin";
|
||||
}
|
||||
if (relative.startsWith("ui/src/ui/")) {
|
||||
if (relative.startsWith("ui/src/")) {
|
||||
return "ui";
|
||||
}
|
||||
if (relative.startsWith("src/utils/")) {
|
||||
@@ -776,6 +776,17 @@ function resolveLightLaneIncludePatterns(kind, targetArg, cwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldUseWholeConfigTarget(kind, targetArg, cwd) {
|
||||
if (isVitestConfigTargetForKind(kind, targetArg, cwd)) {
|
||||
return true;
|
||||
}
|
||||
if (kind !== "ui") {
|
||||
return false;
|
||||
}
|
||||
const relative = toRepoRelativeTarget(targetArg, cwd);
|
||||
return relative.startsWith("ui/src/") && !relative.startsWith("ui/src/ui/");
|
||||
}
|
||||
|
||||
function createVitestArgs(params) {
|
||||
return [
|
||||
"exec",
|
||||
@@ -956,7 +967,7 @@ export function buildVitestRunPlans(
|
||||
(kind === "default" &&
|
||||
grouped.every((targetArg) => isFileLikeTarget(toRepoRelativeTarget(targetArg, cwd))));
|
||||
const useWholeConfigTarget = grouped.some((targetArg) =>
|
||||
isVitestConfigTargetForKind(kind, targetArg, cwd),
|
||||
shouldUseWholeConfigTarget(kind, targetArg, cwd),
|
||||
);
|
||||
const includePatterns = useCliTargetArgs
|
||||
? null
|
||||
|
||||
@@ -259,6 +259,22 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes changed ui support files to the ui lane without dead include globs", () => {
|
||||
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"ui/src/styles/base.css",
|
||||
"ui/src/test-helpers/lit-warnings.setup.ts",
|
||||
]);
|
||||
|
||||
expect(plans).toEqual([
|
||||
{
|
||||
config: "test/vitest/vitest.ui.config.ts",
|
||||
forwardedArgs: [],
|
||||
includePatterns: null,
|
||||
watchMode: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes auto-reply route source files to route regression tests", () => {
|
||||
expect(
|
||||
resolveChangedTestTargetPlan([
|
||||
@@ -274,7 +290,6 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes changed utils and shared files to their light scoped lanes", () => {
|
||||
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"src/shared/string-normalization.ts",
|
||||
|
||||
@@ -188,13 +188,13 @@
|
||||
}
|
||||
|
||||
/* Scrollbar - visible on light backgrounds */
|
||||
:root[data-theme="light"]::-webkit-scrollbar-thumb,
|
||||
:root[data-theme="light"] ::-webkit-scrollbar-thumb {
|
||||
:root[data-theme-mode="light"]::-webkit-scrollbar-thumb,
|
||||
:root[data-theme-mode="light"] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:root[data-theme="light"]::-webkit-scrollbar-thumb:hover,
|
||||
:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover {
|
||||
:root[data-theme-mode="light"]::-webkit-scrollbar-thumb:hover,
|
||||
:root[data-theme-mode="light"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: min(900px, 68%);
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* User messages align content right */
|
||||
@@ -32,6 +34,10 @@
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-group.tool .chat-group-messages {
|
||||
max-width: min(980px, calc(100% - 46px));
|
||||
}
|
||||
|
||||
.chat-group.user .chat-group-footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -193,9 +199,23 @@ img.chat-avatar {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-bubble--tool-shell {
|
||||
align-self: stretch;
|
||||
width: min(100%, 760px);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.chat-bubble--tool-shell:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-bubble.has-copy {
|
||||
padding-right: 70px;
|
||||
}
|
||||
@@ -297,10 +317,19 @@ img.chat-avatar {
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
}
|
||||
|
||||
:root[data-theme-mode="light"] .chat-bubble--tool-shell {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.chat-bubble:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-bubble--tool-shell:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* User bubbles have different styling */
|
||||
.chat-group.user .chat-bubble {
|
||||
background: var(--accent-subtle);
|
||||
@@ -488,6 +517,10 @@ img.chat-avatar {
|
||||
.chat-group-messages {
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
.chat-group.tool .chat-group-messages {
|
||||
max-width: calc(100% - 46px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* Tool Card Styles */
|
||||
.chat-tool-card {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
@@ -45,6 +48,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-tool-card__actions {
|
||||
@@ -62,6 +66,7 @@
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-tool-card__icon {
|
||||
@@ -170,6 +175,7 @@
|
||||
|
||||
.chat-tool-card__block {
|
||||
margin-top: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-tool-card__preview,
|
||||
@@ -333,6 +339,9 @@
|
||||
.chat-tool-card__block-preview,
|
||||
.chat-tool-card__block-content,
|
||||
.chat-tool-card__block-empty {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 11px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -341,6 +350,7 @@
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -522,14 +532,19 @@
|
||||
}
|
||||
|
||||
.chat-tool-msg-collapse {
|
||||
margin-top: 2px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 11px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
@@ -537,7 +552,16 @@
|
||||
list-style: none;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--bg-hover) 35%, transparent);
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--accent) 9%, transparent),
|
||||
transparent 34%
|
||||
),
|
||||
color-mix(in srgb, var(--card) 86%, var(--secondary) 14%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--bg) 76%, transparent),
|
||||
0 8px 22px color-mix(in srgb, black 12%, transparent);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
appearance: none;
|
||||
@@ -550,7 +574,13 @@
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary[type="button"] {
|
||||
background: color-mix(in srgb, var(--bg-hover) 35%, transparent);
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--accent) 9%, transparent),
|
||||
transparent 34%
|
||||
),
|
||||
color-mix(in srgb, var(--card) 86%, var(--secondary) 14%);
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary::-webkit-details-marker {
|
||||
@@ -579,10 +609,22 @@
|
||||
|
||||
.chat-tool-msg-summary:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--bg-hover) 60%, transparent);
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--accent) 13%, transparent),
|
||||
transparent 38%
|
||||
),
|
||||
color-mix(in srgb, var(--card) 76%, var(--bg-hover) 24%);
|
||||
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
|
||||
}
|
||||
|
||||
.chat-tool-msg-collapse--manual.is-open > .chat-tool-msg-summary,
|
||||
.chat-tool-msg-collapse[open] > .chat-tool-msg-summary {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.chat-tool-msg-summary__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -669,9 +711,34 @@
|
||||
}
|
||||
|
||||
.chat-tool-msg-body {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.chat-bubble--tool-shell .chat-tool-msg-body {
|
||||
margin-top: 0;
|
||||
padding: 12px 14px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
background: color-mix(in srgb, var(--card) 82%, var(--secondary) 18%);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg) 68%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-bubble--tool-shell .chat-tool-msg-body > .chat-text {
|
||||
max-height: min(46vh, 540px);
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Reading Indicator */
|
||||
.chat-reading-indicator {
|
||||
background: transparent;
|
||||
|
||||
@@ -184,9 +184,10 @@
|
||||
/* Actions Bar */
|
||||
.config-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -204,6 +205,41 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-actions__left {
|
||||
flex: 1 1 280px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.config-actions__right {
|
||||
flex: 999 1 420px;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.config-actions__notice {
|
||||
flex: 0 1 24rem;
|
||||
max-width: 24rem;
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.config-actions__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.config-actions__buttons .btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.config-changes-badge {
|
||||
@@ -231,6 +267,7 @@
|
||||
.config-status {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.config-top-tabs {
|
||||
@@ -552,6 +589,120 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-theme-import {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.settings-theme-import__copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-theme-import__title {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.settings-theme-import__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-theme-import__inline-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-theme-import__inline-hint strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-theme-import__field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-theme-import__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-theme-import__input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.settings-theme-import__input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-theme-import__input:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.settings-theme-import__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-theme-import__meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-theme-import__meta-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-theme-import__meta-value {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-theme-import__message {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.settings-theme-import__message--success {
|
||||
background: var(--ok-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-theme-import__message--error {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Roundness options */
|
||||
.settings-roundness__options {
|
||||
display: flex;
|
||||
@@ -1790,3 +1941,30 @@
|
||||
min-width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.config-actions {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.config-actions__left,
|
||||
.config-actions__right {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.config-actions__right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.config-actions__notice {
|
||||
flex-basis: 100%;
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.config-actions__buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,6 +867,18 @@ export function renderApp(state: AppViewState) {
|
||||
themeMode: state.themeMode,
|
||||
setTheme: (theme, context) => state.setTheme(theme, context),
|
||||
setThemeMode: (mode, context) => state.setThemeMode(mode, context),
|
||||
hasCustomTheme: Boolean(state.settings.customTheme),
|
||||
customThemeLabel: state.settings.customTheme?.label ?? null,
|
||||
customThemeSourceUrl: state.settings.customTheme?.sourceUrl ?? null,
|
||||
customThemeImportUrl: state.customThemeImportUrl,
|
||||
customThemeImportBusy: state.customThemeImportBusy,
|
||||
customThemeImportMessage: state.customThemeImportMessage,
|
||||
customThemeImportExpanded: state.customThemeImportExpanded,
|
||||
customThemeImportFocusToken: state.customThemeImportFocusToken,
|
||||
onCustomThemeImportUrlChange: (next) => state.setCustomThemeImportUrl(next),
|
||||
onOpenCustomThemeImport: () => state.openCustomThemeImport(),
|
||||
onImportCustomTheme: () => void state.importCustomTheme(),
|
||||
onClearCustomTheme: () => state.clearCustomTheme(),
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setBorderRadius: (value) => state.setBorderRadius(value),
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
@@ -1007,8 +1019,19 @@ export function renderApp(state: AppViewState) {
|
||||
},
|
||||
theme: state.theme,
|
||||
themeMode: state.themeMode,
|
||||
hasCustomTheme: Boolean(state.settings.customTheme),
|
||||
customThemeLabel: state.settings.customTheme?.label ?? null,
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setTheme: (theme, context) => state.setTheme(theme, context),
|
||||
onOpenCustomThemeImport: () => {
|
||||
state.setTab("appearance");
|
||||
state.appearanceFormMode = "form";
|
||||
state.appearanceSearchQuery = "";
|
||||
state.appearanceActiveSection = "__appearance__";
|
||||
state.appearanceActiveSubsection = null;
|
||||
state.openCustomThemeImport();
|
||||
requestHostUpdate?.();
|
||||
},
|
||||
setThemeMode: (mode, context) => state.setThemeMode(mode, context),
|
||||
setBorderRadius: (value) => state.setBorderRadius(value),
|
||||
userName: state.userName ?? null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
setTabFromRoute,
|
||||
syncThemeWithSettings,
|
||||
} from "./app-settings.ts";
|
||||
import { normalizeImportedCustomTheme } from "./custom-theme.ts";
|
||||
import type { ThemeMode, ThemeName } from "./theme.ts";
|
||||
|
||||
type Tab =
|
||||
@@ -45,6 +46,7 @@ type SettingsHost = {
|
||||
navWidth: number;
|
||||
navGroupsCollapsed: Record<string, boolean>;
|
||||
borderRadius: number;
|
||||
customTheme?: import("./custom-theme.ts").ImportedCustomTheme;
|
||||
};
|
||||
theme: ThemeName & ThemeMode;
|
||||
themeMode: ThemeMode;
|
||||
@@ -180,6 +182,66 @@ const createHost = (tab: Tab): SettingsHost => ({
|
||||
wikiMemoryPalace: null,
|
||||
});
|
||||
|
||||
function createCustomThemeFixture() {
|
||||
return normalizeImportedCustomTheme(
|
||||
{
|
||||
name: "Light Green",
|
||||
cssVars: {
|
||||
theme: {
|
||||
"font-sans": "Inter, system-ui, sans-serif",
|
||||
"font-mono": "JetBrains Mono, monospace",
|
||||
},
|
||||
light: {
|
||||
background: "oklch(0.98 0.01 120)",
|
||||
foreground: "oklch(0.2 0.03 265)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.2 0.03 265)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.2 0.03 265)",
|
||||
primary: "oklch(0.8 0.2 128)",
|
||||
"primary-foreground": "oklch(0 0 0)",
|
||||
secondary: "oklch(0.35 0.03 257)",
|
||||
"secondary-foreground": "oklch(0.98 0.01 248)",
|
||||
muted: "oklch(0.96 0.01 248)",
|
||||
"muted-foreground": "oklch(0.55 0.04 257)",
|
||||
accent: "oklch(0.98 0.02 155)",
|
||||
"accent-foreground": "oklch(0.45 0.1 151)",
|
||||
destructive: "oklch(0.64 0.2 25)",
|
||||
"destructive-foreground": "oklch(1 0 0)",
|
||||
border: "oklch(0.92 0.01 255)",
|
||||
input: "oklch(0.92 0.01 255)",
|
||||
ring: "oklch(0.8 0.2 128)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.12 0.04 265)",
|
||||
foreground: "oklch(0.98 0.01 248)",
|
||||
card: "oklch(0.2 0.04 266)",
|
||||
"card-foreground": "oklch(0.98 0.01 248)",
|
||||
popover: "oklch(0.2 0.04 266)",
|
||||
"popover-foreground": "oklch(0.98 0.01 248)",
|
||||
primary: "oklch(0.8 0.2 128)",
|
||||
"primary-foreground": "oklch(0 0 0)",
|
||||
secondary: "oklch(0.28 0.04 260)",
|
||||
"secondary-foreground": "oklch(0.98 0.01 248)",
|
||||
muted: "oklch(0.28 0.04 260)",
|
||||
"muted-foreground": "oklch(0.71 0.03 257)",
|
||||
accent: "oklch(0.39 0.09 152)",
|
||||
"accent-foreground": "oklch(0.8 0.2 128)",
|
||||
destructive: "oklch(0.44 0.16 27)",
|
||||
"destructive-foreground": "oklch(1 0 0)",
|
||||
border: "oklch(0.28 0.04 260)",
|
||||
input: "oklch(0.28 0.04 260)",
|
||||
ring: "oklch(0.8 0.2 128)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("setTabFromRoute", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
@@ -242,6 +304,18 @@ describe("setTabFromRoute", () => {
|
||||
expect(host.themeResolved).toBe("dash-light");
|
||||
});
|
||||
|
||||
it("falls back to claw when custom is selected without a stored custom theme", () => {
|
||||
const host = createHost("chat");
|
||||
host.settings.theme = "custom";
|
||||
host.settings.themeMode = "dark";
|
||||
|
||||
syncThemeWithSettings(host);
|
||||
|
||||
expect(host.theme).toBe("claw");
|
||||
expect(host.settings.theme).toBe("claw");
|
||||
expect(host.themeResolved).toBe("dark");
|
||||
});
|
||||
|
||||
it("applies named system themes on OS preference changes", () => {
|
||||
const listeners: Array<(event: MediaQueryListEvent) => void> = [];
|
||||
const matchMedia = vi.fn().mockReturnValue({
|
||||
@@ -283,6 +357,22 @@ describe("setTabFromRoute", () => {
|
||||
expect(root.dataset.theme).toBe("dash-light");
|
||||
expect(root.style.colorScheme).toBe("light");
|
||||
});
|
||||
|
||||
it("applies imported custom light themes as light-mode tokens", () => {
|
||||
const root = {
|
||||
dataset: {} as DOMStringMap,
|
||||
style: { colorScheme: "" } as CSSStyleDeclaration & { colorScheme: string },
|
||||
};
|
||||
vi.stubGlobal("document", { documentElement: root } as Document);
|
||||
|
||||
const host = createHost("chat");
|
||||
host.settings.customTheme = createCustomThemeFixture();
|
||||
applyResolvedTheme(host, "custom-light");
|
||||
|
||||
expect(host.themeResolved).toBe("custom-light");
|
||||
expect(root.dataset.theme).toBe("custom-light");
|
||||
expect(root.style.colorScheme).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySettingsFromUrl", () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ import { loadPresence, type PresenceState } from "./controllers/presence.ts";
|
||||
import { loadSessions, type SessionsState } from "./controllers/sessions.ts";
|
||||
import { loadSkills, type SkillsState } from "./controllers/skills.ts";
|
||||
import { loadUsage, type UsageState } from "./controllers/usage.ts";
|
||||
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
|
||||
import { isMonitoredAuthProvider } from "./model-auth-helpers.ts";
|
||||
import {
|
||||
inferBasePathFromPathname,
|
||||
@@ -141,6 +142,7 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||
};
|
||||
host.settings = normalized;
|
||||
saveSettings(normalized);
|
||||
syncCustomThemeStyleTag(normalized.customTheme);
|
||||
if (next.theme !== host.theme || next.themeMode !== host.themeMode) {
|
||||
host.theme = next.theme;
|
||||
host.themeMode = next.themeMode;
|
||||
@@ -413,8 +415,17 @@ export function inferBasePath() {
|
||||
}
|
||||
|
||||
export function syncThemeWithSettings(host: SettingsHost) {
|
||||
host.theme = host.settings.theme ?? "claw";
|
||||
syncCustomThemeStyleTag(host.settings.customTheme);
|
||||
const normalizedTheme =
|
||||
host.settings.theme === "custom" && !host.settings.customTheme
|
||||
? "claw"
|
||||
: (host.settings.theme ?? "claw");
|
||||
host.theme = normalizedTheme;
|
||||
host.themeMode = host.settings.themeMode ?? "system";
|
||||
if (normalizedTheme !== host.settings.theme) {
|
||||
host.settings = { ...host.settings, theme: normalizedTheme };
|
||||
saveSettings(host.settings);
|
||||
}
|
||||
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
|
||||
applyBorderRadius(host.settings.borderRadius ?? 50);
|
||||
syncSystemThemeListener(host);
|
||||
|
||||
@@ -60,6 +60,11 @@ export type AppViewState = {
|
||||
themeMode: ThemeMode;
|
||||
themeResolved: ResolvedTheme;
|
||||
themeOrder: ThemeName[];
|
||||
customThemeImportUrl: string;
|
||||
customThemeImportBusy: boolean;
|
||||
customThemeImportMessage: { kind: "success" | "error"; text: string } | null;
|
||||
customThemeImportExpanded: boolean;
|
||||
customThemeImportFocusToken: number;
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
lastErrorCode: string | null;
|
||||
@@ -381,6 +386,10 @@ export type AppViewState = {
|
||||
setTab: (tab: Tab) => void;
|
||||
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
|
||||
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
setCustomThemeImportUrl: (next: string) => void;
|
||||
openCustomThemeImport: () => void;
|
||||
importCustomTheme: () => Promise<void>;
|
||||
clearCustomTheme: () => void;
|
||||
setBorderRadius: (value: number) => void;
|
||||
applySettings: (next: UiSettings) => void;
|
||||
applyLocalUserIdentity?: (next: { name?: string | null; avatar?: string | null }) => void;
|
||||
|
||||
@@ -77,6 +77,7 @@ import type {
|
||||
ClawHubSkillDetail,
|
||||
SkillMessage,
|
||||
} from "./controllers/skills.ts";
|
||||
import { importCustomThemeFromUrl } from "./custom-theme.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import type { SidebarContent } from "./sidebar-content.ts";
|
||||
@@ -155,6 +156,11 @@ export class OpenClawApp extends LitElement {
|
||||
@state() themeMode: ThemeMode = this.settings.themeMode ?? "system";
|
||||
@state() themeResolved: ResolvedTheme = "dark";
|
||||
@state() themeOrder: ThemeName[] = this.buildThemeOrder(this.theme);
|
||||
@state() customThemeImportUrl = "";
|
||||
@state() customThemeImportBusy = false;
|
||||
@state() customThemeImportMessage: { kind: "success" | "error"; text: string } | null = null;
|
||||
@state() customThemeImportExpanded = false;
|
||||
@state() customThemeImportFocusToken = 0;
|
||||
@state() hello: GatewayHelloOk | null = null;
|
||||
@state() lastError: string | null = null;
|
||||
@state() lastErrorCode: string | null = null;
|
||||
@@ -672,6 +678,61 @@ export class OpenClawApp extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
setCustomThemeImportUrl(next: string) {
|
||||
this.customThemeImportUrl = next;
|
||||
if (this.customThemeImportMessage?.kind === "error") {
|
||||
this.customThemeImportMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
openCustomThemeImport() {
|
||||
this.customThemeImportExpanded = true;
|
||||
this.customThemeImportFocusToken += 1;
|
||||
}
|
||||
|
||||
async importCustomTheme() {
|
||||
if (this.customThemeImportBusy) {
|
||||
return;
|
||||
}
|
||||
this.customThemeImportExpanded = true;
|
||||
this.customThemeImportBusy = true;
|
||||
this.customThemeImportMessage = null;
|
||||
try {
|
||||
const customTheme = await importCustomThemeFromUrl(this.customThemeImportUrl);
|
||||
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
|
||||
...this.settings,
|
||||
customTheme,
|
||||
});
|
||||
this.customThemeImportUrl = "";
|
||||
this.customThemeImportMessage = {
|
||||
kind: "success",
|
||||
text: `Imported ${customTheme.label}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
this.customThemeImportMessage = {
|
||||
kind: "error",
|
||||
text: error instanceof Error ? error.message : "Failed to import tweakcn theme.",
|
||||
};
|
||||
} finally {
|
||||
this.customThemeImportBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
clearCustomTheme() {
|
||||
const nextTheme = this.theme === "custom" ? "claw" : this.theme;
|
||||
this.customThemeImportExpanded = true;
|
||||
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
|
||||
...this.settings,
|
||||
theme: nextTheme,
|
||||
customTheme: undefined,
|
||||
});
|
||||
this.themeOrder = this.buildThemeOrder(nextTheme);
|
||||
this.customThemeImportMessage = {
|
||||
kind: "success",
|
||||
text: "Cleared custom theme.",
|
||||
};
|
||||
}
|
||||
|
||||
setBorderRadius(value: number) {
|
||||
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
|
||||
...this.settings,
|
||||
|
||||
@@ -491,6 +491,7 @@ describe("grouped chat rendering", () => {
|
||||
isToolMessageExpanded: () => false,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-bubble--tool-shell")).not.toBeNull();
|
||||
const summary = container.querySelector<HTMLElement>(".chat-tool-msg-summary");
|
||||
expect(summary?.textContent).toContain("Tool call");
|
||||
expect(container.textContent).not.toContain('"thread": true');
|
||||
|
||||
@@ -1404,7 +1404,13 @@ function renderGroupedMessage(
|
||||
// Detect pure-JSON messages and render as collapsible block
|
||||
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
|
||||
|
||||
const bubbleClasses = ["chat-bubble", opts.isStreaming ? "streaming" : "", "fade-in"]
|
||||
const isToolMessage = normalizedRole === "tool" || isToolResult;
|
||||
const bubbleClasses = [
|
||||
"chat-bubble",
|
||||
isToolMessage ? "chat-bubble--tool-shell" : "",
|
||||
opts.isStreaming ? "streaming" : "",
|
||||
"fade-in",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
@@ -1421,7 +1427,6 @@ function renderGroupedMessage(
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const isToolMessage = normalizedRole === "tool" || isToolResult;
|
||||
const toolMessageDisclosureId = `toolmsg:${messageKey}`;
|
||||
const toolMessageExpanded = opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false;
|
||||
const toolNames = [...new Set(toolCards.map((c) => c.name))];
|
||||
|
||||
267
ui/src/ui/custom-theme.test.ts
Normal file
267
ui/src/ui/custom-theme.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildCustomThemeStyles,
|
||||
importCustomThemeFromUrl,
|
||||
normalizeImportedCustomTheme,
|
||||
normalizeTweakcnThemeUrl,
|
||||
parseImportedCustomTheme,
|
||||
syncCustomThemeStyleTag,
|
||||
} from "./custom-theme.ts";
|
||||
import type { ImportedCustomTheme } from "./custom-theme.ts";
|
||||
|
||||
function createTweakcnPayload() {
|
||||
return {
|
||||
name: "Light Green",
|
||||
cssVars: {
|
||||
theme: {
|
||||
"font-sans": "Inter, system-ui, sans-serif",
|
||||
"font-mono": "JetBrains Mono, monospace",
|
||||
},
|
||||
light: {
|
||||
background: "oklch(0.98 0.01 120)",
|
||||
foreground: "oklch(0.2 0.03 265)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.2 0.03 265)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.2 0.03 265)",
|
||||
primary: "oklch(0.8 0.2 128)",
|
||||
"primary-foreground": "oklch(0 0 0)",
|
||||
secondary: "oklch(0.35 0.03 257)",
|
||||
"secondary-foreground": "oklch(0.98 0.01 248)",
|
||||
muted: "oklch(0.96 0.01 248)",
|
||||
"muted-foreground": "oklch(0.55 0.04 257)",
|
||||
accent: "oklch(0.98 0.02 155)",
|
||||
"accent-foreground": "oklch(0.45 0.1 151)",
|
||||
destructive: "oklch(0.64 0.2 25)",
|
||||
"destructive-foreground": "oklch(1 0 0)",
|
||||
border: "oklch(0.92 0.01 255)",
|
||||
input: "oklch(0.92 0.01 255)",
|
||||
ring: "oklch(0.8 0.2 128)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.12 0.04 265)",
|
||||
foreground: "oklch(0.98 0.01 248)",
|
||||
card: "oklch(0.2 0.04 266)",
|
||||
"card-foreground": "oklch(0.98 0.01 248)",
|
||||
popover: "oklch(0.2 0.04 266)",
|
||||
"popover-foreground": "oklch(0.98 0.01 248)",
|
||||
primary: "oklch(0.8 0.2 128)",
|
||||
"primary-foreground": "oklch(0 0 0)",
|
||||
secondary: "oklch(0.28 0.04 260)",
|
||||
"secondary-foreground": "oklch(0.98 0.01 248)",
|
||||
muted: "oklch(0.28 0.04 260)",
|
||||
"muted-foreground": "oklch(0.71 0.03 257)",
|
||||
accent: "oklch(0.39 0.09 152)",
|
||||
"accent-foreground": "oklch(0.8 0.2 128)",
|
||||
destructive: "oklch(0.44 0.16 27)",
|
||||
"destructive-foreground": "oklch(1 0 0)",
|
||||
border: "oklch(0.28 0.04 260)",
|
||||
input: "oklch(0.28 0.04 260)",
|
||||
ring: "oklch(0.8 0.2 128)",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createImportedTheme() {
|
||||
return normalizeImportedCustomTheme(createTweakcnPayload(), {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
}
|
||||
|
||||
function createResponse(
|
||||
body: string,
|
||||
options: {
|
||||
body?: ReadableStream<Uint8Array> | null;
|
||||
headers?: HeadersInit;
|
||||
status?: number;
|
||||
url?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
ok: (options.status ?? 200) >= 200 && (options.status ?? 200) < 300,
|
||||
status: options.status ?? 200,
|
||||
headers: new Headers(options.headers),
|
||||
body:
|
||||
options.body === undefined
|
||||
? new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
: options.body,
|
||||
text: vi.fn(async () => body),
|
||||
url: options.url ?? "",
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("custom theme import helpers", () => {
|
||||
it("normalizes tweakcn share links and raw registry links", () => {
|
||||
expect(
|
||||
normalizeTweakcnThemeUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z"),
|
||||
).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
expect(
|
||||
normalizeTweakcnThemeUrl("https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z"),
|
||||
).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a tweakcn payload into a normalized imported theme record", () => {
|
||||
const imported = createImportedTheme();
|
||||
|
||||
expect(imported.label).toBe("Light Green");
|
||||
expect(imported.sourceUrl).toBe("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z");
|
||||
expect(imported.light.bg).toBe("oklch(0.98 0.01 120)");
|
||||
expect(imported.dark.bg).toBe("oklch(0.12 0.04 265)");
|
||||
expect(imported.light["font-body"]).toBe("Inter, system-ui, sans-serif");
|
||||
expect(imported.dark["accent-hover"]).toContain("color-mix");
|
||||
});
|
||||
|
||||
it("fetches tweakcn themes with bounded no-redirect requests", async () => {
|
||||
const response = createResponse(JSON.stringify(createTweakcnPayload()));
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
const imported = await importCustomThemeFromUrl(
|
||||
"https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchImpl,
|
||||
);
|
||||
|
||||
expect(imported.label).toBe("Light Green");
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
expect.objectContaining({
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized tweakcn theme responses before parsing", async () => {
|
||||
const response = createResponse("{}", {
|
||||
headers: { "content-length": "200001" },
|
||||
});
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
importCustomThemeFromUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", fetchImpl),
|
||||
).rejects.toThrow("too large");
|
||||
});
|
||||
|
||||
it("rejects tweakcn theme responses without a bounded body stream", async () => {
|
||||
const response = createResponse(JSON.stringify(createTweakcnPayload()), { body: null });
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
importCustomThemeFromUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", fetchImpl),
|
||||
).rejects.toThrow("unreadable theme payload");
|
||||
expect(response.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects redirected tweakcn import responses", async () => {
|
||||
const response = createResponse(JSON.stringify(createTweakcnPayload()), {
|
||||
url: "https://example.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
importCustomThemeFromUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", fetchImpl),
|
||||
).rejects.toThrow("Unexpected redirect");
|
||||
});
|
||||
|
||||
it("rejects CSS tokens that can escape variables or trigger external requests", () => {
|
||||
const payload = createTweakcnPayload();
|
||||
payload.cssVars.light.background = 'url("https://example.com/track")';
|
||||
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
|
||||
payload.cssVars.light.background = "oklch(0.98 0.01 120)/*";
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
|
||||
payload.cssVars.light.background = 'image-set("https://example.com/pixel.png" 1x)';
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
|
||||
payload.cssVars.light.background = "oklch(0.98 0.01 120)";
|
||||
payload.cssVars.theme["font-sans"] = "var(--attacker-font)";
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
});
|
||||
|
||||
it("builds stable CSS blocks for custom dark and light themes", () => {
|
||||
const css = buildCustomThemeStyles(createImportedTheme());
|
||||
|
||||
expect(css).toContain(':root[data-theme="custom"]');
|
||||
expect(css).toContain(':root[data-theme="custom-light"]');
|
||||
expect(css).toContain("--bg: oklch(0.12 0.04 265);");
|
||||
expect(css).toContain("--bg: oklch(0.98 0.01 120);");
|
||||
});
|
||||
|
||||
it("throws when stored custom theme tokens are missing", () => {
|
||||
const theme = { ...createImportedTheme(), light: undefined } as unknown as ImportedCustomTheme;
|
||||
|
||||
expect(() => buildCustomThemeStyles(theme)).toThrow(
|
||||
"Stored custom theme is missing required tokens.",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses stored imported themes and rejects malformed records", () => {
|
||||
const imported = createImportedTheme();
|
||||
|
||||
expect(parseImportedCustomTheme(imported)?.themeId).toBe("cmlhfpjhw000004l4f4ax3m7z");
|
||||
expect(parseImportedCustomTheme({ ...imported, light: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it("syncs the managed custom theme style tag in the document head", () => {
|
||||
const appendChild = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const style = { id: "", textContent: "", remove } as unknown as HTMLStyleElement;
|
||||
const documentStub = {
|
||||
head: { appendChild },
|
||||
createElement: vi.fn(() => style),
|
||||
getElementById: vi.fn(() => null),
|
||||
} as unknown as Document;
|
||||
vi.stubGlobal("document", documentStub);
|
||||
|
||||
syncCustomThemeStyleTag(createImportedTheme());
|
||||
|
||||
expect(appendChild).toHaveBeenCalledWith(style);
|
||||
expect(style.textContent).toContain(':root[data-theme="custom"]');
|
||||
|
||||
vi.stubGlobal("document", {
|
||||
head: documentStub.head,
|
||||
createElement: documentStub.createElement,
|
||||
getElementById: vi.fn(() => style),
|
||||
} as unknown as Document);
|
||||
|
||||
syncCustomThemeStyleTag(null);
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
593
ui/src/ui/custom-theme.ts
Normal file
593
ui/src/ui/custom-theme.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
import { z } from "zod";
|
||||
import { normalizeOptionalString } from "./string-coerce.ts";
|
||||
|
||||
const TWEAKCN_HOSTS = new Set(["tweakcn.com", "www.tweakcn.com"]);
|
||||
const THEME_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
||||
const CUSTOM_THEME_STYLE_ID = "openclaw-custom-theme";
|
||||
const MAX_TWEAKCN_THEME_BYTES = 200_000;
|
||||
const MAX_CSS_TOKEN_LENGTH = 240;
|
||||
const TWEAKCN_FETCH_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_FONT_BODY =
|
||||
'"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
const DEFAULT_MONO =
|
||||
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace';
|
||||
const FORBIDDEN_CSS_VALUE_PARTS = [
|
||||
"url(",
|
||||
"image(",
|
||||
"image-set(",
|
||||
"-webkit-image-set(",
|
||||
"cross-fade(",
|
||||
"element(",
|
||||
"-moz-element(",
|
||||
"paint(",
|
||||
"@import",
|
||||
"expression(",
|
||||
] as const;
|
||||
const SAFE_COLOR_KEYWORDS = new Set(["black", "white", "transparent", "currentcolor"]);
|
||||
const SAFE_COLOR_FUNCTION_PATTERN =
|
||||
/^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\([a-z0-9+\-.,/%\s]+\)$/i;
|
||||
const SAFE_HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const SAFE_FONT_FAMILY_PATTERN = /^[a-z0-9\s,'"._-]+(?:,\s*[a-z0-9\s'"._-]+)*$/i;
|
||||
|
||||
const MODE_TOKEN_ORDER = [
|
||||
"bg",
|
||||
"bg-accent",
|
||||
"bg-elevated",
|
||||
"bg-hover",
|
||||
"bg-muted",
|
||||
"bg-content",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"card-highlight",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"panel",
|
||||
"panel-strong",
|
||||
"panel-hover",
|
||||
"chrome",
|
||||
"chrome-strong",
|
||||
"text",
|
||||
"text-strong",
|
||||
"chat-text",
|
||||
"muted",
|
||||
"muted-strong",
|
||||
"muted-foreground",
|
||||
"border",
|
||||
"border-strong",
|
||||
"border-hover",
|
||||
"input",
|
||||
"ring",
|
||||
"accent",
|
||||
"accent-hover",
|
||||
"accent-muted",
|
||||
"accent-subtle",
|
||||
"accent-foreground",
|
||||
"accent-glow",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"accent-2",
|
||||
"accent-2-muted",
|
||||
"accent-2-subtle",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"danger",
|
||||
"danger-muted",
|
||||
"danger-subtle",
|
||||
"focus",
|
||||
"focus-ring",
|
||||
"focus-glow",
|
||||
"font-body",
|
||||
"font-display",
|
||||
"mono",
|
||||
"grid-line",
|
||||
] as const;
|
||||
|
||||
type ModeTokenName = (typeof MODE_TOKEN_ORDER)[number];
|
||||
type ThemeTokenMap = Record<ModeTokenName, string>;
|
||||
|
||||
const REQUIRED_TWEAKCN_MODE_VARS = [
|
||||
"background",
|
||||
"foreground",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"accent",
|
||||
"accent-foreground",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
] as const;
|
||||
type RequiredTweakcnModeVar = (typeof REQUIRED_TWEAKCN_MODE_VARS)[number];
|
||||
|
||||
export type ImportedCustomTheme = {
|
||||
sourceUrl: string;
|
||||
themeId: string;
|
||||
label: string;
|
||||
importedAt: string;
|
||||
light: ThemeTokenMap;
|
||||
dark: ThemeTokenMap;
|
||||
};
|
||||
|
||||
const cssTokenSchema = z.string().max(MAX_CSS_TOKEN_LENGTH);
|
||||
|
||||
function createStringShape<const T extends readonly string[]>(keys: T) {
|
||||
return Object.fromEntries(keys.map((key) => [key, cssTokenSchema])) as Record<
|
||||
T[number],
|
||||
typeof cssTokenSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const tweakcnThemeSchema = z.object({
|
||||
name: z.string().max(80).optional(),
|
||||
cssVars: z.object({
|
||||
theme: z
|
||||
.object({
|
||||
"font-sans": cssTokenSchema.optional(),
|
||||
"font-mono": cssTokenSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
light: z.object(createStringShape(REQUIRED_TWEAKCN_MODE_VARS)),
|
||||
dark: z.object(createStringShape(REQUIRED_TWEAKCN_MODE_VARS)),
|
||||
}),
|
||||
});
|
||||
|
||||
const importedCustomThemeSchema = z.object({
|
||||
sourceUrl: z.string(),
|
||||
themeId: z.string(),
|
||||
label: z.string(),
|
||||
importedAt: z.string(),
|
||||
light: z.object(createStringShape(MODE_TOKEN_ORDER)),
|
||||
dark: z.object(createStringShape(MODE_TOKEN_ORDER)),
|
||||
});
|
||||
|
||||
type TweakcnThemePayload = z.infer<typeof tweakcnThemeSchema>;
|
||||
|
||||
type TweakcnThemeResolution = {
|
||||
sourceUrl: string;
|
||||
fetchUrl: string;
|
||||
themeId: string;
|
||||
};
|
||||
|
||||
function requireThemeId(value: string) {
|
||||
if (!THEME_ID_PATTERN.test(value)) {
|
||||
throw new Error("Unsupported tweakcn link. Expected a theme share URL.");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeThemeIdFromPath(pathname: string): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 2 && segments[0] === "themes") {
|
||||
requireThemeId(segments[1]);
|
||||
return segments[1];
|
||||
}
|
||||
if (segments.length === 3 && segments[0] === "r" && segments[1] === "themes") {
|
||||
requireThemeId(segments[2]);
|
||||
return segments[2];
|
||||
}
|
||||
throw new Error("Unsupported tweakcn link. Expected a theme share URL.");
|
||||
}
|
||||
|
||||
function requireSafeCssValue(value: unknown, label: string) {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
if (normalized.length > MAX_CSS_TOKEN_LENGTH) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (FORBIDDEN_CSS_VALUE_PARTS.some((part) => lowered.includes(part))) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
if (normalized.includes("/*") || normalized.includes("*/") || normalized.includes("\\")) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
for (const char of normalized) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (
|
||||
code < 0x20 ||
|
||||
code === 0x7f ||
|
||||
char === "{" ||
|
||||
char === "}" ||
|
||||
char === ";" ||
|
||||
char === "<" ||
|
||||
char === ">" ||
|
||||
char === "`"
|
||||
) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireSafeExternalColorValue(value: unknown, label: string) {
|
||||
const normalized = requireSafeCssValue(value, label);
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (
|
||||
SAFE_COLOR_KEYWORDS.has(lowered) ||
|
||||
SAFE_HEX_COLOR_PATTERN.test(normalized) ||
|
||||
SAFE_COLOR_FUNCTION_PATTERN.test(normalized)
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
|
||||
function requireSafeFontFamilyValue(value: unknown, label: string) {
|
||||
const normalized = requireSafeCssValue(value, label);
|
||||
if (
|
||||
normalized.includes("(") ||
|
||||
normalized.includes(")") ||
|
||||
!SAFE_FONT_FAMILY_PATTERN.test(normalized)
|
||||
) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireSafeExternalModeValue(value: unknown, label: string) {
|
||||
if (label === "font-sans" || label === "font-mono") {
|
||||
return requireSafeFontFamilyValue(value, label);
|
||||
}
|
||||
return requireSafeExternalColorValue(value, label);
|
||||
}
|
||||
|
||||
function makeTokenMap(entries: Array<[ModeTokenName, string]>): ThemeTokenMap {
|
||||
return Object.fromEntries(entries) as ThemeTokenMap;
|
||||
}
|
||||
|
||||
function normalizeStoredTokenMap(value: Record<string, string> | undefined): ThemeTokenMap | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries: Array<[ModeTokenName, string]> = [];
|
||||
for (const key of MODE_TOKEN_ORDER) {
|
||||
const normalized =
|
||||
key === "font-body" || key === "font-display" || key === "mono"
|
||||
? requireSafeFontFamilyValue(value[key], key)
|
||||
: requireSafeCssValue(value[key], key);
|
||||
entries.push([key, normalized]);
|
||||
}
|
||||
return makeTokenMap(entries);
|
||||
}
|
||||
|
||||
function resolveModeVar(
|
||||
theme: Record<string, string | undefined>,
|
||||
shared: Record<string, string | undefined> | undefined,
|
||||
key: string,
|
||||
fallback?: string,
|
||||
) {
|
||||
const themeValue = normalizeOptionalString(theme[key]);
|
||||
if (themeValue) {
|
||||
return requireSafeExternalModeValue(themeValue, key);
|
||||
}
|
||||
const sharedValue = normalizeOptionalString(shared?.[key]);
|
||||
if (sharedValue) {
|
||||
return requireSafeExternalModeValue(sharedValue, key);
|
||||
}
|
||||
if (fallback != null) {
|
||||
return key === "font-sans" || key === "font-mono"
|
||||
? requireSafeFontFamilyValue(fallback, key)
|
||||
: requireSafeCssValue(fallback, key);
|
||||
}
|
||||
throw new Error(`tweakcn theme is missing required token: ${key}`);
|
||||
}
|
||||
|
||||
function normalizeModeTokenMap(
|
||||
mode: "light" | "dark",
|
||||
theme: Record<RequiredTweakcnModeVar, string>,
|
||||
shared: Record<string, string | undefined> | undefined,
|
||||
): ThemeTokenMap {
|
||||
const isLight = mode === "light";
|
||||
const contrastTarget = isLight ? "black" : "white";
|
||||
const background = resolveModeVar(theme, shared, "background");
|
||||
const foreground = resolveModeVar(theme, shared, "foreground");
|
||||
const card = resolveModeVar(theme, shared, "card");
|
||||
const cardForeground = resolveModeVar(theme, shared, "card-foreground");
|
||||
const popover = resolveModeVar(theme, shared, "popover");
|
||||
const popoverForeground = resolveModeVar(theme, shared, "popover-foreground");
|
||||
const primary = resolveModeVar(theme, shared, "primary");
|
||||
const primaryForeground = resolveModeVar(theme, shared, "primary-foreground");
|
||||
const secondary = resolveModeVar(theme, shared, "secondary");
|
||||
const secondaryForeground = resolveModeVar(theme, shared, "secondary-foreground");
|
||||
const muted = resolveModeVar(theme, shared, "muted");
|
||||
const mutedForeground = resolveModeVar(theme, shared, "muted-foreground");
|
||||
const accent = resolveModeVar(theme, shared, "accent");
|
||||
const accentForeground = resolveModeVar(theme, shared, "accent-foreground");
|
||||
const destructive = resolveModeVar(theme, shared, "destructive");
|
||||
const destructiveForeground = resolveModeVar(theme, shared, "destructive-foreground");
|
||||
const border = resolveModeVar(theme, shared, "border");
|
||||
const input = resolveModeVar(theme, shared, "input");
|
||||
const ring = resolveModeVar(theme, shared, "ring");
|
||||
const fontBody = resolveModeVar(theme, shared, "font-sans", DEFAULT_FONT_BODY);
|
||||
const mono = resolveModeVar(theme, shared, "font-mono", DEFAULT_MONO);
|
||||
|
||||
return makeTokenMap([
|
||||
["bg", background],
|
||||
["bg-accent", "color-mix(in srgb, var(--bg) 88%, var(--card) 12%)"],
|
||||
["bg-elevated", card],
|
||||
["bg-hover", "color-mix(in srgb, var(--muted) 68%, var(--bg) 32%)"],
|
||||
["bg-muted", muted],
|
||||
["bg-content", "color-mix(in srgb, var(--bg) 92%, var(--card) 8%)"],
|
||||
["card", card],
|
||||
["card-foreground", cardForeground],
|
||||
["card-highlight", `color-mix(in srgb, var(--text) ${isLight ? "3" : "5"}%, transparent)`],
|
||||
["popover", popover],
|
||||
["popover-foreground", popoverForeground],
|
||||
["panel", background],
|
||||
["panel-strong", card],
|
||||
["panel-hover", "color-mix(in srgb, var(--card) 76%, var(--muted) 24%)"],
|
||||
["chrome", "color-mix(in srgb, var(--bg) 96%, transparent)"],
|
||||
["chrome-strong", "color-mix(in srgb, var(--bg) 98%, transparent)"],
|
||||
["text", foreground],
|
||||
["text-strong", foreground],
|
||||
["chat-text", foreground],
|
||||
["muted", mutedForeground],
|
||||
["muted-strong", "color-mix(in srgb, var(--muted) 84%, var(--text) 16%)"],
|
||||
["muted-foreground", mutedForeground],
|
||||
["border", border],
|
||||
["border-strong", "color-mix(in srgb, var(--border) 72%, var(--text) 28%)"],
|
||||
["border-hover", "color-mix(in srgb, var(--border) 55%, var(--text) 45%)"],
|
||||
["input", input],
|
||||
["ring", ring],
|
||||
["accent", accent],
|
||||
["accent-hover", `color-mix(in srgb, var(--accent) 82%, ${contrastTarget} 18%)`],
|
||||
["accent-muted", accent],
|
||||
["accent-subtle", `color-mix(in srgb, var(--accent) ${isLight ? "10" : "16"}%, transparent)`],
|
||||
["accent-foreground", accentForeground],
|
||||
["accent-glow", `color-mix(in srgb, var(--accent) ${isLight ? "18" : "30"}%, transparent)`],
|
||||
["primary", primary],
|
||||
["primary-foreground", primaryForeground],
|
||||
["secondary", secondary],
|
||||
["secondary-foreground", secondaryForeground],
|
||||
["accent-2", primary],
|
||||
["accent-2-muted", "color-mix(in srgb, var(--accent-2) 72%, transparent)"],
|
||||
[
|
||||
"accent-2-subtle",
|
||||
`color-mix(in srgb, var(--accent-2) ${isLight ? "8" : "12"}%, transparent)`,
|
||||
],
|
||||
["destructive", destructive],
|
||||
["destructive-foreground", destructiveForeground],
|
||||
["danger", destructive],
|
||||
["danger-muted", "color-mix(in srgb, var(--danger) 75%, transparent)"],
|
||||
["danger-subtle", `color-mix(in srgb, var(--danger) ${isLight ? "8" : "12"}%, transparent)`],
|
||||
["focus", `color-mix(in srgb, var(--ring) ${isLight ? "14" : "22"}%, transparent)`],
|
||||
[
|
||||
"focus-ring",
|
||||
`0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) ${isLight ? "70" : "80"}%, transparent)`,
|
||||
],
|
||||
["focus-glow", "0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)"],
|
||||
["font-body", fontBody],
|
||||
["font-display", fontBody],
|
||||
["mono", mono],
|
||||
["grid-line", `color-mix(in srgb, var(--text) ${isLight ? "4" : "3"}%, transparent)`],
|
||||
]);
|
||||
}
|
||||
|
||||
function describeThemeLabel(value: string | undefined) {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
return "Custom";
|
||||
}
|
||||
return normalized.slice(0, 80);
|
||||
}
|
||||
|
||||
export function normalizeTweakcnThemeUrl(input: string): TweakcnThemeResolution {
|
||||
const normalized = normalizeOptionalString(input);
|
||||
if (!normalized) {
|
||||
throw new Error("Paste a tweakcn theme link to import.");
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error("Paste a full tweakcn URL.");
|
||||
}
|
||||
if (!TWEAKCN_HOSTS.has(parsed.hostname)) {
|
||||
throw new Error("Only tweakcn.com theme links are supported.");
|
||||
}
|
||||
const themeId = normalizeThemeIdFromPath(parsed.pathname);
|
||||
return {
|
||||
themeId,
|
||||
sourceUrl: `https://tweakcn.com/themes/${themeId}`,
|
||||
fetchUrl: `https://tweakcn.com/r/themes/${themeId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseImportedCustomTheme(value: unknown): ImportedCustomTheme | null {
|
||||
const parsed = importedCustomThemeSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
requireThemeId(parsed.data.themeId);
|
||||
const light = normalizeStoredTokenMap(parsed.data.light);
|
||||
const dark = normalizeStoredTokenMap(parsed.data.dark);
|
||||
if (!light || !dark) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceUrl: parsed.data.sourceUrl,
|
||||
themeId: parsed.data.themeId,
|
||||
label: describeThemeLabel(parsed.data.label),
|
||||
importedAt: parsed.data.importedAt,
|
||||
light,
|
||||
dark,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeImportedCustomTheme(
|
||||
payload: unknown,
|
||||
resolution: Pick<TweakcnThemeResolution, "sourceUrl" | "themeId">,
|
||||
): ImportedCustomTheme {
|
||||
const parsed = tweakcnThemeSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new Error("tweakcn returned an invalid theme payload.");
|
||||
}
|
||||
const data: TweakcnThemePayload = parsed.data;
|
||||
const shared = data.cssVars.theme;
|
||||
return {
|
||||
sourceUrl: resolution.sourceUrl,
|
||||
themeId: resolution.themeId,
|
||||
label: describeThemeLabel(data.name),
|
||||
importedAt: new Date().toISOString(),
|
||||
light: normalizeModeTokenMap("light", data.cssVars.light, shared),
|
||||
dark: normalizeModeTokenMap("dark", data.cssVars.dark, shared),
|
||||
};
|
||||
}
|
||||
|
||||
function assertTweakcnResponseUrl(value: string | undefined) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Unexpected tweakcn import response URL.");
|
||||
}
|
||||
if (parsed.protocol !== "https:" || !TWEAKCN_HOSTS.has(parsed.hostname)) {
|
||||
throw new Error("Unexpected redirect during tweakcn import.");
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentLength(headers: Headers): number | null {
|
||||
const raw = headers.get("content-length");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
async function readResponseTextWithLimit(response: Response): Promise<string> {
|
||||
const contentLength = parseContentLength(response.headers);
|
||||
if (contentLength != null && contentLength > MAX_TWEAKCN_THEME_BYTES) {
|
||||
throw new Error("tweakcn theme payload is too large.");
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("tweakcn returned an unreadable theme payload.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) {
|
||||
break;
|
||||
}
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > MAX_TWEAKCN_THEME_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error("tweakcn theme payload is too large.");
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonResponseWithLimit(response: Response): Promise<unknown> {
|
||||
const text = await readResponseTextWithLimit(response);
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new Error("tweakcn returned invalid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function importCustomThemeFromUrl(
|
||||
input: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ImportedCustomTheme> {
|
||||
const resolution = normalizeTweakcnThemeUrl(input);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TWEAKCN_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(resolution.fetchUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
assertTweakcnResponseUrl(response.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`tweakcn import failed (${response.status}).`);
|
||||
}
|
||||
const payload = await readJsonResponseWithLimit(response);
|
||||
return normalizeImportedCustomTheme(payload, resolution);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error("tweakcn import timed out.", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCustomThemeStyles(theme: ImportedCustomTheme) {
|
||||
const light = normalizeStoredTokenMap(theme.light);
|
||||
const dark = normalizeStoredTokenMap(theme.dark);
|
||||
if (!light || !dark) {
|
||||
throw new Error("Stored custom theme is missing required tokens.");
|
||||
}
|
||||
const renderDeclarations = (modeTokens: ThemeTokenMap) =>
|
||||
MODE_TOKEN_ORDER.map((key) => ` --${key}: ${modeTokens[key]};`).join("\n");
|
||||
return [
|
||||
`:root[data-theme="custom"] {`,
|
||||
renderDeclarations(dark),
|
||||
`}`,
|
||||
`:root[data-theme="custom-light"] {`,
|
||||
renderDeclarations(light),
|
||||
`}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function syncCustomThemeStyleTag(theme: ImportedCustomTheme | null | undefined) {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
let style = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!theme) {
|
||||
style?.remove();
|
||||
return;
|
||||
}
|
||||
let cssText = "";
|
||||
try {
|
||||
cssText = buildCustomThemeStyles(theme);
|
||||
} catch {
|
||||
style?.remove();
|
||||
return;
|
||||
}
|
||||
if (!cssText) {
|
||||
style?.remove();
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = CUSTOM_THEME_STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = cssText;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
saveLocalUserIdentity,
|
||||
saveSettings,
|
||||
} from "./storage.ts";
|
||||
import { normalizeImportedCustomTheme } from "./custom-theme.ts";
|
||||
|
||||
function setTestLocation(params: { protocol: string; host: string; pathname: string }) {
|
||||
vi.stubGlobal("location", {
|
||||
@@ -43,6 +44,66 @@ function expectedGatewayUrl(basePath: string): string {
|
||||
return `${proto}://${location.host}${basePath}`;
|
||||
}
|
||||
|
||||
function createCustomThemeFixture() {
|
||||
return normalizeImportedCustomTheme(
|
||||
{
|
||||
name: "Light Green",
|
||||
cssVars: {
|
||||
theme: {
|
||||
"font-sans": "Inter, system-ui, sans-serif",
|
||||
"font-mono": "JetBrains Mono, monospace",
|
||||
},
|
||||
light: {
|
||||
background: "oklch(0.98 0.01 120)",
|
||||
foreground: "oklch(0.2 0.03 265)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.2 0.03 265)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.2 0.03 265)",
|
||||
primary: "oklch(0.8 0.2 128)",
|
||||
"primary-foreground": "oklch(0 0 0)",
|
||||
secondary: "oklch(0.35 0.03 257)",
|
||||
"secondary-foreground": "oklch(0.98 0.01 248)",
|
||||
muted: "oklch(0.96 0.01 248)",
|
||||
"muted-foreground": "oklch(0.55 0.04 257)",
|
||||
accent: "oklch(0.98 0.02 155)",
|
||||
"accent-foreground": "oklch(0.45 0.1 151)",
|
||||
destructive: "oklch(0.64 0.2 25)",
|
||||
"destructive-foreground": "oklch(1 0 0)",
|
||||
border: "oklch(0.92 0.01 255)",
|
||||
input: "oklch(0.92 0.01 255)",
|
||||
ring: "oklch(0.8 0.2 128)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.12 0.04 265)",
|
||||
foreground: "oklch(0.98 0.01 248)",
|
||||
card: "oklch(0.2 0.04 266)",
|
||||
"card-foreground": "oklch(0.98 0.01 248)",
|
||||
popover: "oklch(0.2 0.04 266)",
|
||||
"popover-foreground": "oklch(0.98 0.01 248)",
|
||||
primary: "oklch(0.8 0.2 128)",
|
||||
"primary-foreground": "oklch(0 0 0)",
|
||||
secondary: "oklch(0.28 0.04 260)",
|
||||
"secondary-foreground": "oklch(0.98 0.01 248)",
|
||||
muted: "oklch(0.28 0.04 260)",
|
||||
"muted-foreground": "oklch(0.71 0.03 257)",
|
||||
accent: "oklch(0.39 0.09 152)",
|
||||
"accent-foreground": "oklch(0.8 0.2 128)",
|
||||
destructive: "oklch(0.44 0.16 27)",
|
||||
"destructive-foreground": "oklch(1 0 0)",
|
||||
border: "oklch(0.28 0.04 260)",
|
||||
input: "oklch(0.28 0.04 260)",
|
||||
ring: "oklch(0.8 0.2 128)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("loadSettings default gateway URL derivation", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
@@ -357,6 +418,87 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists the browser-local custom theme payload when present", async () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
const customTheme = createCustomThemeFixture();
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "custom",
|
||||
themeMode: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
customTheme,
|
||||
});
|
||||
|
||||
expect(loadSettings()).toMatchObject({
|
||||
theme: "custom",
|
||||
customTheme: {
|
||||
label: "Light Green",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to claw when persisted custom theme data is invalid", async () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
localStorage.setItem(
|
||||
`openclaw.control.settings.v1:${gwUrl}`,
|
||||
JSON.stringify({
|
||||
gatewayUrl: gwUrl,
|
||||
theme: "custom",
|
||||
themeMode: "dark",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
customTheme: {
|
||||
sourceUrl: "https://tweakcn.com/themes/broken",
|
||||
themeId: "broken",
|
||||
label: "Broken",
|
||||
importedAt: "2026-04-22T00:00:00.000Z",
|
||||
light: {},
|
||||
dark: {},
|
||||
},
|
||||
sessionsByGateway: {
|
||||
[gwUrl]: {
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadSettings()).toMatchObject({
|
||||
theme: "claw",
|
||||
themeMode: "dark",
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes persisted session selection per gateway", async () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
|
||||
@@ -23,6 +23,7 @@ type PersistedUiSettings = Omit<UiSettings, "token" | "sessionKey" | "lastActive
|
||||
|
||||
import { isSupportedLocale } from "../i18n/index.ts";
|
||||
import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts";
|
||||
import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts";
|
||||
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
|
||||
import { normalizeOptionalString } from "./string-coerce.ts";
|
||||
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
|
||||
@@ -63,6 +64,7 @@ export type UiSettings = {
|
||||
navWidth: number; // Sidebar width when expanded (240–400px)
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
borderRadius: number; // Corner roundness (0–100, default 50)
|
||||
customTheme?: ImportedCustomTheme;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
@@ -219,6 +221,7 @@ export function loadSettings(): UiSettings {
|
||||
const parsedGatewayUrl = normalizeOptionalString(parsed.gatewayUrl) ?? defaults.gatewayUrl;
|
||||
const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl;
|
||||
const scopedSessionSelection = resolveScopedSessionSelection(gatewayUrl, parsed, defaults);
|
||||
const customTheme = parseImportedCustomTheme((parsed as { customTheme?: unknown }).customTheme);
|
||||
const { theme, mode } = parseThemeSelection(
|
||||
(parsed as { theme?: unknown }).theme,
|
||||
(parsed as { themeMode?: unknown }).themeMode,
|
||||
@@ -229,7 +232,7 @@ export function loadSettings(): UiSettings {
|
||||
token: loadSessionToken(gatewayUrl),
|
||||
sessionKey: scopedSessionSelection.sessionKey,
|
||||
lastActiveSessionKey: scopedSessionSelection.lastActiveSessionKey,
|
||||
theme,
|
||||
theme: theme === "custom" && !customTheme ? "claw" : theme,
|
||||
themeMode: mode,
|
||||
chatFocusMode:
|
||||
typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
|
||||
@@ -263,6 +266,7 @@ export function loadSettings(): UiSettings {
|
||||
parsed.borderRadius <= 100
|
||||
? snapBorderRadius(parsed.borderRadius)
|
||||
: defaults.borderRadius,
|
||||
customTheme: customTheme ?? undefined,
|
||||
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
|
||||
};
|
||||
if ("token" in parsed) {
|
||||
@@ -351,6 +355,7 @@ function persistSettings(next: UiSettings) {
|
||||
navWidth: next.navWidth,
|
||||
navGroupsCollapsed: next.navGroupsCollapsed,
|
||||
borderRadius: next.borderRadius,
|
||||
...(next.customTheme ? { customTheme: next.customTheme } : {}),
|
||||
sessionsByGateway,
|
||||
...(next.locale ? { locale: next.locale } : {}),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ThemeName = "claw" | "knot" | "dash";
|
||||
export type ThemeName = "claw" | "knot" | "dash" | "custom";
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
export type ResolvedTheme =
|
||||
| "dark"
|
||||
@@ -6,9 +6,11 @@ export type ResolvedTheme =
|
||||
| "openknot"
|
||||
| "openknot-light"
|
||||
| "dash"
|
||||
| "dash-light";
|
||||
| "dash-light"
|
||||
| "custom"
|
||||
| "custom-light";
|
||||
|
||||
export const VALID_THEME_NAMES = new Set<ThemeName>(["claw", "knot", "dash"]);
|
||||
export const VALID_THEME_NAMES = new Set<ThemeName>(["claw", "knot", "dash", "custom"]);
|
||||
export const VALID_THEME_MODES = new Set<ThemeMode>(["system", "light", "dark"]);
|
||||
|
||||
type ThemeSelection = { theme: ThemeName; mode: ThemeMode };
|
||||
@@ -70,5 +72,8 @@ export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
|
||||
if (theme === "knot") {
|
||||
return resolvedMode === "light" ? "openknot-light" : "openknot";
|
||||
}
|
||||
return resolvedMode === "light" ? "dash-light" : "dash";
|
||||
if (theme === "dash") {
|
||||
return resolvedMode === "light" ? "dash-light" : "dash";
|
||||
}
|
||||
return resolvedMode === "light" ? "custom-light" : "custom";
|
||||
}
|
||||
|
||||
@@ -32,8 +32,11 @@ function createProps(overrides: Partial<QuickSettingsProps> = {}): QuickSettings
|
||||
onSecurityConfigure: vi.fn(),
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
hasCustomTheme: false,
|
||||
customThemeLabel: null,
|
||||
borderRadius: 50,
|
||||
setTheme: vi.fn(),
|
||||
onOpenCustomThemeImport: vi.fn(),
|
||||
setThemeMode: vi.fn(),
|
||||
setBorderRadius: vi.fn(),
|
||||
userName: "Val",
|
||||
@@ -90,4 +93,67 @@ describe("renderQuickSettings", () => {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("always shows the custom theme option in quick settings", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(renderQuickSettings(createProps()), container);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === "Custom",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("routes custom clicks into the tweakcn importer until a custom theme exists", () => {
|
||||
const setTheme = vi.fn();
|
||||
const onOpenCustomThemeImport = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
hasCustomTheme: false,
|
||||
setTheme,
|
||||
onOpenCustomThemeImport,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const customButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Custom",
|
||||
);
|
||||
customButton?.click();
|
||||
|
||||
expect(onOpenCustomThemeImport).toHaveBeenCalledTimes(1);
|
||||
expect(setTheme).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the imported custom theme from quick settings once it exists", () => {
|
||||
const setTheme = vi.fn();
|
||||
const onOpenCustomThemeImport = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
theme: "claw",
|
||||
hasCustomTheme: true,
|
||||
setTheme,
|
||||
onOpenCustomThemeImport,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const customButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Custom",
|
||||
);
|
||||
customButton?.click();
|
||||
|
||||
expect(setTheme).toHaveBeenCalledWith("custom", expect.any(Object));
|
||||
expect(onOpenCustomThemeImport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,8 +77,11 @@ export type QuickSettingsProps = {
|
||||
// Appearance
|
||||
theme: ThemeName;
|
||||
themeMode: ThemeMode;
|
||||
hasCustomTheme: boolean;
|
||||
customThemeLabel?: string | null;
|
||||
borderRadius: number;
|
||||
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
|
||||
onOpenCustomThemeImport?: () => void;
|
||||
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
setBorderRadius: (value: number) => void;
|
||||
userName?: string | null;
|
||||
@@ -103,7 +106,7 @@ export type QuickSettingsProps = {
|
||||
// ── Theme options ──
|
||||
|
||||
type ThemeOption = { id: ThemeName; label: string };
|
||||
const THEME_OPTIONS: ThemeOption[] = [
|
||||
const BUILTIN_THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: "claw", label: "Claw" },
|
||||
{ id: "knot", label: "Knot" },
|
||||
{ id: "dash", label: "Dash" },
|
||||
@@ -378,6 +381,7 @@ function renderSecurityCard(props: QuickSettingsProps) {
|
||||
}
|
||||
|
||||
function renderAppearanceCard(props: QuickSettingsProps) {
|
||||
const themeOptions: ThemeOption[] = [...BUILTIN_THEME_OPTIONS, { id: "custom", label: "Custom" }];
|
||||
return html`
|
||||
<div class="qs-card">
|
||||
${renderCardHeader(icons.spark, "Appearance")}
|
||||
@@ -385,13 +389,17 @@ function renderAppearanceCard(props: QuickSettingsProps) {
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Theme</span>
|
||||
<div class="qs-segmented">
|
||||
${THEME_OPTIONS.map(
|
||||
${themeOptions.map(
|
||||
(opt) => html`
|
||||
<button
|
||||
class="qs-segmented__btn ${opt.id === props.theme
|
||||
? "qs-segmented__btn--active"
|
||||
: ""}"
|
||||
@click=${(e: Event) => {
|
||||
if (opt.id === "custom" && !props.hasCustomTheme) {
|
||||
props.onOpenCustomThemeImport?.();
|
||||
return;
|
||||
}
|
||||
if (opt.id !== props.theme) {
|
||||
props.setTheme(opt.id, {
|
||||
element: (e.currentTarget as HTMLElement) ?? undefined,
|
||||
|
||||
@@ -43,6 +43,18 @@ describe("config view", () => {
|
||||
themeMode: "system" as ThemeMode,
|
||||
setTheme: vi.fn(),
|
||||
setThemeMode: vi.fn(),
|
||||
hasCustomTheme: false,
|
||||
customThemeLabel: null,
|
||||
customThemeSourceUrl: null,
|
||||
customThemeImportUrl: "",
|
||||
customThemeImportBusy: false,
|
||||
customThemeImportMessage: null,
|
||||
customThemeImportExpanded: false,
|
||||
customThemeImportFocusToken: 0,
|
||||
onCustomThemeImportUrlChange: vi.fn(),
|
||||
onImportCustomTheme: vi.fn(),
|
||||
onClearCustomTheme: vi.fn(),
|
||||
onOpenCustomThemeImport: vi.fn(),
|
||||
borderRadius: 50,
|
||||
setBorderRadius: vi.fn(),
|
||||
gatewayUrl: "",
|
||||
@@ -207,6 +219,12 @@ describe("config view", () => {
|
||||
);
|
||||
expect(formButton?.classList.contains("active")).toBe(true);
|
||||
expect(rawButton?.disabled).toBe(true);
|
||||
const rawNotice = container.querySelector(".config-actions__notice");
|
||||
const actionButtons = container.querySelector(".config-actions__buttons");
|
||||
expect(rawNotice).not.toBeNull();
|
||||
expect(actionButtons).not.toBeNull();
|
||||
expect(actionButtons?.textContent).toContain("Reload");
|
||||
expect(actionButtons?.textContent).toContain("Update");
|
||||
expect(normalizedText(container)).toContain(
|
||||
"Raw mode disabled (snapshot cannot safely round-trip raw text).",
|
||||
);
|
||||
@@ -496,4 +514,84 @@ describe("config view", () => {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onFormPatch).toHaveBeenCalledWith(["gateway", "mode"], "local");
|
||||
});
|
||||
|
||||
it("opens the tweakcn importer when custom is clicked without an imported theme", () => {
|
||||
const onOpenCustomThemeImport = vi.fn();
|
||||
const { container } = renderConfigView({
|
||||
activeSection: "__appearance__",
|
||||
includeSections: ["__appearance__"],
|
||||
onOpenCustomThemeImport,
|
||||
});
|
||||
|
||||
const customButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Custom",
|
||||
);
|
||||
|
||||
expect(customButton?.disabled).toBe(false);
|
||||
expect(normalizedText(container)).toContain("Click Custom to import a tweakcn theme");
|
||||
|
||||
customButton?.click();
|
||||
|
||||
expect(onOpenCustomThemeImport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the tweakcn importer once the custom slot is opened", () => {
|
||||
const { container } = renderConfigView({
|
||||
activeSection: "__appearance__",
|
||||
includeSections: ["__appearance__"],
|
||||
customThemeImportExpanded: true,
|
||||
customThemeImportFocusToken: 1,
|
||||
});
|
||||
|
||||
const importButton = Array.from(container.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Import custom theme"),
|
||||
);
|
||||
|
||||
expect(importButton?.disabled).toBe(true);
|
||||
expect(container.querySelector(".settings-theme-import__input")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows custom theme actions once a tweakcn import exists", () => {
|
||||
const setTheme = vi.fn();
|
||||
const onClearCustomTheme = vi.fn();
|
||||
const onImportCustomTheme = vi.fn();
|
||||
const onCustomThemeImportUrlChange = vi.fn();
|
||||
const { container } = renderConfigView({
|
||||
activeSection: "__appearance__",
|
||||
includeSections: ["__appearance__"],
|
||||
hasCustomTheme: true,
|
||||
customThemeLabel: "Light Green",
|
||||
customThemeSourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
customThemeImportUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
setTheme,
|
||||
onClearCustomTheme,
|
||||
onImportCustomTheme,
|
||||
onCustomThemeImportUrlChange,
|
||||
});
|
||||
|
||||
const customButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Custom",
|
||||
);
|
||||
expect(customButton?.disabled).toBe(false);
|
||||
customButton?.click();
|
||||
expect(setTheme).toHaveBeenCalledWith("custom", expect.any(Object));
|
||||
|
||||
const replaceButton = Array.from(container.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Replace custom theme"),
|
||||
);
|
||||
const clearButton = Array.from(container.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Clear custom theme"),
|
||||
);
|
||||
replaceButton?.click();
|
||||
clearButton?.click();
|
||||
|
||||
expect(onImportCustomTheme).toHaveBeenCalledTimes(1);
|
||||
expect(onClearCustomTheme).toHaveBeenCalledTimes(1);
|
||||
expect(normalizedText(container)).toContain("Loaded Light Green");
|
||||
|
||||
const input = container.querySelector(".settings-theme-import__input") as HTMLInputElement;
|
||||
input.value = "https://tweakcn.com/themes/custom";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
expect(onCustomThemeImportUrlChange).toHaveBeenCalledWith("https://tweakcn.com/themes/custom");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,18 @@ export type ConfigProps = {
|
||||
themeMode: ThemeMode;
|
||||
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
|
||||
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
hasCustomTheme: boolean;
|
||||
customThemeLabel: string | null;
|
||||
customThemeSourceUrl: string | null;
|
||||
customThemeImportUrl: string;
|
||||
customThemeImportBusy: boolean;
|
||||
customThemeImportMessage: { kind: "success" | "error"; text: string } | null;
|
||||
customThemeImportExpanded?: boolean;
|
||||
customThemeImportFocusToken?: number;
|
||||
onCustomThemeImportUrlChange: (next: string) => void;
|
||||
onImportCustomTheme: () => void;
|
||||
onClearCustomTheme: () => void;
|
||||
onOpenCustomThemeImport?: () => void;
|
||||
borderRadius: number;
|
||||
setBorderRadius: (value: number) => void;
|
||||
gatewayUrl: string;
|
||||
@@ -568,21 +580,66 @@ function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints):
|
||||
return truncateValue(value);
|
||||
}
|
||||
|
||||
type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult };
|
||||
const THEME_OPTIONS: ThemeOption[] = [
|
||||
type ThemeOption = {
|
||||
id: ThemeName;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: TemplateResult;
|
||||
};
|
||||
const BUILTIN_THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
|
||||
{ id: "knot", label: "Knot", description: "Black & red", icon: icons.link },
|
||||
{ id: "dash", label: "Dash", description: "Chocolate blueprint", icon: icons.barChart },
|
||||
];
|
||||
|
||||
function focusCustomThemeImportInput() {
|
||||
const schedule =
|
||||
typeof requestAnimationFrame === "function"
|
||||
? requestAnimationFrame
|
||||
: (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0);
|
||||
schedule(() => {
|
||||
const input = globalThis.document?.querySelector<HTMLInputElement>(
|
||||
"[data-custom-theme-import-input]",
|
||||
);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
if (typeof input.scrollIntoView === "function") {
|
||||
input.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
|
||||
function renderAppearanceSection(props: ConfigProps) {
|
||||
const showCustomThemeImport = props.hasCustomTheme || props.customThemeImportExpanded === true;
|
||||
if (
|
||||
showCustomThemeImport &&
|
||||
props.customThemeImportFocusToken != null &&
|
||||
props.customThemeImportFocusToken !== cvs.lastCustomThemeImportFocusToken
|
||||
) {
|
||||
cvs.lastCustomThemeImportFocusToken = props.customThemeImportFocusToken;
|
||||
focusCustomThemeImportInput();
|
||||
}
|
||||
const themeOptions: ThemeOption[] = [
|
||||
...BUILTIN_THEME_OPTIONS,
|
||||
{
|
||||
id: "custom",
|
||||
label: "Custom",
|
||||
description: props.hasCustomTheme
|
||||
? `Imported from tweakcn${props.customThemeLabel ? `: ${props.customThemeLabel}` : ""}`
|
||||
: "Open the tweakcn importer for this browser-local slot",
|
||||
icon: icons.spark,
|
||||
},
|
||||
];
|
||||
return html`
|
||||
<div class="settings-appearance">
|
||||
<div class="settings-appearance__section">
|
||||
<h3 class="settings-appearance__heading">Theme</h3>
|
||||
<p class="settings-appearance__hint">Choose a theme family.</p>
|
||||
<div class="settings-theme-grid">
|
||||
${THEME_OPTIONS.map(
|
||||
${themeOptions.map(
|
||||
(opt) => html`
|
||||
<button
|
||||
class="settings-theme-card ${opt.id === props.theme
|
||||
@@ -590,6 +647,10 @@ function renderAppearanceSection(props: ConfigProps) {
|
||||
: ""}"
|
||||
title=${opt.description}
|
||||
@click=${(e: Event) => {
|
||||
if (opt.id === "custom" && !props.hasCustomTheme) {
|
||||
props.onOpenCustomThemeImport?.();
|
||||
return;
|
||||
}
|
||||
if (opt.id !== props.theme) {
|
||||
const context: ThemeTransitionContext = {
|
||||
element: (e.currentTarget as HTMLElement) ?? undefined,
|
||||
@@ -609,6 +670,80 @@ function renderAppearanceSection(props: ConfigProps) {
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${showCustomThemeImport
|
||||
? html`
|
||||
<div class="settings-theme-import">
|
||||
<div class="settings-theme-import__copy">
|
||||
<div class="settings-theme-import__title">Import from tweakcn</div>
|
||||
<p class="settings-theme-import__hint">
|
||||
Paste a tweakcn share link. The import stays in this browser only and replaces
|
||||
the current custom slot.
|
||||
</p>
|
||||
</div>
|
||||
<label class="settings-theme-import__field">
|
||||
<span class="settings-theme-import__label">tweakcn link</span>
|
||||
<input
|
||||
class="settings-theme-import__input"
|
||||
data-custom-theme-import-input
|
||||
type="url"
|
||||
placeholder="https://tweakcn.com/themes/..."
|
||||
.value=${props.customThemeImportUrl}
|
||||
@input=${(e: Event) =>
|
||||
props.onCustomThemeImportUrlChange(
|
||||
(e.currentTarget as HTMLInputElement).value,
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<div class="settings-theme-import__actions">
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${props.customThemeImportBusy ||
|
||||
props.customThemeImportUrl.trim().length === 0}
|
||||
@click=${props.onImportCustomTheme}
|
||||
>
|
||||
${props.customThemeImportBusy
|
||||
? "Importing…"
|
||||
: props.hasCustomTheme
|
||||
? "Replace custom theme"
|
||||
: "Import custom theme"}
|
||||
</button>
|
||||
${props.hasCustomTheme
|
||||
? html`
|
||||
<button class="btn btn--sm danger" @click=${props.onClearCustomTheme}>
|
||||
Clear custom theme
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
${props.hasCustomTheme
|
||||
? html`
|
||||
<div class="settings-theme-import__meta">
|
||||
<span class="settings-theme-import__meta-label">Loaded</span>
|
||||
<span class="settings-theme-import__meta-value"
|
||||
>${props.customThemeLabel ?? "Custom"} ·
|
||||
${props.customThemeSourceUrl ?? "tweakcn"}</span
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${props.customThemeImportMessage
|
||||
? html`
|
||||
<div
|
||||
class="settings-theme-import__message settings-theme-import__message--${props
|
||||
.customThemeImportMessage.kind}"
|
||||
>
|
||||
${props.customThemeImportMessage.text}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<p class="settings-theme-import__inline-hint">
|
||||
Click <strong>Custom</strong> to import a tweakcn theme into this browser-local
|
||||
slot.
|
||||
</p>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="settings-appearance__section">
|
||||
@@ -670,6 +805,7 @@ interface ConfigEphemeralState {
|
||||
envRevealed: boolean;
|
||||
validityDismissed: boolean;
|
||||
revealedSensitivePaths: Set<string>;
|
||||
lastCustomThemeImportFocusToken: number | null;
|
||||
}
|
||||
|
||||
function createConfigEphemeralState(): ConfigEphemeralState {
|
||||
@@ -678,6 +814,7 @@ function createConfigEphemeralState(): ConfigEphemeralState {
|
||||
envRevealed: false,
|
||||
validityDismissed: false,
|
||||
revealedSensitivePaths: new Set(),
|
||||
lastCustomThemeImportFocusToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -941,37 +1078,39 @@ export function renderConfig(props: ConfigProps) {
|
||||
<div class="config-actions__right">
|
||||
${!rawAvailable
|
||||
? html`
|
||||
<span class="config-status muted"
|
||||
<span class="config-status muted config-actions__notice"
|
||||
>Raw mode disabled (snapshot cannot safely round-trip raw text).</span
|
||||
>
|
||||
`
|
||||
: nothing}
|
||||
${props.onOpenFile
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
|
||||
@click=${props.onOpenFile}
|
||||
>
|
||||
${icons.fileText} Open
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
|
||||
${props.loading ? t("common.loading") : t("common.reload")}
|
||||
</button>
|
||||
<button class="btn btn--sm" ?disabled=${!hasChanges} @click=${props.onReset}>
|
||||
Clear
|
||||
</button>
|
||||
<button class="btn btn--sm primary" ?disabled=${!canSave} @click=${props.onSave}>
|
||||
${props.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn btn--sm" ?disabled=${!canApply} @click=${props.onApply}>
|
||||
${props.applying ? "Applying…" : "Apply"}
|
||||
</button>
|
||||
<button class="btn btn--sm" ?disabled=${!canUpdate} @click=${props.onUpdate}>
|
||||
${props.updating ? "Updating…" : "Update"}
|
||||
</button>
|
||||
<div class="config-actions__buttons">
|
||||
${props.onOpenFile
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
|
||||
@click=${props.onOpenFile}
|
||||
>
|
||||
${icons.fileText} Open
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
|
||||
${props.loading ? t("common.loading") : t("common.reload")}
|
||||
</button>
|
||||
<button class="btn btn--sm" ?disabled=${!hasChanges} @click=${props.onReset}>
|
||||
Clear
|
||||
</button>
|
||||
<button class="btn btn--sm primary" ?disabled=${!canSave} @click=${props.onSave}>
|
||||
${props.saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn btn--sm" ?disabled=${!canApply} @click=${props.onApply}>
|
||||
${props.applying ? "Applying…" : "Apply"}
|
||||
</button>
|
||||
<button class="btn btn--sm" ?disabled=${!canUpdate} @click=${props.onUpdate}>
|
||||
${props.updating ? "Updating…" : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user