feat(theme): add Clear Blue and Tea Green foundation palettes

Problem: Studio offers no plain built-in alternatives matching the white/blue
and warm/tea-green directions requested in #12909.

Root cause: the default swatch inherits active pack colours, plain packs lose
the default reading surface and weight, and legacy Studio rules override
semantic accent colours on selected controls and dark send buttons.

Fix: ship two light/dark token-only packs, share canonical default preview
colours, preserve plain reading styles, and close the affected controls over
theme tokens. Add optional accentInk, localized labels and backdrop grouping.

Verification: 209 frontend tests, production build/typecheck, theme and HTTP
server tests, go vet ./..., make lint, make check, builtin and boot tests.
Browser checks cover system-following, default restoration, and light/dark
workbenches at desktop and phone widths; screenshots are included.
This commit is contained in:
SivanCola committed 2026-10-13 18:27:58 +08:00
1 parent a41ce9f980
commit 0636552ae3
31 files changed
+356 -46

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
---
status: active
owner: @esengine
backup: @SivanCola
reviewed: 2026-10-13
---
# Foundation palette verification
final result: passed
Clear Blue and Tea Green implement the two approved colour directions for
[#12909](https://github.com/esengine/DeepSeek-Reasonix/issues/12909).
The existing default remains the baseline. Layout, typography, copy and brand
assets follow the product rather than generated mockup variations.
## Screenshots
Captured from the actual Studio components with synthetic performance-harness
data, one conversation turn and an empty composer. Fixture workspace and host
names are not real user data. Screenshots use 1:1 density.
| Palette | Light, 1280 × 720 | Dark, 1280 × 720 |
| --- | --- | --- |
| Default baseline | [Light](docs/design/theme-palettes/default-light-wide.jpg) | [Dark](docs/design/theme-palettes/default-dark-wide.jpg) |
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-wide.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-wide.jpg) |
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-wide.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-wide.jpg) |
| Palette | Light, 390 × 844 | Dark, 390 × 844 |
| --- | --- | --- |
| Default baseline | [Light](docs/design/theme-palettes/default-light-phone.jpg) | [Dark](docs/design/theme-palettes/default-dark-phone.jpg) |
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-phone.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-phone.jpg) |
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-phone.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-phone.jpg) |
## Visual comparison
The approved light mockups were 1619 × 971 pixels, including a caption outside
the application. They were viewed together with the implementation screenshots.
Comparison excluded that caption and assessed the palette direction, rather
than treating generated layout drift as a product specification.
- Typography: existing families, size, line height and tracking retained. Plain
light palettes now share the default prose weight; user overrides still win.
- Spacing: existing grid, controls, corners and panel proportions retained.
- Colours: cool white/blue and warm white/tea-green surfaces match the approved
directions. Text, syntax and button contrast checks pass in both schemes.
- Assets: existing logos and icons retained; no added image or sky layers.
- Copy: existing workbench text retained; new palette names and descriptions
have Chinese and English labels.
Full views made the navigation, transcript and composer readable without
additional crops. Dark palettes extend the approved hues; the source mockups
did not specify a dark layout. Phone captures confirm no horizontal overflow.
## Findings and iteration history
1. Default preview inherited the selected pack, and plain packs changed reading
surfaces and weight. Shared canonical default variables and plain-surface
rules now keep the preview independent and typography consistent.
2. Initial comparison found neutral selected navigation and a dark send button
overridden by body colour. Shared pack selectors now use accent ink/wash and
the accent/foreground pair; disabled controls remain neutral.
3. Revised screenshots were compared with both approved directions. No
actionable P0/P1/P2 differences remain. Sky themes now join image themes in
the expandable collection; the active backdrop remains in the first group.
## Behavioural evidence
- Immediate selection, return to default, light/dark and system-following modes
verified in the browser; each preview follows its own scheme tokens.
- Default comparison: original and revised Studio CSS applied to the same DOM.
Fourteen style properties matched for 735 visible elements in each scheme
under a light system and 734 in each scheme under a dark system.
- Compared colour, background, border, shadow, font, size, weight, line height,
tracking, width, height, padding, margin and radius.
- Go integration tests cover selection, config persistence, server recreation
and reset to the default for both new built-in IDs.
- Frontend neighbourhood: 22 test files, 209 tests passed; production build and
typecheck passed. Browser warning/error logs were empty.
- Theme and HTTP server package tests, repository checks and whitespace checks
passed. Lint budgets were not increased.
## Remaining coverage
Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning.
+4
View File
@@ -445,6 +445,10 @@ export const EN: Record<string, string> = {
"深色": "Dark",
"配色": "Palette",
"界面主题": "Interface theme",
"清白蓝": "Clear Blue",
"茶绿": "Tea Green",
"清透白底与克制蓝色,适合专注阅读和日常工作。": "Clean white surfaces and restrained blue accents for focused reading and everyday work.",
"柔和暖白与低饱和茶绿,营造安静舒展的工作台。": "Soft warm whites and muted tea greens for a calm, comfortable workbench.",
"选择工作台的表面色与强调色。成功、警告和失败等状态色保持一致,代码字体在下方单独设置。":
"Choose the workbench surfaces and accent. Success, warning and failure keep their meaning, while the code font is configured separately below.",
"更多已安装主题": "More installed themes",
+4 -2
View File
@@ -1,5 +1,7 @@
import { MockNetwork } from "./mock_network";
import type { ThemeImport, ThemePack } from "./port";
import clearBlue from "../../../../internal/ext/theme/builtin/official-clear-blue.json";
import teaGreen from "../../../../internal/ext/theme/builtin/official-tea-green.json";
// Theme packs, and where an extension's view was told to sit.
export class MockTheme extends MockNetwork {
@@ -11,10 +13,10 @@ export class MockTheme extends MockNetwork {
private removed = new Set<string>();
// Two packs so the picker has something to switch between; the fixture is
// where the mapping gets exercised without a Go process.
// Production foundations plus legacy fixtures exercise both token generations.
async themes(): Promise<ThemePack[]> {
const packs: ThemePack[] = [
...[clearBlue, teaGreen].map((pack) => ({ ...pack, active: this.activeTheme === pack.id })),
{
id: "dusk", name: "Dusk", author: "fixture", removable: true, active: this.activeTheme === "dusk",
tokens: {
+7 -2
View File
@@ -241,9 +241,14 @@ raised with its ratios kept.
## Weight and contrast
- Default light Studio replies use `--w-read: 400` for prose only. Headings,
- Default and plain-palette light Studio replies use `--w-read: 400` for prose only. Headings,
emphasis and small UI text keep their existing weights. A chosen weight,
installed pack or dark scheme falls back to `--w-reg`.
illustrated/sky pack or dark scheme falls back to `--w-reg`.
- `--default-*` holds the canonical Studio preview palette independently of
the active pack. Default surfaces and the default picker card share it.
- `--accent-ink` is text on ordinary or tinted surfaces, distinct from
`--accent-fg` on filled controls. Packs may set `accentInk`; legacy packs
derive it by mixing their accent towards their body text colour.
- `background.textGlow: none` paints `--flow-text-shadow: none` through the
pack sheet. The backdrop transcript rule defaults to its 2px shadow and 7px
halo when this variable is absent. The choice affects text only; the frosted
@@ -13,6 +13,18 @@ describe("the Studio shell closes over the active theme", () => {
expect(css.slice(at + MARKER.length)).not.toContain(MARKER);
});
it("shares plain reading surfaces and weight with foundation palettes", () => {
expect(css).toContain(':root[data-theme="light"]:not([data-bg]) {');
expect(css).toContain(':root[data-theme="light"]:not([data-bg]):not([data-weight]) {');
});
it("previews the default from its own palette even while a pack is active", () => {
const preview = css.match(/\.pal\[data-value=""\] \.pal-art\s*\{([^}]*)\}/s)?.[1] ?? "";
for (const name of ["page", "surface", "panel", "border", "text", "dim", "accent"]) {
expect(preview).toContain(`--pal-${name}: var(--default-${name})`);
}
});
it("covers every persistent product surface", () => {
for (const selector of [
".chrome", ".rail", ".compose", ".activity-group", ".studio-browser", ".btn.send",
+60 -37
View File
@@ -36,23 +36,16 @@
font-display: swap;
}
:root {
--page: #f6f6f4;
--surface: #ffffff;
--raised: #ffffff;
--overlay: #efefec;
--border: #deded9;
--hair: #e9e9e5;
--float: #ffffff;
--float-hi: #f4f4f1;
--accent: #282a28;
--accent-fg: #ffffff;
--accent-ink: #303330;
--accent-wash: #e9eae6;
--net: #566aa9;
--net-wash: #eef1fa;
--focus: #7388c8;
--text: #292b29;
--muted: #5f625d;
--faint: #666a63;
--ghost: #93968f;
--shadow-float: 0 1px 2px rgb(24 29 24 / .08), 0 18px 50px -22px rgb(24 29 24 / .25);
@@ -69,23 +62,16 @@
@media (prefers-color-scheme: dark) {
:root {
--page: #171817;
--surface: #1a1b1a;
--raised: #20211f;
--overlay: #252624;
--border: #343633;
--hair: #292b28;
--float: #242523;
--float-hi: #2c2e2b;
--accent: #e8eae5;
--accent-fg: #171817;
--accent-ink: #e1e3dd;
--accent-wash: #292b28;
--net: #8fa5e8;
--net-wash: #202635;
--focus: #8fa5e8;
--text: #e5e7e1;
--muted: #b8bab4;
--faint: #969991;
--ghost: #757871;
--shadow-float: 0 0 0 1px rgb(0 0 0 / .42), 0 20px 56px -18px rgb(0 0 0 / .75);
@@ -93,23 +79,16 @@
}
:root[data-theme="dark"] {
--page: #171817;
--surface: #1a1b1a;
--raised: #20211f;
--overlay: #252624;
--border: #343633;
--hair: #292b28;
--float: #242523;
--float-hi: #2c2e2b;
--accent: #e8eae5;
--accent-fg: #171817;
--accent-ink: #e1e3dd;
--accent-wash: #292b28;
--net: #8fa5e8;
--net-wash: #202635;
--focus: #8fa5e8;
--text: #e5e7e1;
--muted: #b8bab4;
--faint: #969991;
--ghost: #757871;
--shadow-float: 0 0 0 1px rgb(0 0 0 / .42), 0 20px 56px -18px rgb(0 0 0 / .75);
@@ -374,55 +353,70 @@ body { background: var(--page); }
:root,
:root[data-theme="dark"] {
--page: #151615;
--surface: #181918;
--raised: #1d1e1c;
--default-page: #151615;
--default-surface: #181918;
--default-panel: #1d1e1c;
--default-border: #30322e;
--default-accent: #d8d9d4;
--default-text: #e7e8e3;
--default-dim: #b4b7b0;
--page: var(--default-page);
--surface: var(--default-surface);
--raised: var(--default-panel);
--overlay: #242522;
--border: #30322e;
--border: var(--default-border);
--hair: #272925;
--float: #20211f;
--float-hi: #292b28;
--accent: #d8d9d4;
--accent: var(--default-accent);
--accent-fg: #171817;
--accent-ink: #e7e8e3;
--accent-wash: #292a27;
--focus: #9ca6a0;
--text: #e7e8e3;
--muted: #b4b7b0;
--text: var(--default-text);
--muted: var(--default-dim);
--faint: #8e9289;
--ghost: #8e9289;
--shadow-float: 0 0 0 1px rgb(0 0 0 / .42), 0 18px 48px -18px rgb(0 0 0 / .76);
}
:root[data-theme="light"] {
--page: #f0f0ed;
--surface: #fafaf8;
--raised: #ffffff;
--default-page: #f0f0ed;
--default-surface: #fafaf8;
--default-panel: #ffffff;
--default-border: #d6d7d1;
--default-accent: #252725;
--default-text: #292b29;
--default-dim: #5e625c;
--page: var(--default-page);
--surface: var(--default-surface);
--raised: var(--default-panel);
--overlay: #e9e9e5;
--border: #d6d7d1;
--border: var(--default-border);
--hair: #e2e3de;
--float: #ffffff;
--float-hi: #f1f1ed;
--accent: #252725;
--accent: var(--default-accent);
--accent-fg: #ffffff;
--accent-ink: #252725;
--accent-wash: #e4e5e0;
--net: #566aa9;
--net-wash: #eef1fa;
--focus: #68736d;
--text: #292b29;
--muted: #5e625c;
--text: var(--default-text);
--muted: var(--default-dim);
--faint: #666a63;
--ghost: #666a63;
}
:root[data-theme="light"]:not([data-pack]):not([data-bg]) {
:root[data-theme="light"]:not([data-bg]) {
--reading-surface: var(--surface);
--compose-surface: var(--raised);
--compose-shadow: 0 3px 12px rgb(25 30 25 / .07);
}
:root[data-theme="light"]:not([data-pack]):not([data-weight]) {
:root[data-theme="light"]:not([data-pack]):not([data-weight]),
:root[data-theme="light"]:not([data-bg]):not([data-weight]) {
--w-read: 400;
}
@@ -4209,3 +4203,32 @@ body { font-size: 14px; line-height: 1.75; }
.workbench-content { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.workbench-content[hidden] { display: none; }
.pal[data-value=""] .pal-art {
--pal-page: var(--default-page);
--pal-surface: var(--default-surface);
--pal-panel: var(--default-panel);
--pal-border: var(--default-border);
--pal-text: var(--default-text);
--pal-dim: var(--default-dim);
--pal-accent: var(--default-accent);
}
:root[data-pack] .chrome .browser-action[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent-wash); }
:root[data-pack] .chrome-context-card section > button { color: var(--link); }
:root[data-pack] .studio-brand-accent { color: var(--brand); }
:root[data-pack] .navbtn[aria-current],
:root[data-pack] .studio-session-segments button[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent-wash); }
:root[data-pack] .navbtn[aria-current]::before { background: var(--accent); }
:root[data-pack] .studio-session-segments button[aria-pressed="true"] b { color: var(--accent-ink); }
:root[data-pack] .btn.send:not(:disabled) { background: var(--accent); color: var(--accent-fg); }
:root[data-pack] .studio-effort-menu .studio-effort-strength i[data-on] { background: var(--accent); }
:root[data-pack] .compose:hover::after,
:root[data-pack] .compose:focus-within::after { border-color: var(--halo); }
:root[data-pack] .btn.send:not(:disabled):hover { background: color-mix(in srgb, var(--accent) 90%, var(--accent-ink)); }
:root[data-pack] .flow .opt:hover { border-color: var(--accent) !important; }
:root[data-pack] .flow .opt[data-on] {
border-color: var(--accent) !important; background: var(--accent-wash);
box-shadow: 0 0 0 1px var(--accent-wash);
}
:root[data-pack] .flow .opt .mark { border-color: var(--accent); }
+5 -5
View File
@@ -155,7 +155,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
);
const cell = (p: ThemePack) => (
<div className="pal-cell" key={p.id}>
<Swatch pack={p} theme={theme} name={p.name} on={!!p.active} onPick={() => pick(p.id)} />
<Swatch pack={p} theme={theme} name={t(p.name)} on={!!p.active} onPick={() => pick(p.id)} />
{p.removable && <ThemeRemove name={p.name} onRemove={() => remove(p.id)} />}
</div>
);
@@ -166,10 +166,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
// page a user opens merely to switch between light and dark. If one of them
// is active it remains visible beside the foundations, so the current state
// never disappears into a closed disclosure.
const foundations = packs.filter((p) => !p.background?.image);
const activeIllustrated = packs.find((p) => p.active && p.background?.image);
const foundations = packs.filter((p) => !p.background?.image && !p.sky);
const activeIllustrated = packs.find((p) => p.active && (p.background?.image || p.sky));
const featured = activeIllustrated ? [...foundations, activeIllustrated] : foundations;
const illustrated = packs.filter((p) => p.background?.image && p.id !== activeIllustrated?.id);
const illustrated = packs.filter((p) => (p.background?.image || p.sky) && p.id !== activeIllustrated?.id);
// A change lands on screen through App's own effect; this only sends it on
// so the next launch opens the same way.
@@ -720,7 +720,7 @@ function Swatch({
: undefined;
return (
<button className="pal" type="button" data-action="theme.activate" data-value={pack?.id ?? ""} title={pack?.description || name} data-on={on ? "" : undefined} aria-pressed={on} onClick={onPick}>
<button className="pal" type="button" data-action="theme.activate" data-value={pack?.id ?? ""} title={pack?.description ? t(pack.description) : name} data-on={on ? "" : undefined} aria-pressed={on} onClick={onPick}>
<span className="pal-art" style={style}>
<span className="pal-rail" />
<span className="pal-body">
@@ -84,3 +84,24 @@ it("keeps a working preview mounted across ordinary palette redraws", async () =
expect((card.querySelector(".pal-art") as HTMLElement).style.getPropertyValue("--pal-page")).toBe("#0F0D0B");
expect(themes).toHaveBeenCalledTimes(1);
});
it.each(["zh", "en"])("keeps foundations visible and previews their own scheme in %s", async (lang) => {
localStorage.setItem(STORAGE, lang);
boot();
const port = new MockPort();
const view = render(picker(port));
const blue = await screen.findByRole("button", { name: lang === "zh" ? /清白蓝/ : /Clear Blue/ });
const green = screen.getByRole("button", { name: lang === "zh" ? /茶绿/ : /Tea Green/ });
expect(blue.closest("details")).toBeNull();
expect(green.closest("details")).toBeNull();
const sky = document.querySelector('.pal[data-value="tide"]')!;
expect(sky.closest("details")?.open).toBe(false);
const art = blue.querySelector(".pal-art") as HTMLElement;
expect(art.style.getPropertyValue("--pal-page")).toBe("#F5F7FA");
await userEvent.click(green);
await waitFor(() => expect(green.getAttribute("aria-pressed")).toBe("true"));
expect(art.style.getPropertyValue("--pal-page")).toBe("#F5F7FA");
view.rerender(picker(port, "dark"));
expect(art.style.getPropertyValue("--pal-page")).toBe("#151B24");
expect((green.querySelector(".pal-art") as HTMLElement).style.getPropertyValue("--pal-page")).toBe("#18211B");
});
@@ -83,6 +83,17 @@ describe("a pack is delivered by a generated stylesheet", () => {
expect(read("--page")).toBe("#101010");
});
it("gives accent text its own ink and derives it for older packs", () => {
apply(pack, "light");
expect(read("--accent-ink")).toBe("color-mix(in srgb, #0066CC 65%, #1A1A1A)");
apply({ ...pack, tokens: { ...pack.tokens, light: { ...pack.tokens.light, accentInk: "#004488" } } }, "light");
expect(read("--accent-ink")).toBe("#004488");
apply(pack, "dark");
expect(read("--accent-ink")).toContain("#66AAFF");
apply(null, "dark");
expect(read("--accent-ink")).toBe("");
});
it("keeps a value that would end a rule inside its declaration", () => {
apply({ ...pack, tokens: { light: { bg: "#fff} body{display:none", fg: "#111" }, dark: {} } }, "light");
expect(sheet()?.sheet?.cssRules).toHaveLength(1);
+2
View File
@@ -19,6 +19,7 @@ const SURFACE: Record<string, string[]> = {
fgStrong: ["--text-strong"],
accent: ["--accent"],
accentFg: ["--accent-fg"],
accentInk: ["--accent-ink"],
link: ["--link"],
brand: ["--brand"],
halo: ["--halo"],
@@ -42,6 +43,7 @@ const SURFACE: Record<string, string[]> = {
// What a pack's own surfaces imply for the grounds it did not name.
const DERIVED: [string, { source: string; paint: (t: Record<string, string>) => string }][] = [
["accentInk", { source: "accent", paint: (t) => `color-mix(in srgb, ${t.accent} 65%, ${t.fg ?? "var(--text)"})` }],
["float", { source: "panel", paint: (t) => t.panel }],
["floatHi", { source: "bgElev", paint: (t) => t.bgElev }],
["codeBg", { source: "bgSoft", paint: (t) => `color-mix(in srgb, ${t.bgSoft} 50%, ${t.bg ?? t.bgSoft})` }],
+15
View File
@@ -86,6 +86,21 @@ The authoritative vocabulary is the kernel's [`Tokens` map](../internal/ext/them
The frontend maps the vocabulary to CSS variables in [`theme.ts`](../desktop/frontend-next/src/ui/theme.ts). The kernel's [`TestThemeTokenVocabularyMatchesTheFrontend`](../internal/ext/theme/tokens_test.go) checks that both sides agree.
`accent` paints filled controls; `accentFg` is text on that fill. Optional
`accentInk` paints accent-coloured text on ordinary surfaces and tinted washes.
When absent, Studio mixes 65% `accent` with 35% `fg` (or the current text colour).
Choose these as separate contrast pairs. Older readers ignore `accentInk` and
continue loading the other schema-version-1 tokens.
The built-in **清白蓝 / Clear Blue** (`official-clear-blue`) and **茶绿 / Tea Green**
(`official-tea-green`) are plain light/dark palettes. They use no wallpaper, sky,
font or radius overrides. They are opt-in; an empty `desktop.theme_pack` still
selects the unchanged default.
Foundation palettes share the default reading
surface and respect the reader's size, weight and contrast preferences.
Status colours such as `ok`, `warn` and `err` belong to Studio and cannot be
recoloured by a pack. `sidebar` and `chat` are not current token names.
An unknown token or invalid value is dropped, while valid tokens still load;
+4
View File
@@ -186,6 +186,10 @@ JSON Schema: [theme-pack.schema.json](./theme-pack.schema.json)
Studio also reads `link`, `brand`, `halo` and `labelAgent` (hyperlinks, the product tint, hover emphasis, agent names). `net`, `deleg` and the other status colours are not tokens and are dropped with a warning.
`accentInk` optionally supplies accent-coloured text independently of the
`accent` fill and `accentFg` button text. See the [current author guide](THEME_AUTHOR_GUIDE.md#current-token-vocabulary)
for runtime vocabulary and fallback behaviour.
Colors must **not** include `url()`, gradients, or arbitrary CSS.
## Engine behavior
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+1
View File
@@ -101,6 +101,7 @@
"fgFaint": { "$ref": "#/$defs/hexColor" },
"accent": { "$ref": "#/$defs/hexColor" },
"accentFg": { "$ref": "#/$defs/hexColor" },
"accentInk": { "$ref": "#/$defs/hexColor" },
"ok": { "$ref": "#/$defs/hexColor" },
"warn": { "$ref": "#/$defs/hexColor" },
"err": { "$ref": "#/$defs/hexColor" }
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "official-clear-blue",
"name": "清白蓝",
"author": "Reasonix",
"description": "清透白底与克制蓝色,适合专注阅读和日常工作。",
"license": "MIT",
"tokens": {
"light": {
"bg": "#F5F7FA", "bgSoft": "#FFFFFF", "bgElev": "#F0F3F7", "panel": "#FFFFFF",
"border": "#DCE2EA", "borderSoft": "#E8EDF3",
"fg": "#202936", "fgStrong": "#182231", "fgDim": "#566274", "fgFaint": "#637083",
"accent": "#356AD2", "accentFg": "#FFFFFF", "accentInk": "#285BBC",
"link": "#285BBC", "brand": "#285BBC", "halo": "#356AD2", "labelAgent": "#285BBC",
"float": "#FFFFFF", "floatHi": "#F0F3F7", "codeBg": "#F0F3F7", "sunkBg": "#F5F7FA",
"synKeyword": "#7545A0", "synString": "#276749", "synNumber": "#975312", "synFunction": "#285BBC"
},
"dark": {
"bg": "#151B24", "bgSoft": "#1A2330", "bgElev": "#253142", "panel": "#202B3A",
"border": "#39485D", "borderSoft": "#2B394C",
"fg": "#E3EBF6", "fgStrong": "#F2F6FC", "fgDim": "#BCC9DA", "fgFaint": "#A0AFBF",
"accent": "#8FB8F4", "accentFg": "#142135", "accentInk": "#A6C5FF",
"link": "#A6C5FF", "brand": "#A6C5FF", "halo": "#8FB8F4", "labelAgent": "#A6C5FF",
"float": "#202B3A", "floatHi": "#253142", "codeBg": "#1A2330", "sunkBg": "#151B24",
"synKeyword": "#C8AEED", "synString": "#9FD2AD", "synNumber": "#E4BD84", "synFunction": "#A6C5FF"
}
}
}
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "official-tea-green",
"name": "茶绿",
"author": "Reasonix",
"description": "柔和暖白与低饱和茶绿,营造安静舒展的工作台。",
"license": "MIT",
"tokens": {
"light": {
"bg": "#F3F4EE", "bgSoft": "#FBFCF8", "bgElev": "#EAF0E8", "panel": "#FFFFFF",
"border": "#D7DFD3", "borderSoft": "#E5EAE1",
"fg": "#27342D", "fgStrong": "#1C2C23", "fgDim": "#56665B", "fgFaint": "#627065",
"accent": "#42745D", "accentFg": "#FFFFFF", "accentInk": "#35624D",
"link": "#35624D", "brand": "#35624D", "halo": "#42745D", "labelAgent": "#35624D",
"float": "#FFFFFF", "floatHi": "#EAF0E8", "codeBg": "#EEF2EA", "sunkBg": "#F3F4EE",
"synKeyword": "#725489", "synString": "#35624D", "synNumber": "#8A581B", "synFunction": "#326578"
},
"dark": {
"bg": "#18211B", "bgSoft": "#1D2921", "bgElev": "#2A392F", "panel": "#243229",
"border": "#415448", "borderSoft": "#324439",
"fg": "#E5EDE2", "fgStrong": "#F3F7F0", "fgDim": "#C0CFBF", "fgFaint": "#A3B3A6",
"accent": "#93BE9E", "accentFg": "#15261C", "accentInk": "#AED3B6",
"link": "#AED3B6", "brand": "#AED3B6", "halo": "#93BE9E", "labelAgent": "#AED3B6",
"float": "#243229", "floatHi": "#2A392F", "codeBg": "#1D2921", "sunkBg": "#18211B",
"synKeyword": "#C9B3DC", "synString": "#AED3B6", "synNumber": "#DEC18E", "synFunction": "#A2CBD3"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package theme
import (
"testing"
"reasonix/internal/base/testenv"
)
func TestFoundationPalettesAreCompleteAndReadable(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
for _, id := range []string{"official-clear-blue", "official-tea-green"} {
t.Run(id, func(t *testing.T) {
pack, err := Load(id)
if err != nil {
t.Fatal(err)
}
if pack.Background != nil || pack.Sky != nil || len(pack.Warnings) != 0 {
t.Fatalf("foundation must be plain and valid: %+v", pack)
}
for _, scheme := range []string{"light", "dark"} {
tokens := pack.Tokens[scheme]
for _, name := range []string{"radiusXs", "radiusSm", "radiusMd", "fontUi", "fontMono"} {
if _, ok := tokens[name]; ok {
t.Fatalf("palette changes %s", name)
}
}
for _, name := range []string{"accentInk", "link", "brand", "labelAgent"} {
for _, surface := range []string{"bg", "bgSoft", "bgElev", "panel", "float", "floatHi", "codeBg"} {
if got := contrastRatio(t, tokens[name], tokens[surface]); got < 4.5 {
t.Errorf("%s %s on %s: %.2f", scheme, name, surface, got)
}
}
}
if got := contrastRatio(t, tokens["accentFg"], tokens["accent"]); got < 4.5 {
t.Errorf("%s button text contrast: %.2f", scheme, got)
}
}
})
}
}
+1
View File
@@ -39,6 +39,7 @@ var Tokens = map[string]TokenKind{
"fgStrong": TokenColour,
"accent": TokenColour,
"accentFg": TokenColour,
"accentInk": TokenColour,
"link": TokenColour,
"brand": TokenColour,
"halo": TokenColour,
+22
View File
@@ -115,6 +115,28 @@ func TestActivateEmptyIDRestoresTheDefaultAppearance(t *testing.T) {
}
}
func TestFoundationPaletteSelectionSurvivesServerRestart(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
srv := themeServer(t)
for _, id := range []string{"official-clear-blue", "official-tea-green", ""} {
if resp := postJSON(t, srv.URL+"/themes", map[string]string{"id": id}); resp.StatusCode != http.StatusNoContent {
t.Fatalf("activate %q = %d", id, resp.StatusCode)
}
if cfg, err := config.Load(); err != nil || cfg.Desktop.ThemePack != id {
t.Fatalf("saved theme = %q, want %q (err %v)", cfg.Desktop.ThemePack, id, err)
}
srv = themeServer(t)
for _, row := range listThemes(t, srv.URL) {
if row.Active != (row.ID == id) {
t.Fatalf("wrong active state after restart: %+v", row)
}
if row.ID == id && (row.Removable || row.Background != nil || row.HasPreview) {
t.Fatalf("foundation should be built in with a token preview: %+v", row)
}
}
}
}
func TestActivateUnknownThemeIsRejectedAndChangesNothing(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
srv := themeServer(t)
+5
View File
@@ -1 +1,6 @@
下一版的变更(待发布) / Changes for the next release.
- 外观新增「清白蓝」「茶绿」,支持浅色、深色及跟随系统,默认主题不变。修复默认预览串色和控件配色不一致。关联 #12909。
- Adds Clear Blue and Tea Green in light/dark and system modes, preserving the default. Fixes default-preview tint leakage and inconsistent control colours. Refs #12909.
- 动态天空主题移至「更多已安装主题」,激活的背景主题仍在首组显示。关联 #12909。
- Dynamic-sky themes move to More installed themes; the active backdrop remains in the first group. Refs #12909.