mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
feat(theme): add per-scheme backdrop surface opacity
This commit is contained in:
1 parent
573c87ce81
commit
4a28c20aad
13 files changed
+380
-34
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shots = process.env.SURFACE_SCREENSHOTS;
|
||||
if (shots) await mkdir(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
const surfaces = ["nav", "rail", "chrome", "side", "compose"];
|
||||
const opacity = {
|
||||
dark: { nav: 0.45, rail: 0.46, chrome: 0.8, side: 0.81, compose: 0.88 },
|
||||
light: { nav: 0.6, rail: 0.61, chrome: 0.86, side: 0.87, compose: 0.89 },
|
||||
};
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) for (const width of [1600, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 1000 }, reducedMotion: "reduce" });
|
||||
await context.addInitScript((scheme) => localStorage.setItem("rx-theme", scheme), scheme);
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/themes/bench-photo/background", (route) => route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><rect width="1600" height="1000" fill="#638c99"/><circle cx="1100" cy="400" r="400" fill="#ddbf88"/></svg>' }));
|
||||
await page.goto(`${base}?pack=photo&ws=1&sess=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({ content: "* { transition: none !important; animation: none !important; caret-color: transparent !important }" });
|
||||
// Details no longer mount .side in the dock layout; phone omits .nav.
|
||||
// Exercise their actual stylesheet selectors without changing visible UI.
|
||||
await page.evaluate((surfaces) => {
|
||||
for (const name of surfaces) if (!document.querySelector(`.${name}`)) {
|
||||
const probe = document.createElement("aside");
|
||||
probe.className = name;
|
||||
probe.style.cssText = "position:fixed;left:-9999px;width:10px;height:10px";
|
||||
document.body.append(probe);
|
||||
}
|
||||
}, surfaces);
|
||||
const styles = () => page.evaluate((surfaces) => Object.fromEntries(surfaces.map((name) => {
|
||||
const style = getComputedStyle(document.querySelector(`.${name}`));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = style.backgroundColor;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [name, { alpha: ctx.getImageData(0, 0, 1, 1).data[3] / 255, background: style.backgroundColor, blur: style.backdropFilter, shadow: style.textShadow }];
|
||||
})), surfaces);
|
||||
const paint = async (surfaceOpacity, backdrop = "photo") => page.evaluate(async ({ surfaceOpacity, backdrop, scheme }) => {
|
||||
const { apply } = await import("/src/ui/theme.ts");
|
||||
const tokens = {
|
||||
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
|
||||
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
|
||||
};
|
||||
apply({ id: "bench-photo", name: "Surface fixture", tokens, background: { image: backdrop === "photo", focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4, surfaceOpacity }, ...(backdrop === "sky" ? { sky: { rayAlpha: 0.85, cloudAlpha: 0.4 } } : {}) }, scheme);
|
||||
}, { surfaceOpacity, backdrop, scheme });
|
||||
const baseline = await styles();
|
||||
await paint({});
|
||||
assert.deepEqual(await styles(), baseline, "omission keeps every computed paint exactly");
|
||||
const empty = await page.screenshot({ animations: "disabled" });
|
||||
await paint(undefined);
|
||||
assert.deepEqual(await page.screenshot({ animations: "disabled" }), empty, "omission keeps pixels exactly");
|
||||
await paint(opacity);
|
||||
const custom = await styles();
|
||||
for (const name of surfaces) {
|
||||
assert.ok(Math.abs(custom[name].alpha - opacity[scheme][name]) <= 1 / 255, `${scheme} ${name}: ${custom[name].background}`);
|
||||
assert.equal(custom[name].blur, baseline[name].blur);
|
||||
assert.equal(custom[name].shadow, baseline[name].shadow);
|
||||
}
|
||||
if (shots) await page.screenshot({ path: `${shots}/${scheme}-${width}-after.png`, animations: "disabled" });
|
||||
await paint(opacity, "sky");
|
||||
for (const [name, value] of Object.entries(await styles())) assert.ok(Math.abs(value.alpha - opacity[scheme][name]) <= 1 / 255, `sky ${name}`);
|
||||
await paint(opacity, "none");
|
||||
const withoutBackdrop = await styles();
|
||||
await paint(undefined, "none");
|
||||
assert.deepEqual(await styles(), withoutBackdrop, "data-bg off ignores surface map");
|
||||
await paint({ [scheme]: { nav: 0, compose: 1 } });
|
||||
const boundaries = await styles();
|
||||
assert.equal(boundaries.nav.alpha, 0);
|
||||
assert.equal(boundaries.compose.alpha, 1);
|
||||
for (const name of ["rail", "chrome", "side"]) assert.equal(boundaries[name].background, baseline[name].background, `omitted ${name}`);
|
||||
await paint(undefined);
|
||||
assert.deepEqual(await styles(), baseline, "switching to an old pack clears custom paint");
|
||||
await page.evaluate(async () => (await import("/src/ui/theme.ts")).apply(null, "light"));
|
||||
assert.equal(await page.locator("#pack-theme").count(), 0);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.bg), undefined);
|
||||
console.log(`ok ${scheme} ${width}: five computed alphas; exact omitted pixels; sky, backdrop removal, boundaries and pack clearing`);
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -14,6 +14,20 @@ export interface ThemeBackground {
|
||||
taskOpacity: number;
|
||||
overlayStrength: number;
|
||||
textGlow?: "default" | "none";
|
||||
surfaceOpacity?: ThemeSurfaceOpacities;
|
||||
}
|
||||
|
||||
export interface ThemeSurfaceOpacities {
|
||||
light?: ThemeSurfaceOpacity;
|
||||
dark?: ThemeSurfaceOpacity;
|
||||
}
|
||||
|
||||
export interface ThemeSurfaceOpacity {
|
||||
nav?: number;
|
||||
rail?: number;
|
||||
chrome?: number;
|
||||
side?: number;
|
||||
compose?: number;
|
||||
}
|
||||
|
||||
// A live backdrop the window draws, rather than a picture it places. Absent on
|
||||
|
||||
@@ -319,6 +319,10 @@ pack states a shape, never a number the layout has to agree with.
|
||||
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
|
||||
backing remains. Reduced effects overrides the text shadow.
|
||||
- `background.surfaceOpacity.{light,dark}` paints `--bg-{nav,rail,chrome,side,compose}-background`
|
||||
through the pack sheet, only for a backdrop. Omitted values keep existing paint.
|
||||
- Shell opacity mixes `--page`; composer opacity mixes `--raised`. Existing
|
||||
Studio scheme-specific overrides and blur remain the defaults.
|
||||
|
||||
- Stroke weight is an axis beside contrast, not part of it: however bright the
|
||||
colour, 400 Han at 11px is thin. Han strokes are dense and blur at a size
|
||||
|
||||
@@ -62,24 +62,24 @@
|
||||
:root[data-sky] .app { background: transparent }
|
||||
|
||||
:root[data-bg] .app { background: transparent }
|
||||
|
||||
.rail, .side { background: var(--page) }
|
||||
.panes { background: var(--surface) }
|
||||
|
||||
:root[data-bg] .main, :root[data-bg] .panes { background: transparent }
|
||||
|
||||
:root[data-bg] .rail, :root[data-bg] .side,
|
||||
:root[data-bg] .nav, :root[data-bg] .chrome {
|
||||
background: color-mix(in srgb, var(--page) 62%, transparent);
|
||||
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent),
|
||||
0 0 6px color-mix(in srgb, var(--page) 70%, transparent);
|
||||
}
|
||||
:root[data-bg] .nav { background: var(--bg-nav-background, color-mix(in srgb, var(--page) 62%, transparent)) }
|
||||
:root[data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 62%, transparent)) }
|
||||
:root[data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 62%, transparent)) }
|
||||
:root[data-bg] .side { background: var(--bg-side-background, color-mix(in srgb, var(--page) 62%, transparent)) }
|
||||
:root[data-bg] .flow:not([data-empty]) {
|
||||
text-shadow: var(--flow-text-shadow, 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
|
||||
0 0 7px color-mix(in srgb, var(--page) 72%, transparent));
|
||||
}
|
||||
:root[data-bg] .compose {
|
||||
background: color-mix(in srgb, var(--raised) 80%, transparent);
|
||||
background: var(--bg-compose-background, color-mix(in srgb, var(--raised) 80%, transparent));
|
||||
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
|
||||
@@ -1298,7 +1298,7 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
|
||||
/* The inspector is contextual, not a permanent dashboard. */
|
||||
.side { background: var(--page); border-left: 1px solid var(--hair); }
|
||||
.side { background: var(--bg-side-background, var(--page)); border-left: 1px solid var(--hair); }
|
||||
.side .scroll { background: var(--page); }
|
||||
.side .meter { border-color: var(--hair); }
|
||||
|
||||
@@ -1582,7 +1582,7 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
:root[data-theme="light"] .btn.send { background: var(--accent); color: var(--accent-fg); }
|
||||
:root[data-theme="light"] .btn.send:hover { background: #111210; }
|
||||
:root[data-theme="light"] .side,
|
||||
:root[data-theme="light"] .side { background: var(--bg-side-background, var(--overlay)); border-color: var(--border); }
|
||||
:root[data-theme="light"] .side .scroll { background: var(--overlay); border-color: var(--border); }
|
||||
:root[data-theme="light"] .prefs-sheet,
|
||||
:root[data-theme="light"] .prefs-hd,
|
||||
@@ -1839,9 +1839,8 @@ body { background: var(--page) !important; }
|
||||
:root[data-bg] .pane:has([data-pane="flow"] .flow[data-empty]) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
:root[data-bg] :is(.rail, .chrome) {
|
||||
background: color-mix(in srgb, var(--page) 80%, transparent) !important;
|
||||
}
|
||||
:root[data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 80%, transparent)) !important; }
|
||||
:root[data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 80%, transparent)) !important; }
|
||||
|
||||
.hero .rmark {
|
||||
filter: drop-shadow(0 0 28px var(--studio-light-primary));
|
||||
@@ -3266,9 +3265,8 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
.studio-workspace-kind { color: var(--muted); }
|
||||
.studio-user-foot .acctrow:hover,
|
||||
.studio-settings:hover { background: var(--accent-wash); color: var(--text); }
|
||||
:root[data-theme="light"][data-bg] :is(.rail, .chrome) {
|
||||
background: color-mix(in srgb, var(--page) 86%, transparent) !important;
|
||||
}
|
||||
:root[data-theme="light"][data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 86%, transparent)) !important; }
|
||||
:root[data-theme="light"][data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 86%, transparent)) !important; }
|
||||
:root[data-theme="light"][data-bg] .railfoot {
|
||||
box-shadow: 0 -1px color-mix(in srgb, var(--border) 62%, transparent);
|
||||
}
|
||||
@@ -3283,10 +3281,10 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
background: var(--accent-wash);
|
||||
color: var(--text);
|
||||
}
|
||||
.side,
|
||||
.side .scroll,
|
||||
.studio-browser,
|
||||
.studio-browser-toolbar { background: var(--surface); border-color: var(--hair); color: var(--text); }
|
||||
.side { background: var(--bg-side-background, var(--surface)); border-color: var(--hair); color: var(--text); }
|
||||
.studio-browser-address { background: var(--raised); border-color: var(--border); }
|
||||
.studio-browser-address input { color: var(--text); }
|
||||
/* A wallpaper remains visible, while cards keep enough of the selected pack's
|
||||
@@ -3297,7 +3295,7 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
-webkit-backdrop-filter: blur(11px) saturate(1.04);
|
||||
backdrop-filter: blur(11px) saturate(1.04);
|
||||
}
|
||||
:root[data-bg] .compose { background: color-mix(in srgb, var(--raised) 88%, transparent) !important; }
|
||||
:root[data-bg] .compose { background: var(--bg-compose-background, color-mix(in srgb, var(--raised) 88%, transparent)) !important; }
|
||||
:root[data-bg] .flow:not([data-empty]) { isolation: isolate; }
|
||||
:root[data-bg] .flow:not([data-empty])::before {
|
||||
content: "";
|
||||
|
||||
@@ -262,3 +262,47 @@ describe("a pack's recipes step the shape and the rhythm", () => {
|
||||
expect(read("--d-scale")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("backdrop surface opacities", () => {
|
||||
const surfaces = ["nav", "rail", "chrome", "side", "compose"] as const;
|
||||
const glass: ThemePack = {
|
||||
...illustrated,
|
||||
background: {
|
||||
...illustrated.background!,
|
||||
surfaceOpacity: {
|
||||
dark: { nav: 0, rail: 0.45, chrome: 0.8, side: 1, compose: 0.88 },
|
||||
light: { nav: 0.6, rail: 0.61, chrome: 0.86, side: 0.87, compose: 0.89 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("paints five independent percentages for the active scheme without rounding", () => {
|
||||
for (const scheme of ["light", "dark"] as const) {
|
||||
apply(glass, scheme);
|
||||
for (const surface of surfaces) {
|
||||
expect(read(`--bg-${surface}-background`)).toBe(`color-mix(in srgb, var(${surface === "compose" ? "--raised" : "--page"}) ${glass.background!.surfaceOpacity![scheme]![surface]! * 100}%, transparent)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps omitted values on stylesheet defaults and removes stale scheme values", () => {
|
||||
apply(glass, "dark");
|
||||
apply({ ...glass, background: { ...glass.background!, surfaceOpacity: { light: { nav: 0.6 } } } }, "light");
|
||||
expect(read("--bg-nav-background")).toBe("color-mix(in srgb, var(--page) 60%, transparent)");
|
||||
for (const surface of surfaces.slice(1)) expect(read(`--bg-${surface}-background`)).toBe("");
|
||||
apply(illustrated, "dark");
|
||||
for (const surface of surfaces) expect(read(`--bg-${surface}-background`)).toBe("");
|
||||
});
|
||||
|
||||
it("supports sky without an image, but changes nothing without a backdrop", () => {
|
||||
const noImage = { ...glass, background: { ...glass.background!, image: false } };
|
||||
apply({ ...noImage, sky: { rayAlpha: 0.85, cloudAlpha: 0.4 } }, "dark");
|
||||
expect(read("--bg-rail-background")).toBe("color-mix(in srgb, var(--page) 45%, transparent)");
|
||||
expect(document.documentElement.dataset.bg).toBe("on");
|
||||
apply(noImage, "dark");
|
||||
for (const surface of surfaces) expect(read(`--bg-${surface}-background`)).toBe("");
|
||||
expect(document.documentElement.dataset.bg).toBeUndefined();
|
||||
apply(null, "dark");
|
||||
expect(sheet()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -182,6 +182,13 @@ function declarations(pack: ThemePack, scheme: "light" | "dark", busy: boolean,
|
||||
|
||||
const bg = pack.background;
|
||||
if (bg?.textGlow === "none") out.push(["--flow-text-shadow", "none"]);
|
||||
if (sky || bg?.image) {
|
||||
const opacity = bg?.surfaceOpacity?.[scheme];
|
||||
for (const surface of ["nav", "rail", "chrome", "side", "compose"] as const) {
|
||||
const value = opacity?.[surface];
|
||||
if (value !== undefined) out.push([`--bg-${surface}-background`, `color-mix(in srgb, var(${surface === "compose" ? "--raised" : "--page"}) ${value * 100}%, transparent)`]);
|
||||
}
|
||||
}
|
||||
if (!bg?.image) return out;
|
||||
// The server owns freshness for this mutable URL. encodeURIComponent keeps
|
||||
// a pack id out of the CSS url() grammar.
|
||||
|
||||
@@ -145,6 +145,51 @@ The current `background` block controls `focusX`, `focusY`, `safeArea`
|
||||
Use a low task opacity so the image does not compete with a transcript.
|
||||
Check your image licence and avoid including workspace screenshots or secrets.
|
||||
|
||||
### Backdrop surface opacity
|
||||
|
||||
`background.surfaceOpacity` optionally sets each shell surface's background
|
||||
alpha over a backdrop, independently for light and dark:
|
||||
|
||||
```json
|
||||
"background": {
|
||||
"surfaceOpacity": {
|
||||
"dark": { "nav": 0.45, "rail": 0.45, "chrome": 0.80, "side": 0.80, "compose": 0.88 },
|
||||
"light": { "nav": 0.60, "rail": 0.60, "chrome": 0.86, "side": 0.86, "compose": 0.88 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Surface key | Selector | Colour mixed with transparency |
|
||||
| --- | --- | --- |
|
||||
| `nav` | `.nav` (icon navigation) | `--page` |
|
||||
| `rail` | `.rail` (sessions) | `--page` |
|
||||
| `chrome` | `.chrome` (top bar) | `--page` |
|
||||
| `side` | `.side` (legacy details) | `--page` |
|
||||
| `compose` | `.compose` (composer card) | `--raised` |
|
||||
|
||||
This takes effect only with `data-bg`, from a sky or background image.
|
||||
Blur, transcript backing and text shadows do not change.
|
||||
There is no scalar shorthand or new appearance control.
|
||||
|
||||
Omitted values retain the current stylesheet paint; older packs look unchanged.
|
||||
|
||||
| Surface | Current Studio default | Base layout fallback |
|
||||
| --- | --- | --- |
|
||||
| Navigation | 0.62 | 0.62 |
|
||||
| Sessions/top bar | 0.80 dark; 0.86 light | 0.62 |
|
||||
| Composer | 0.88 | 0.80 |
|
||||
| Legacy details | Opaque light overlay/dark surface | 0.62 |
|
||||
|
||||
The current dock does not mount the legacy `.side` selector.
|
||||
|
||||
- Numbers are clamped to 0–1; zero means fully transparent.
|
||||
- Unknown schemes/surfaces are dropped with theme warnings, also in plugin diagnostics.
|
||||
- Null means omitted. Wrong types fail manifest decoding.
|
||||
- Switching scheme/pack, clearing the pack, or removing the backdrop clears old values.
|
||||
|
||||
Check ordinary and muted text over your image in both schemes and at phone width.
|
||||
|
||||
|
||||
### Transcript text glow
|
||||
|
||||
Set `background.textGlow` to `"none"` to disable the transcript's wallpaper
|
||||
|
||||
@@ -50,6 +50,15 @@
|
||||
"taskOpacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"overlayStrength": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"textGlow": { "type": "string", "enum": ["default", "none"], "default": "default", "description": "Studio transcript text shadow when a backdrop is active; does not change the frosted backing." },
|
||||
"surfaceOpacity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Studio backdrop surface alphas, independently per scheme. Omitted values retain the existing surface paint.",
|
||||
"properties": {
|
||||
"light": { "$ref": "#/$defs/surfaceOpacity" },
|
||||
"dark": { "$ref": "#/$defs/surfaceOpacity" }
|
||||
}
|
||||
},
|
||||
"paneOpacity": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
}
|
||||
},
|
||||
@@ -62,6 +71,17 @@
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"surfaceOpacity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"nav": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"rail": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"chrome": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"side": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"compose": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
}
|
||||
},
|
||||
"hexColor": {
|
||||
"type": "string",
|
||||
"pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestPluginWarningsUsesThemeValidationWithoutRegistration(t *testing.T) {
|
||||
{name: "missing scheme", file: manifestName, raw: `{"schemaVersion":1,"tokens":{"light":{"bg":"#FFFFFF"}}}`, want: []string{"no dark tokens"}},
|
||||
{name: "no usable tokens", file: manifestName, raw: `{"schemaVersion":1,"tokens":{"light":{"sidebar":"#FFFFFF"},"dark":{"bg":"#111111"}}}`, want: []string{"no usable light tokens"}},
|
||||
{name: "partial", file: manifestName, raw: `{"schemaVersion":1,"tokens":{"light":{"bg":"#FFFFFF","fg":"not-a-colour","sidebar":"#111111"},"dark":{"bg":"#111111"}}}`, want: []string{`"fg"`, `"sidebar" is not a theme token`}},
|
||||
{name: "unknown surface", file: manifestName, raw: strings.Replace(valid, `"tokens":`, `"background":{"surfaceOpacity":{"dark":{"sidebar":0.45}}},"tokens":`, 1), want: []string{`background.surfaceOpacity.dark: "sidebar" is not a surface`}},
|
||||
{name: "other file", file: "ignored.txt", raw: "Not a theme manifest."},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSurfaceOpacityManifest(t *testing.T) {
|
||||
const base = `{"schemaVersion":1,"tokens":{"light":{"bg":"#fff"},"dark":{"bg":"#111"}},"background":{"surfaceOpacity":%s}}`
|
||||
for _, tc := range []struct{ name, value, want, warning string }{
|
||||
{"independent", `{"dark":{"nav":0,"rail":0.45,"chrome":0.8,"side":1,"compose":0.88},"light":{"nav":0.6}}`, `{"light":{"nav":0.6},"dark":{"nav":0,"rail":0.45,"chrome":0.8,"side":1,"compose":0.88}}`, ""},
|
||||
{"clamped", `{"dark":{"nav":-2,"compose":3}}`, `{"dark":{"nav":0,"compose":1}}`, ""},
|
||||
{"unknown surface", `{"dark":{"sidebar":0.4,"rail":0.5}}`, `{"dark":{"rail":0.5}}`, "background.surfaceOpacity.dark: \"sidebar\" is not a surface"},
|
||||
{"unknown scheme", `{"sepia":{"rail":0.4}}`, `null`, "background.surfaceOpacity: \"sepia\" is not a scheme"},
|
||||
{"empty", `{}`, `null`, ""},
|
||||
{"null values", `{"light":{"nav":null}}`, `null`, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pack, _, err := decodeManifest([]byte(strings.Replace(base, "%s", tc.value, 1)), "probe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := json.Marshal(pack.Background)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var bg map[string]json.RawMessage
|
||||
if err := json.Unmarshal(encoded, &bg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := string(bg["surfaceOpacity"])
|
||||
if actual == "" {
|
||||
actual = "null"
|
||||
}
|
||||
if actual != tc.want {
|
||||
t.Errorf("surfaceOpacity=%s, want %s", actual, tc.want)
|
||||
}
|
||||
if tc.warning == "" && len(pack.Warnings) != 0 {
|
||||
t.Fatalf("warnings=%v", pack.Warnings)
|
||||
}
|
||||
if tc.warning != "" && (len(pack.Warnings) != 1 || pack.Warnings[0] != tc.warning) {
|
||||
t.Fatalf("warnings=%v, want %q", pack.Warnings, tc.warning)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, value := range []string{`0.62`, `{"dark":{"nav":"0.5"}}`, `{"dark":[]}`} {
|
||||
if _, _, err := decodeManifest([]byte(strings.Replace(base, "%s", value, 1)), "probe"); err == nil || !strings.Contains(err.Error(), "surfaceOpacity") {
|
||||
t.Errorf("value %s: error=%v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
-19
@@ -76,10 +76,28 @@ type Background struct {
|
||||
// can be kept away from it: left | center | right.
|
||||
SafeArea string `json:"safeArea,omitempty"`
|
||||
// HomeOpacity applies when nothing is running, TaskOpacity while a turn is.
|
||||
HomeOpacity float64 `json:"homeOpacity"`
|
||||
TaskOpacity float64 `json:"taskOpacity"`
|
||||
OverlayStrength float64 `json:"overlayStrength"`
|
||||
TextGlow string `json:"textGlow,omitempty"`
|
||||
HomeOpacity float64 `json:"homeOpacity"`
|
||||
TaskOpacity float64 `json:"taskOpacity"`
|
||||
OverlayStrength float64 `json:"overlayStrength"`
|
||||
TextGlow string `json:"textGlow,omitempty"`
|
||||
SurfaceOpacity *SurfaceOpacities `json:"surfaceOpacity,omitempty"`
|
||||
}
|
||||
|
||||
// SurfaceOpacities names independent backdrop surface alphas for each scheme.
|
||||
// Omitted schemes and surfaces leave the frontend's existing paint unchanged.
|
||||
type SurfaceOpacities struct {
|
||||
Light *SurfaceOpacity `json:"light,omitempty"`
|
||||
Dark *SurfaceOpacity `json:"dark,omitempty"`
|
||||
}
|
||||
|
||||
// SurfaceOpacity is the alpha of each shell surface's own background.
|
||||
// Pointers distinguish a fully transparent surface from an omitted value.
|
||||
type SurfaceOpacity struct {
|
||||
Nav *float64 `json:"nav,omitempty"`
|
||||
Rail *float64 `json:"rail,omitempty"`
|
||||
Chrome *float64 `json:"chrome,omitempty"`
|
||||
Side *float64 `json:"side,omitempty"`
|
||||
Compose *float64 `json:"compose,omitempty"`
|
||||
}
|
||||
|
||||
// Sky is a live backdrop rather than a picture: drifting cloud, a sun, and a
|
||||
@@ -124,14 +142,15 @@ type manifestSky struct {
|
||||
}
|
||||
|
||||
type manifestBackground struct {
|
||||
Image string `json:"image"`
|
||||
FocusX *float64 `json:"focusX"`
|
||||
FocusY *float64 `json:"focusY"`
|
||||
SafeArea string `json:"safeArea"`
|
||||
HomeOpacity *float64 `json:"homeOpacity"`
|
||||
TaskOpacity *float64 `json:"taskOpacity"`
|
||||
OverlayStrength *float64 `json:"overlayStrength"`
|
||||
TextGlow string `json:"textGlow"`
|
||||
Image string `json:"image"`
|
||||
FocusX *float64 `json:"focusX"`
|
||||
FocusY *float64 `json:"focusY"`
|
||||
SafeArea string `json:"safeArea"`
|
||||
HomeOpacity *float64 `json:"homeOpacity"`
|
||||
TaskOpacity *float64 `json:"taskOpacity"`
|
||||
OverlayStrength *float64 `json:"overlayStrength"`
|
||||
TextGlow string `json:"textGlow"`
|
||||
SurfaceOpacity map[string]map[string]*float64 `json:"surfaceOpacity"`
|
||||
}
|
||||
|
||||
// Dir is where user-installed packs live: one directory per pack, each with a
|
||||
@@ -234,7 +253,12 @@ func decode(raw []byte, dirID string) (Pack, error) {
|
||||
if err != nil {
|
||||
return Pack{}, err
|
||||
}
|
||||
pack.Background = backgroundOf(m.Background, hasAsset(dirID, assetBackground))
|
||||
if hasAsset(dirID, assetBackground) {
|
||||
if pack.Background == nil {
|
||||
pack.Background = backgroundOf(nil, nil)
|
||||
}
|
||||
pack.Background.Image = true
|
||||
}
|
||||
pack.Sky = skyOf(m.Sky)
|
||||
pack.HasPreview = hasAsset(dirID, assetPreview)
|
||||
return pack, nil
|
||||
@@ -286,10 +310,14 @@ func decodeManifest(raw []byte, dirID string) (Pack, manifest, error) {
|
||||
bg.TextGlow = ""
|
||||
}
|
||||
}
|
||||
var background *Background
|
||||
if m.Background != nil {
|
||||
background = backgroundOf(m.Background, &warnings)
|
||||
}
|
||||
// Map iteration order would otherwise make the same pack report its
|
||||
// problems in a different order on every read.
|
||||
slices.Sort(warnings)
|
||||
pack := Pack{ID: id, Name: name, Author: strings.TrimSpace(m.Author), Description: strings.TrimSpace(m.Description), Tokens: tokens, Recipes: recipes, Warnings: warnings}
|
||||
pack := Pack{ID: id, Name: name, Author: strings.TrimSpace(m.Author), Description: strings.TrimSpace(m.Description), Tokens: tokens, Recipes: recipes, Background: background, Warnings: warnings}
|
||||
return pack, m, nil
|
||||
}
|
||||
|
||||
@@ -363,15 +391,13 @@ func skyOf(m *manifestSky) *Sky {
|
||||
// declares a focus and two opacities has said how it wants to be shown, and a
|
||||
// user dropping their own background.webp next to the manifest should get that
|
||||
// placement rather than a centred default.
|
||||
func backgroundOf(m *manifestBackground, image bool) *Background {
|
||||
if m == nil && !image {
|
||||
return nil
|
||||
}
|
||||
out := &Background{Image: image, FocusX: 0.5, FocusY: 0.5, HomeOpacity: 1, TaskOpacity: 0.2, OverlayStrength: 0.65}
|
||||
func backgroundOf(m *manifestBackground, warnings *[]string) *Background {
|
||||
out := &Background{FocusX: 0.5, FocusY: 0.5, HomeOpacity: 1, TaskOpacity: 0.2, OverlayStrength: 0.65}
|
||||
if m == nil {
|
||||
return out
|
||||
}
|
||||
out.TextGlow = m.TextGlow
|
||||
out.SurfaceOpacity = surfaceOpacitiesOf(m.SurfaceOpacity, warnings)
|
||||
if m.FocusX != nil {
|
||||
out.FocusX = clamp01(*m.FocusX)
|
||||
}
|
||||
@@ -394,6 +420,50 @@ func backgroundOf(m *manifestBackground, image bool) *Background {
|
||||
return out
|
||||
}
|
||||
|
||||
func surfaceOpacitiesOf(raw map[string]map[string]*float64, warnings *[]string) *SurfaceOpacities {
|
||||
out := &SurfaceOpacities{}
|
||||
for scheme, surfaces := range raw {
|
||||
var target **SurfaceOpacity
|
||||
switch scheme {
|
||||
case "light":
|
||||
target = &out.Light
|
||||
case "dark":
|
||||
target = &out.Dark
|
||||
default:
|
||||
*warnings = append(*warnings, fmt.Sprintf("background.surfaceOpacity: %q is not a scheme", scheme))
|
||||
continue
|
||||
}
|
||||
values := &SurfaceOpacity{}
|
||||
for name, value := range surfaces {
|
||||
var field **float64
|
||||
switch name {
|
||||
case "nav":
|
||||
field = &values.Nav
|
||||
case "rail":
|
||||
field = &values.Rail
|
||||
case "chrome":
|
||||
field = &values.Chrome
|
||||
case "side":
|
||||
field = &values.Side
|
||||
case "compose":
|
||||
field = &values.Compose
|
||||
default:
|
||||
*warnings = append(*warnings, fmt.Sprintf("background.surfaceOpacity.%s: %q is not a surface", scheme, name))
|
||||
continue
|
||||
}
|
||||
if value != nil {
|
||||
clamped := clamp01(*value)
|
||||
*field = &clamped
|
||||
*target = values
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.Light == nil && out.Dark == nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
|
||||
@@ -324,6 +324,8 @@ var mirroredWireTypes = []wireMirror{
|
||||
// reader's own size and wallpaper. A field one side cannot read is absent.
|
||||
{"internal/frontend/serve/themes.go", "themeView", tsLookFile, "ThemePack"},
|
||||
{"internal/ext/theme/theme.go", "Background", tsLookFile, "ThemeBackground"},
|
||||
{"internal/ext/theme/theme.go", "SurfaceOpacities", tsLookFile, "ThemeSurfaceOpacities"},
|
||||
{"internal/ext/theme/theme.go", "SurfaceOpacity", tsLookFile, "ThemeSurfaceOpacity"},
|
||||
{"internal/ext/theme/theme.go", "Sky", tsLookFile, "ThemeSky"},
|
||||
{"internal/ext/theme/theme.go", "Recipes", tsLookFile, "ThemeRecipes"},
|
||||
{"internal/ext/theme/install.go", "Installed", tsLookFile, "ThemeImport"},
|
||||
|
||||
Reference in new issue
Block a user