diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index eb20d1cf039c..6c57703336b5 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -149,7 +149,7 @@ Docs translations are generated for the same non-English locale set, but the doc ## Appearance themes -The Appearance panel has the built-in Claw, Knot, and Dash themes (Claw is default), plus one browser-local tweakcn import slot. To import a theme, open the [tweakcn editor](https://tweakcn.com/editor/theme), choose or create a theme, click **Share**, and paste the copied link into Appearance. The importer also accepts `https://tweakcn.com/r/themes/` registry URLs, editor URLs like `https://tweakcn.com/editor/theme?theme=amethyst-haze`, relative `/themes/` paths, raw theme IDs, and default theme names such as `amethyst-haze`. +The Appearance panel has the built-in Claw, Knot, Dash, and Absolutely themes (Claw is default), plus one browser-local tweakcn import slot. To import a theme, open the [tweakcn editor](https://tweakcn.com/editor/theme), choose or create a theme, click **Share**, and paste the copied link into Appearance. The importer also accepts `https://tweakcn.com/r/themes/` registry URLs, editor URLs like `https://tweakcn.com/editor/theme?theme=amethyst-haze`, relative `/themes/` paths, raw theme IDs, and default theme names such as `amethyst-haze`. Imported themes are stored only in the current browser profile; they are not written to gateway config and do not sync across devices. Replacing the imported theme updates the one local slot; clearing it switches back to Claw if the imported theme was active. diff --git a/extensions/slack/src/client.web-api.test.ts b/extensions/slack/src/client.web-api.test.ts index ea66ae52cab2..ef09ab7e9fba 100644 --- a/extensions/slack/src/client.web-api.test.ts +++ b/extensions/slack/src/client.web-api.test.ts @@ -379,7 +379,12 @@ describe("Slack Web API routing", () => { await expect(readClient.auth.test()).rejects.toThrow(); await expect(sharedClient.auth.test()).resolves.toMatchObject({ ok: true }); - expect(requests).toHaveLength(2); + // The read client aborts at 20ms, so whether its request reaches the + // server before the abort is a race on a loaded runner. The bound itself + // is asserted above; here only the shared client's arrival is certain, + // and its token proves the dedicated client did not carry the call. + expect(requests.length).toBeGreaterThanOrEqual(1); + expect(requests.at(-1)?.authorization).toBe("Bearer xoxb-shared"); } finally { await server.close(); } diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 8a15340c98af..a8f732b4fef8 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -156,7 +156,7 @@ export type OpenClawConfig = { */ prefs?: { /** Control UI theme. */ - theme?: "claw" | "knot" | "dash" | "custom"; + theme?: "claw" | "knot" | "dash" | "absolutely" | "custom"; /** Light/dark preference. */ themeMode?: "light" | "dark" | "system"; /** User-selected Control UI accent color (#RRGGBB). */ diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index 7344990b0c2f..61338b419f76 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -220,7 +220,13 @@ export const OpenClawSchemaShape = { prefs: z .strictObject({ theme: z - .union([z.literal("claw"), z.literal("knot"), z.literal("dash"), z.literal("custom")]) + .union([ + z.literal("claw"), + z.literal("knot"), + z.literal("dash"), + z.literal("absolutely"), + z.literal("custom"), + ]) .optional(), themeMode: z .union([z.literal("light"), z.literal("dark"), z.literal("system")]) diff --git a/ui/index.html b/ui/index.html index 4efa26669d30..f1872cf5142f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -21,7 +21,7 @@ globalThis.__zod_globalConfig.jitless = true; (function () { - var THEMES = { claw: 1, knot: 1, dash: 1 }; + var THEMES = { claw: 1, knot: 1, dash: 1, absolutely: 1 }; var MODES = { system: 1, light: 1, dark: 1 }; var LEGACY = { dark: "claw:dark", @@ -61,9 +61,13 @@ ? mode === "light" ? "dash-light" : "dash" - : mode === "light" - ? "light" - : "dark"; + : theme === "absolutely" + ? mode === "light" + ? "absolutely-light" + : "absolutely" + : mode === "light" + ? "light" + : "dark"; document.documentElement.setAttribute("data-theme", resolved); var resolvedMode = resolved.includes("light") ? "light" : "dark"; document.documentElement.setAttribute("data-theme-mode", resolvedMode); @@ -106,6 +110,14 @@ background: #f7f2ec; } + html[data-theme="absolutely"] { + background: #262624; + } + + html[data-theme="absolutely-light"] { + background: #faf9f5; + } + body { margin: 0; min-height: 100%; diff --git a/ui/public/fonts/absolutely.css b/ui/public/fonts/absolutely.css new file mode 100644 index 000000000000..b8badc1e7103 --- /dev/null +++ b/ui/public/fonts/absolutely.css @@ -0,0 +1,92 @@ +/* + * Self-hosted webfonts for the Absolutely theme. + * + * Served as a static asset and linked at runtime by the theme applier, so + * themes that do not use these faces download neither this file nor the + * fonts. Paths are stable (public/, unhashed) because the link is built + * from a constant rather than a bundler import. + * + * The url() references are relative to this stylesheet on purpose: the link + * href is resolved against the configured Control UI mount, and relative + * sources then follow it, so a gateway served under a base path such as + * /openclaw still reaches the faces. + * + * Both families are SIL Open Font License 1.1 and ship unmodified, with their + * upstream license text beside the files in public/fonts. Serving them from the + * gateway (not a font CDN) keeps `font-src 'self'` intact, avoids a third-party + * request on every load, and works on an offline or LAN-only gateway. + * + * Latin and latin-ext subsets only, matching the Control UI's shipped locales; + * unicode-range values come from the upstream subset definitions, so a glyph + * outside them falls through to the stack's next family instead of loading a + * face nothing renders. + */ + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 300 700; + font-display: swap; + src: url("space-grotesk-latin-ext.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, + U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, + U+A720-A7FF; +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 300 700; + font-display: swap; + src: url("space-grotesk-latin.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Lora"; + font-style: italic; + font-weight: 400 700; + font-display: swap; + src: url("lora-italic-latin-ext.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, + U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, + U+A720-A7FF; +} + +@font-face { + font-family: "Lora"; + font-style: italic; + font-weight: 400 700; + font-display: swap; + src: url("lora-italic-latin.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Lora"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("lora-latin-ext.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, + U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, + U+A720-A7FF; +} + +@font-face { + font-family: "Lora"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("lora-latin.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/ui/public/fonts/lora-OFL.txt b/ui/public/fonts/lora-OFL.txt new file mode 100644 index 000000000000..d0ce7fe062e6 --- /dev/null +++ b/ui/public/fonts/lora-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/public/fonts/lora-italic-latin-ext.woff2 b/ui/public/fonts/lora-italic-latin-ext.woff2 new file mode 100644 index 000000000000..580e3d9c5d28 Binary files /dev/null and b/ui/public/fonts/lora-italic-latin-ext.woff2 differ diff --git a/ui/public/fonts/lora-italic-latin.woff2 b/ui/public/fonts/lora-italic-latin.woff2 new file mode 100644 index 000000000000..89f70d296149 Binary files /dev/null and b/ui/public/fonts/lora-italic-latin.woff2 differ diff --git a/ui/public/fonts/lora-latin-ext.woff2 b/ui/public/fonts/lora-latin-ext.woff2 new file mode 100644 index 000000000000..0ee51edbabbe Binary files /dev/null and b/ui/public/fonts/lora-latin-ext.woff2 differ diff --git a/ui/public/fonts/lora-latin.woff2 b/ui/public/fonts/lora-latin.woff2 new file mode 100644 index 000000000000..536efc5c3c45 Binary files /dev/null and b/ui/public/fonts/lora-latin.woff2 differ diff --git a/ui/public/fonts/space-grotesk-OFL.txt b/ui/public/fonts/space-grotesk-OFL.txt new file mode 100644 index 000000000000..f831dbc69f05 --- /dev/null +++ b/ui/public/fonts/space-grotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/public/fonts/space-grotesk-latin-ext.woff2 b/ui/public/fonts/space-grotesk-latin-ext.woff2 new file mode 100644 index 000000000000..e3aa61ccdd2a Binary files /dev/null and b/ui/public/fonts/space-grotesk-latin-ext.woff2 differ diff --git a/ui/public/fonts/space-grotesk-latin.woff2 b/ui/public/fonts/space-grotesk-latin.woff2 new file mode 100644 index 000000000000..7b0e76a5c9bd Binary files /dev/null and b/ui/public/fonts/space-grotesk-latin.woff2 differ diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 96e7415e7b51..f2c3b8bcc975 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -70,7 +70,7 @@ import { resolveApplicationStartupSettings, } from "./startup-settings.ts"; import { startThemeTransition } from "./theme-transition.ts"; -import { resolveTheme, type ThemeMode } from "./theme.ts"; +import { resolveTheme, syncThemeFontStylesheet, type ThemeMode } from "./theme.ts"; import { createWebPushCapability } from "./web-push.ts"; function applyThemePresentation(settings: ReturnType): void { @@ -88,6 +88,7 @@ function applyThemePresentation(settings: ReturnType): void root.classList.toggle("wa-dark", root.dataset.themeMode === "dark"); root.style.colorScheme = root.dataset.themeMode; root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`); + syncThemeFontStylesheet(settings.theme); syncCustomThemeStyleTag(settings.customTheme); applyControlUiAccent(settings.accent); const background = getComputedStyle(root).getPropertyValue("--bg").trim(); diff --git a/ui/src/app/mount-fallback.test.ts b/ui/src/app/mount-fallback.test.ts index 5e16fbb51112..d23ffb9a3f2d 100644 --- a/ui/src/app/mount-fallback.test.ts +++ b/ui/src/app/mount-fallback.test.ts @@ -44,7 +44,7 @@ function installStartupPaintShell(window: TestWindow, html: string): void { const startupScript = Array.from( parsed.querySelectorAll("script:not([src])"), - ).find((script) => script.textContent?.includes("var THEMES = { claw: 1, knot: 1, dash: 1 }")); + ).find((script) => script.textContent?.includes("var THEMES = {")); if (!startupScript?.textContent) { throw new Error("Expected inline startup theme script in index.html"); } @@ -96,6 +96,18 @@ describe("Control UI mount fallback", () => { ["claw dark", { theme: "claw", themeMode: "dark" }, "dark", "rgb(14, 16, 21)"], ["OpenKnot dark", { theme: "knot", themeMode: "dark" }, "openknot", "rgb(8, 8, 8)"], ["Dash light", { theme: "dash", themeMode: "light" }, "dash-light", "rgb(247, 242, 236)"], + [ + "Absolutely dark", + { theme: "absolutely", themeMode: "dark" }, + "absolutely", + "rgb(38, 38, 36)", + ], + [ + "Absolutely light", + { theme: "absolutely", themeMode: "light" }, + "absolutely-light", + "rgb(250, 249, 245)", + ], ])( "paints %s before the app stylesheet loads", async (_name, settings, expectedTheme, expectedBackground) => { diff --git a/ui/src/app/public-assets.ts b/ui/src/app/public-assets.ts index bfc35377d0c9..c6230e1c5369 100644 --- a/ui/src/app/public-assets.ts +++ b/ui/src/app/public-assets.ts @@ -5,6 +5,7 @@ import { resolveControlUiPaths } from "./browser.ts"; type ControlUiPublicAsset = | ControlUiRootPublicAsset + | `fonts/${string}.css` | `provider-icons/ProviderIcon-${string}.svg` | `plugin-art/${string}.webp` | `app-art/${string}.webp`; diff --git a/ui/src/app/server-prefs-state.ts b/ui/src/app/server-prefs-state.ts index 8369918f47a7..b1dd813b8e8c 100644 --- a/ui/src/app/server-prefs-state.ts +++ b/ui/src/app/server-prefs-state.ts @@ -12,7 +12,7 @@ import { } from "./settings.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; -const THEMES: ReadonlySet = new Set(["claw", "knot", "dash", "custom"]); +const THEMES: ReadonlySet = new Set(["claw", "knot", "dash", "absolutely", "custom"]); const THEME_MODES: ReadonlySet = new Set(["light", "dark", "system"]); type SyncedPrefSpec = { diff --git a/ui/src/app/theme.ts b/ui/src/app/theme.ts index 9c6102c3c950..e8089b8a8f50 100644 --- a/ui/src/app/theme.ts +++ b/ui/src/app/theme.ts @@ -1,5 +1,6 @@ // Control UI module implements theme behavior. -export type ThemeName = "claw" | "knot" | "dash" | "custom"; +import { inferControlUiPublicAssetPath } from "./public-assets.ts"; +export type ThemeName = "claw" | "knot" | "dash" | "absolutely" | "custom"; export type ThemeMode = "system" | "light" | "dark"; export type ResolvedTheme = | "dark" @@ -8,10 +9,25 @@ export type ResolvedTheme = | "openknot-light" | "dash" | "dash-light" + | "absolutely" + | "absolutely-light" | "custom" | "custom-light"; -const VALID_THEME_NAMES = new Set(["claw", "knot", "dash", "custom"]); +const VALID_THEME_NAMES = new Set(["claw", "knot", "dash", "absolutely", "custom"]); + +const THEME_FONT_STYLESHEET_ID = "openclaw-theme-fonts"; +/* Themes that ship their own faces. The stylesheet is fetched only while such a + theme is active, so every other theme pays nothing for fonts it never paints. + Loading with the app bundle (not the first-paint boot script) costs one + font-display: swap on a cold load and keeps the theme->asset mapping in one + place. Values are bundle-relative asset names: the href is resolved against + the configured Control UI mount, and the stylesheet's own url() references + are relative to it, so both levels follow a non-root base path. */ +const THEME_FONT_STYLESHEETS: Partial> = { + absolutely: "fonts/absolutely.css", +}; +type ControlUiFontStylesheet = `fonts/${string}.css`; const VALID_THEME_MODES = new Set(["system", "light", "dark"]); function prefersLightScheme(): boolean { @@ -52,5 +68,33 @@ export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme { if (theme === "dash") { return resolvedMode === "light" ? "dash-light" : "dash"; } + if (theme === "absolutely") { + return resolvedMode === "light" ? "absolutely-light" : "absolutely"; + } return resolvedMode === "light" ? "custom-light" : "custom"; } + +/** Loads (or drops) the webfont stylesheet a theme declares. Idempotent. */ +export function syncThemeFontStylesheet(theme: ThemeName): void { + if (typeof document === "undefined") { + return; + } + const asset = THEME_FONT_STYLESHEETS[theme]; + const existing = document.getElementById(THEME_FONT_STYLESHEET_ID); + if (!asset) { + existing?.remove(); + return; + } + const href = inferControlUiPublicAssetPath(asset); + if (existing instanceof HTMLLinkElement) { + if (existing.getAttribute("href") !== href) { + existing.href = href; + } + return; + } + const link = document.createElement("link"); + link.id = THEME_FONT_STYLESHEET_ID; + link.rel = "stylesheet"; + link.href = href; + document.head.append(link); +} diff --git a/ui/src/e2e/theme-muted-contrast.e2e.test.ts b/ui/src/e2e/theme-muted-contrast.e2e.test.ts index d796ac7f968d..dad558456457 100644 --- a/ui/src/e2e/theme-muted-contrast.e2e.test.ts +++ b/ui/src/e2e/theme-muted-contrast.e2e.test.ts @@ -17,6 +17,8 @@ const themeCases = [ { family: "knot", mode: "light", resolved: "openknot-light" }, { family: "dash", mode: "dark", resolved: "dash" }, { family: "dash", mode: "light", resolved: "dash-light" }, + { family: "absolutely", mode: "dark", resolved: "absolutely" }, + { family: "absolutely", mode: "light", resolved: "absolutely-light" }, ] as const; const textTokens = [ @@ -30,7 +32,10 @@ const textTokens = [ const surfaceTokens = ["--bg", "--bg-elevated", "--bg-muted", "--card", "--panel"] as const; -function themeConfigResponse(family: "claw" | "knot" | "dash", mode: "dark" | "light") { +function themeConfigResponse( + family: "claw" | "knot" | "dash" | "absolutely", + mode: "dark" | "light", +) { const config = { ui: { prefs: { ...(family === "claw" ? {} : { theme: family }), themeMode: mode } }, }; diff --git a/ui/src/e2e/theme-typography.e2e.test.ts b/ui/src/e2e/theme-typography.e2e.test.ts new file mode 100644 index 000000000000..b0e3b560a219 --- /dev/null +++ b/ui/src/e2e/theme-typography.e2e.test.ts @@ -0,0 +1,191 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { controlUiBundledGatewayUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { requireRecord, requireString } from "./chat-flow.test-support.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +/* + * A theme that declares webfonts must actually paint in them, and a theme that + * does not must never pay for them. Both halves are invisible to unit tests: + * the stylesheet is linked at runtime and the faces only resolve once the + * browser has fetched them, so a broken asset path or a dropped link degrades + * silently to the fallback stack and looks merely "a bit off". + */ + +const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const proofDirectory = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/theme-typography"); + +const suite = createControlUiE2eSuite({ + name: "Control UI theme typography", + trackBrowserContexts: true, + unavailableMessage: (executablePath) => + `Playwright Chromium is required for theme typography proof at ${executablePath}`, +}); + +function themeConfigResponse(theme: string, mode: "dark" | "light") { + const config = { ui: { prefs: { theme, themeMode: mode } } }; + const hash = `theme-typography-${theme}-${mode}`; + return { + appliedConfigHash: hash, + config, + configRevisionHash: hash, + hash, + issues: [], + raw: JSON.stringify(config), + valid: true, + }; +} + +async function openThemedChat(theme: string, mode: "dark" | "light", basePath = "") { + const context = await suite.newBrowserContext({ + colorScheme: mode, + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1440 }, + }); + await context.addInitScript( + ({ gatewayUrl, initialMode, initialTheme }) => { + localStorage.setItem( + `openclaw.control.settings.v1:${gatewayUrl}`, + JSON.stringify({ gatewayUrl, theme: initialTheme, themeMode: initialMode }), + ); + }, + { + gatewayUrl: controlUiBundledGatewayUrl(suite.server.baseUrl), + initialMode: mode, + initialTheme: theme, + }, + ); + const page = await context.newPage(); + const fontRequests: string[] = []; + page.on("response", (response) => { + const url = response.url(); + if (url.includes("/fonts/")) { + fontRequests.push(`${url.split("/").pop()} ${response.status()}`); + } + }); + const gateway = await installMockGateway(page, { + ...(basePath ? { basePath } : {}), + methodResponses: { "config.get": themeConfigResponse(theme, mode) }, + }); + return { fontRequests, gateway, page }; +} + +async function renderAssistantProse( + gateway: Awaited>, + page: Awaited>["page"], +) { + await page.locator(".agent-chat__composer-combobox textarea").fill("say something"); + await page.getByRole("button", { name: "Send message" }).click(); + const sendRequest = await gateway.waitForRequest("chat.send"); + const runId = requireString( + requireRecord(sendRequest.params).idempotencyKey, + "chat send idempotency key", + ); + const text = + "Typography carries the theme: chat prose renders in the reading face while chrome, chips, and code keep their own."; + await gateway.emitGatewayEvent("chat", { + message: { content: [{ text, type: "text" }], role: "assistant", timestamp: Date.now() }, + runId, + sessionKey: "main", + state: "final", + }); + // first() is the prompt this test just sent; the assistant reply is last. + await expect.poll(() => page.locator(".chat-text").last().textContent()).toContain("Typography"); +} + +suite.define(() => { + it("paints Absolutely chrome and chat prose in its own faces", async () => { + if (captureUiProof) { + await mkdir(proofDirectory, { recursive: true }); + } + const { fontRequests, gateway, page } = await openThemedChat("absolutely", "dark"); + await page.goto(`${suite.server.baseUrl}chat`); + await renderAssistantProse(gateway, page); + + const report = await page.evaluate(async () => { + await document.fonts.ready; + const chats = document.querySelectorAll(".chat-text"); + const chat = chats[chats.length - 1]; + // Computed families come back quoted ('"Space Grotesk", -apple-system…'); + // the first entry is the one that actually paints. + const primary = (value: string) => + (value.split(",")[0] ?? "").trim().replace(/^["']|["']$/gu, ""); + return { + chatFontFamily: chat ? primary(getComputedStyle(chat).fontFamily) : null, + bodyFontFamily: primary(getComputedStyle(document.body).fontFamily), + linkHref: document.getElementById("openclaw-theme-fonts")?.getAttribute("href") ?? null, + loaded: [...document.fonts].filter((f) => f.status === "loaded").map((f) => f.family), + }; + }); + + expect(report.linkHref).toBe("/fonts/absolutely.css"); + // The declared face must win, not merely appear somewhere in the stack. + expect(report.bodyFontFamily).toBe("Space Grotesk"); + expect(report.chatFontFamily).toBe("Lora"); + expect(new Set(report.loaded)).toEqual(new Set(["Space Grotesk", "Lora"])); + expect(fontRequests.every((entry) => entry.endsWith(" 200"))).toBe(true); + + if (captureUiProof) { + await page.screenshot({ path: path.join(proofDirectory, "absolutely-chat-dark.png") }); + } + }); + + it("resolves the font stylesheet against a configured mount path", async () => { + // A gateway mounted at a base path serves the bundle below that prefix, so + // root-absolute font URLs 404 there and the theme silently falls back to + // system faces while its palette still applies. + const basePath = "/openclaw"; + const { page } = await openThemedChat("absolutely", "dark", basePath); + const requested: string[] = []; + await page.route(`**${basePath}/fonts/**`, async (route) => { + const { pathname } = new URL(route.request().url()); + requested.push(pathname); + await route.fulfill({ status: 404, body: "", contentType: "text/css" }); + }); + + await page.goto(`${suite.server.baseUrl}${basePath.slice(1)}/chat`); + await page + .locator(".agent-chat__composer-combobox textarea") + .waitFor({ state: "visible", timeout: 30_000 }); + + const linkHref = await page.evaluate( + () => document.getElementById("openclaw-theme-fonts")?.getAttribute("href") ?? null, + ); + + expect(linkHref).toBe(`${basePath}/fonts/absolutely.css`); + // The browser must actually fetch below the mount, not at the root. + await expect.poll(() => requested).toContain(`${basePath}/fonts/absolutely.css`); + }); + + it("leaves themes without declared faces on the system stack", async () => { + if (captureUiProof) { + await mkdir(proofDirectory, { recursive: true }); + } + const { fontRequests, gateway, page } = await openThemedChat("claw", "dark"); + await page.goto(`${suite.server.baseUrl}chat`); + await renderAssistantProse(gateway, page); + + const report = await page.evaluate(async () => { + await document.fonts.ready; + const primary = (value: string) => + (value.split(",")[0] ?? "").trim().replace(/^["']|["']$/gu, ""); + return { + bodyFontFamily: primary(getComputedStyle(document.body).fontFamily), + linkHref: document.getElementById("openclaw-theme-fonts")?.getAttribute("href") ?? null, + loaded: [...document.fonts].filter((f) => f.status === "loaded").map((f) => f.family), + }; + }); + + expect(report.linkHref).toBeNull(); + expect(report.loaded).toEqual([]); + expect(report.bodyFontFamily).not.toBe("Space Grotesk"); + // The default path must not fetch a font asset at all. + expect(fontRequests).toEqual([]); + + if (captureUiProof) { + await page.screenshot({ path: path.join(proofDirectory, "claw-chat-dark.png") }); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1ab302c3bf74..21e8a342ad79 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1821,6 +1821,10 @@ export const en: TranslationMap = { label: "Dash", description: "Chocolate blueprint", }, + absolutely: { + label: "Absolutely", + description: "Clay on graphite", + }, }, textSizes: { small: "Small", diff --git a/ui/src/pages/config/view-appearance.ts b/ui/src/pages/config/view-appearance.ts index 1bc4bcdd9975..a789051aa44a 100644 --- a/ui/src/pages/config/view-appearance.ts +++ b/ui/src/pages/config/view-appearance.ts @@ -59,6 +59,11 @@ const BUILTIN_THEME_OPTIONS: ThemeOption[] = [ labelKey: "configView.themes.dash.label", descriptionKey: "configView.themes.dash.description", }, + { + id: "absolutely", + labelKey: "configView.themes.absolutely.label", + descriptionKey: "configView.themes.absolutely.description", + }, ]; const ACCENT_PRESETS = [ @@ -211,11 +216,6 @@ export function renderAppearanceSection( > ${renderThemeCardVisual(opt.id, props.theme)} ${opt.label} - ${opt.id === props.theme - ? html`` - : nothing} `, )} diff --git a/ui/src/styles/base-theme-contrast.node.test.ts b/ui/src/styles/base-theme-contrast.node.test.ts index 5f44072e389b..bf185fd24cf2 100644 --- a/ui/src/styles/base-theme-contrast.node.test.ts +++ b/ui/src/styles/base-theme-contrast.node.test.ts @@ -124,6 +124,8 @@ function resolveThemes(blocks: Map): Map { ["openknot-light", layer(light, blocks.get(':root[data-theme="openknot-light"]'))], ["dash", layer(blocks.get(':root[data-theme="dash"]'))], ["dash-light", layer(light, blocks.get(':root[data-theme="dash-light"]'))], + ["absolutely", layer(blocks.get(':root[data-theme="absolutely"]'))], + ["absolutely-light", layer(light, blocks.get(':root[data-theme="absolutely-light"]'))], ]); } diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 6f977133ea1d..8c62041082e6 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -228,6 +228,9 @@ "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-display: var(--font-body); + /* Chat prose only. Defaults to the UI face; a theme may point it at a + reading face without changing chrome, chips, tables, or code. */ + --font-chat: var(--font-body); --control-ui-text-scale: 1; --control-ui-text-xs: calc(11px * var(--control-ui-text-scale)); --control-ui-text-sm: calc(12px * var(--control-ui-text-scale)); @@ -938,6 +941,166 @@ --grid-line: rgba(80, 50, 20, 0.04); } +:root[data-theme="absolutely"] { + /* + * Accent — terracotta clay on warm graphite + * + * WCAG 2.1 AA audit (bg = #262624, relative luminance ≈ 0.0198): + * --accent #d97757 L ≈ 0.257 contrast ≈ 4.9:1 AA text ✓ + * --accent-hover #e68b6b L ≈ 0.338 contrast ≈ 6.0:1 ✓ + * --primary-foreground #241f1b on #d97757 button: 5.2:1 AA ✓ + * --primary-foreground #241f1b on #e68b6b hover: 6.4:1 AA ✓ + * --destructive-foreground #fafafa on #d32f2f button: 4.77:1 AA ✓ + * focus-ring at 75% opacity: ≈ 3.3:1 against bg ✓ (non-text 3:1 required) + * + * The accent dims below AA on the lightest surfaces (3.7:1 on --bg-muted), + * so it stays a fill/indicator color there; body copy uses --text. + */ + --ring: #d97757; + --accent: #d97757; + --accent-hover: #e68b6b; + --accent-muted: #d97757; + --accent-subtle: rgba(217, 119, 87, 0.14); + --accent-glow: rgba(217, 119, 87, 0.22); + --primary: #d97757; + --primary-hover: #e68b6b; + --primary-foreground: #241f1b; + + /* Deepen only the text-bearing destructive fill; keep danger accents vivid. */ + --destructive: #d32f2f; + --destructive-hover: #bc2a2a; + + /* Focus — clay ring */ + --focus: rgba(217, 119, 87, 0.2); + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 75%, transparent); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow); + + /* Surfaces — warm graphite, a shade off true neutral */ + --bg: #262624; + --bg-accent: #2d2c29; + --bg-elevated: #33322e; + --bg-hover: #3a3934; + --bg-muted: #3a3934; + + --card: #2d2c29; + --card-foreground: #ede8dd; + --card-highlight: rgba(255, 246, 232, 0.04); + --popover: #33322e; + --popover-foreground: #ede8dd; + + --panel: #262624; + --panel-strong: #33322e; + --panel-hover: #3a3934; + --chrome: rgba(38, 38, 36, 0.96); + --chrome-strong: rgba(38, 38, 36, 0.98); + + --text: #e4dfd4; + --text-strong: #f5f1e8; + --chat-text: #e4dfd4; + --muted: #aba498; + --muted-strong: #b8b1a4; + --muted-foreground: #aba498; + + --border: #383630; + --border-strong: #48453d; + --border-hover: #5c584e; + --input: #383630; + + --secondary: #2d2c29; + --secondary-foreground: #ede8dd; + --accent-2: #b8926a; + --accent-2-muted: rgba(184, 146, 106, 0.7); + --accent-2-subtle: rgba(184, 146, 106, 0.12); + + --shadow-sm: 0 1px 2px rgba(12, 10, 8, 0.35); + --shadow-md: 0 4px 16px rgba(12, 10, 8, 0.45); + --shadow-lg: 0 12px 32px rgba(12, 10, 8, 0.55); + --overlay-shadow: var(--shadow-md); + + --grid-line: rgba(255, 246, 232, 0.03); + /* Typography is this theme's signature: a grotesque for chrome, a reading + serif for chat prose (styles/fonts.css self-hosts both). The stacks keep + system fallbacks so a failed font load degrades instead of blanking text. */ + --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-chat: "Lora", Georgia, "Times New Roman", serif; +} + +:root[data-theme="absolutely-light"] { + /* + * Accent — burnt clay on ivory paper + * + * WCAG 2.1 AA audit (bg = #faf9f5, relative luminance ≈ 0.934): + * --accent #a8452a L ≈ 0.126 contrast ≈ 5.6:1 AA text ✓ + * --accent-hover #8e3822 L ≈ 0.086 contrast ≈ 7.3:1 ✓ + * --primary-foreground #ffffff on #a8452a button: 5.9:1 AA ✓ + * focus-ring at 70% opacity: ≈ 3.2:1 against ivory bg ✓ + */ + --ring: #a8452a; + --accent: #a8452a; + --accent-hover: #8e3822; + --accent-muted: #a8452a; + --accent-subtle: rgba(168, 69, 42, 0.1); + --accent-glow: rgba(168, 69, 42, 0.14); + --primary: #a8452a; + --primary-foreground: #ffffff; + + /* Surfaces — ivory paper with a faint warm cast */ + --bg: #faf9f5; + --bg-accent: #f3f1e9; + --bg-elevated: #ffffff; + --bg-hover: #edeae0; + --bg-muted: #edeae0; + --bg-content: #f3f1e9; + + --card: #ffffff; + --card-foreground: #1f1d1a; + --card-highlight: rgba(60, 50, 30, 0.02); + --popover: #ffffff; + --popover-foreground: #1f1d1a; + --session-hovercard-surface: var(--card); + --session-hovercard-progress-surface: color-mix( + in srgb, + var(--card) 50%, + var(--panel-strong) 50% + ); + + --panel: #faf9f5; + --panel-strong: #f3f1e9; + --panel-hover: #e6e2d6; + --chrome: rgba(250, 249, 245, 0.96); + --chrome-strong: rgba(250, 249, 245, 0.98); + + --text: #3d3a33; + --text-strong: #1f1d1a; + --chat-text: #3d3a33; + --muted: #6b655b; + --muted-strong: #555047; + --muted-foreground: #6b655b; + + --border: #e3dfd2; + --border-strong: #cdc7b6; + --border-hover: #ada694; + --input: #e3dfd2; + + --secondary: #f3f1e9; + --secondary-foreground: #3d3a33; + --accent-2: #8a6a44; + --accent-2-muted: rgba(138, 106, 68, 0.75); + --accent-2-subtle: rgba(138, 106, 68, 0.1); + + --shadow-sm: 0 1px 2px rgba(60, 50, 30, 0.05); + --shadow-md: 0 4px 12px rgba(60, 50, 30, 0.07); + --shadow-lg: 0 12px 28px rgba(60, 50, 30, 0.09); + --overlay-shadow: var(--shadow-md); + + --grid-line: rgba(60, 50, 30, 0.04); + /* Typography is this theme's signature: a grotesque for chrome, a reading + serif for chat prose (styles/fonts.css self-hosts both). The stacks keep + system fallbacks so a failed font load degrades instead of blanking text. */ + --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-chat: "Lora", Georgia, "Times New Roman", serif; +} + * { box-sizing: border-box; /* scrollbar-width doesn't inherit (unlike scrollbar-color above), so every diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index d5a8c0526891..9a55ba830c39 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -1178,7 +1178,8 @@ details.msg-meta:not([open]) .msg-meta__details { } :root[data-theme="openknot"] .chat-confirm-popover__yes, -:root[data-theme="dash"] .chat-confirm-popover__yes { +:root[data-theme="dash"] .chat-confirm-popover__yes, +:root[data-theme="absolutely"] .chat-confirm-popover__yes { background: var(--destructive); color: var(--destructive-foreground); } @@ -1192,7 +1193,8 @@ details.msg-meta:not([open]) .msg-meta__details { } :root[data-theme="openknot"] .chat-confirm-popover__yes:hover, -:root[data-theme="dash"] .chat-confirm-popover__yes:hover { +:root[data-theme="dash"] .chat-confirm-popover__yes:hover, +:root[data-theme="absolutely"] .chat-confirm-popover__yes:hover { background: var(--destructive-hover); } diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 7910e5dba291..41ae0b670be5 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -19,6 +19,7 @@ } .chat-text { + font-family: var(--font-chat); font-size: var(--chat-text-size); line-height: 1.5; color: var(--chat-text); diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index fcd4351b4685..9a1aa499a3c0 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -249,7 +249,9 @@ .settings-theme-card { position: relative; display: grid; - grid-template-columns: auto 1fr auto; + /* minmax(0, 1fr): a plain 1fr floors at the label's min-content width, so a + long theme name (imported tweakcn labels) overflowed the card's border. */ + grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; min-height: 64px; @@ -269,14 +271,15 @@ background: var(--bg-hover); } +/* Selection is carried by this skin alone (aria-pressed owns the semantics), + so it stays stronger than the hover state at every accent. */ .settings-theme-card--active { - border-color: color-mix(in srgb, var(--accent) 35%, transparent); - background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 60%, transparent); + background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); } -.settings-theme-card__icon, -.settings-theme-card__check { +.settings-theme-card__icon { display: inline-flex; align-items: center; justify-content: center; @@ -285,8 +288,7 @@ color: var(--accent); } -.settings-theme-card__icon svg, -.settings-theme-card__check svg { +.settings-theme-card__icon svg { width: 18px; height: 18px; stroke: currentColor; @@ -297,6 +299,9 @@ font-size: 13px; font-weight: 600; color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .settings-theme-card--active .settings-theme-card__label { @@ -355,6 +360,13 @@ --theme-chip-accent-2: #a88050; } +.settings-theme-card--absolutely, +.settings-accent-theme--absolutely { + --theme-chip-bg: #262624; + --theme-chip-accent: #d97757; + --theme-chip-accent-2: #b8926a; +} + :root[data-theme-mode="light"] :is(.settings-theme-card--claw, .settings-accent-theme--claw) { --theme-chip-bg: #faf9f7; --theme-chip-accent: #bd4531; @@ -372,6 +384,13 @@ --theme-chip-accent: #6e4828; --theme-chip-accent-2: #6a4e30; } + +:root[data-theme-mode="light"] + :is(.settings-theme-card--absolutely, .settings-accent-theme--absolutely) { + --theme-chip-bg: #faf9f5; + --theme-chip-accent: #a8452a; + --theme-chip-accent-2: #8a6a44; +} /* stylelint-enable color-no-hex */ .settings-theme-card--custom, diff --git a/ui/src/styles/theme-contrast.test.ts b/ui/src/styles/theme-contrast.test.ts index f7a0d129665e..57f34bff6783 100644 --- a/ui/src/styles/theme-contrast.test.ts +++ b/ui/src/styles/theme-contrast.test.ts @@ -145,6 +145,7 @@ describe("Control UI theme contrast", () => { ':root[data-theme-mode="light"]', ':root[data-theme="openknot-light"]', ':root[data-theme="dash-light"]', + ':root[data-theme="absolutely-light"]', ]) { const theme = readCssVarBlock(baseCss, selector); const muted = requireCssColor(theme, "muted");