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.
No files matched your search
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
|
||||
@@ -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})` }],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ var Tokens = map[string]TokenKind{
|
||||
"fgStrong": TokenColour,
|
||||
"accent": TokenColour,
|
||||
"accentFg": TokenColour,
|
||||
"accentInk": TokenColour,
|
||||
"link": TokenColour,
|
||||
"brand": TokenColour,
|
||||
"halo": TokenColour,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||