feat(theme): add per-scheme backdrop surface opacity

This commit is contained in:
Lancy committed 2026-10-27 02:39:02 +08:00
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
View File
@@ -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
+5 -5
View File
@@ -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);
}
+8 -10
View File
@@ -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();
});
});
+7
View File
@@ -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.
+45
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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"},