mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
feat(control-ui): add the Absolutely theme with self-hosted typography (#129885)
* chore(control-ui): vendor Space Grotesk and Lora webfont subsets Adds the woff2 faces the Absolutely theme paints in, plus the stylesheet that declares them. Both families are SIL Open Font License 1.1 and ship unmodified, with their upstream license text beside the files as the license requires. Latin and latin-ext subsets only, 184 KB total. Serving these from the gateway rather than 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. The stylesheet is a static asset rather than a bundled import: it is linked at runtime only while a theme that declares faces is active, so it stays out of the startup CSS budget and out of every other theme's load. * feat(control-ui): add the Absolutely theme with self-hosted typography Adds a fourth built-in Control UI theme: terracotta clay on warm graphite in dark mode, burnt clay on ivory in light mode, with its own typography — Space Grotesk for chrome and Lora for chat prose. Typography is theme-owned rather than global. A new --font-chat token defaults to --font-body, so every other theme is byte-for-byte unchanged, and the face declarations live in a static stylesheet that the theme applier links only while a theme that declares fonts is active. Themes without declared faces issue no font request at all. The faces themselves are vendored in the preceding commit. Also fixes two defects found while building it: - Theme cards overflowed their border on long labels: the grid's 1fr label track floors at min-content, so a long name pushed the trailing column past the card edge. The track is now minmax(0, 1fr) with an ellipsis, which also covers imported tweakcn labels. The selected-state checkmark that made the overflow visible is gone; the accent border, tint, and ring already carry selection, and aria-pressed owns the semantics. - index.html's first-paint bootstrap only knew claw/knot/dash, so any new theme flashed the default palette before the app corrected it. It now resolves the new theme and has a matching pre-paint background, with mount-fallback coverage for both modes. Startup JS grows 198 B for the theme wiring, appearance option, locale strings, and font loader, which fits the existing ratchet tolerance, so no budget baseline moves. Startup CSS stays within its 45 KiB ceiling, which is why the faces are a linked static asset rather than a bundled import. * style(control-ui): format the vendored font stylesheet The generated @font-face block kept its upstream single-line unicode-range declarations, which oxfmt wraps. Repo-wide `pnpm format:check` is clean. * test(control-ui): type the chat runId in the typography e2e Reuses the chat-flow requireRecord/requireString helpers instead of asserting the params shape, so runId is a real string rather than an unknown coerced by String() (oxlint no-base-to-string). * fix(control-ui): resolve theme fonts against the configured UI mount The theme font stylesheet and its faces used root-absolute URLs, so a gateway served under a configured Control UI base path (gateway.controlUi.basePath, for example /openclaw) fetched them from the wrong origin path. Both requests 404ed there and Absolutely silently fell back to system fonts while its palette still applied, which reads as "the theme looks a bit off" rather than as a broken asset. The link href now goes through inferControlUiPublicAssetPath, the same helper plugin art and provider icons already use, and the stylesheet's own url() references are relative to it, so both levels follow the mount without the loader knowing the base path. Adds an e2e case under /openclaw asserting the resolved href and that the browser fetches below the mount; it fails on the previous code with the root-absolute path. Reported by ClawSweeper review on #129885. * test(slack): stop racing the aborted read request in the Web API bound test `bounds dedicated reads without timing out shared clients` asserted the mock server received exactly two requests, but the read client aborts at 20ms and the server records on arrival, so whether the aborted request lands first is a race. It failed on a loaded CI runner with 1 received. The bound is still asserted directly (read rejects, shared resolves); the count now only requires the shared client's arrival, and checks its token so the dedicated client is still proven not to have carried the call. Unrelated to this PR's theme work; fixed here rather than left red.
This commit is contained in:
committed by
GitHub
parent
4bd8859126
commit
80ecc7c8b5
@@ -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/<id>` registry URLs, editor URLs like `https://tweakcn.com/editor/theme?theme=amethyst-haze`, relative `/themes/<id>` 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/<id>` registry URLs, editor URLs like `https://tweakcn.com/editor/theme?theme=amethyst-haze`, relative `/themes/<id>` 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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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")])
|
||||
|
||||
+16
-4
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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<typeof loadSettings>): void {
|
||||
@@ -88,6 +88,7 @@ function applyThemePresentation(settings: ReturnType<typeof loadSettings>): 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();
|
||||
|
||||
@@ -44,7 +44,7 @@ function installStartupPaintShell(window: TestWindow, html: string): void {
|
||||
|
||||
const startupScript = Array.from(
|
||||
parsed.querySelectorAll<HTMLScriptElement>("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) => {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "./settings.ts";
|
||||
import type { ThemeMode, ThemeName } from "./theme.ts";
|
||||
|
||||
const THEMES: ReadonlySet<ThemeName> = new Set(["claw", "knot", "dash", "custom"]);
|
||||
const THEMES: ReadonlySet<ThemeName> = new Set(["claw", "knot", "dash", "absolutely", "custom"]);
|
||||
const THEME_MODES: ReadonlySet<ThemeMode> = new Set(["light", "dark", "system"]);
|
||||
|
||||
type SyncedPrefSpec<T> = {
|
||||
|
||||
+46
-2
@@ -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<ThemeName>(["claw", "knot", "dash", "custom"]);
|
||||
const VALID_THEME_NAMES = new Set<ThemeName>(["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<Record<ThemeName, ControlUiFontStylesheet>> = {
|
||||
absolutely: "fonts/absolutely.css",
|
||||
};
|
||||
type ControlUiFontStylesheet = `fonts/${string}.css`;
|
||||
const VALID_THEME_MODES = new Set<ThemeMode>(["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);
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
};
|
||||
|
||||
@@ -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<ReturnType<typeof installMockGateway>>,
|
||||
page: Awaited<ReturnType<typeof openThemedChat>>["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") });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1821,6 +1821,10 @@ export const en: TranslationMap = {
|
||||
label: "Dash",
|
||||
description: "Chocolate blueprint",
|
||||
},
|
||||
absolutely: {
|
||||
label: "Absolutely",
|
||||
description: "Clay on graphite",
|
||||
},
|
||||
},
|
||||
textSizes: {
|
||||
small: "Small",
|
||||
|
||||
@@ -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)}
|
||||
<span class="settings-theme-card__label">${opt.label}</span>
|
||||
${opt.id === props.theme
|
||||
? html`<span class="settings-theme-card__check" aria-hidden="true"
|
||||
>${icons.check}</span
|
||||
>`
|
||||
: nothing}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -124,6 +124,8 @@ function resolveThemes(blocks: Map<string, TokenMap>): Map<string, TokenMap> {
|
||||
["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"]'))],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user