Merge commit 'refs/pr12660' into takeover/12660

# Conflicts:
#	desktop/frontend-next/src/port/look.ts
#	desktop/frontend-next/src/styles/TOKENS.md
#	internal/ext/theme/theme.go
This commit is contained in:
yhh committed 2026-10-12 09:25:15 -07:00
commit 3bce33bff3
13 files changed
+348 -5

No files matched your search

+1
View File
@@ -61,6 +61,7 @@ node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
node perf/effects.mjs # Effects guard: a sky pack idles quietly, a hidden window draws no sky, Reduced removes blur, sky and grain
node perf/text-glow.mjs # Transcript glow, pack switching and prose contrast across schemes, wallpapers, widths, effects and motion
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
+5 -1
View File
@@ -44,6 +44,7 @@ let peakInFlight = 0;
// ?pack=sky opens on a pack with the live sky; ?pack=photo on one with a
// picture. Both are the states where the shell goes translucent over a backdrop.
const PACK = new URLSearchParams(location.search).get("pack");
const GLOW = new URLSearchParams(location.search).get("glow");
function benchPack(): ThemePack | null {
const tokens = {
@@ -59,7 +60,10 @@ function benchPack(): ThemePack | null {
if (PACK === "photo") {
return {
id: "bench-photo", name: "Bench photo", active: true, tokens,
background: { image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4 },
background: {
image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4,
...(GLOW === "none" || GLOW === "default" ? { textGlow: GLOW } : {}),
},
};
}
return null;
+132
View File
@@ -0,0 +1,132 @@
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 before = process.env.GLOW_BEFORE === "1";
const shots = process.env.GLOW_SCREENSHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch();
let checked = 0;
const errors = [];
async function open(scheme, paper, width, effects, motion, glow) {
const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 900 }, reducedMotion: motion });
await context.addInitScript(({ scheme, effects }) => {
localStorage.setItem("rx-theme", scheme);
localStorage.setItem("rx-effects", effects);
}, { scheme, effects });
const page = await context.newPage();
page.on("pageerror", (e) => errors.push(e.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="${paper === "bright" ? "#fff9e5" : "#081329"}"/><circle cx="1250" cy="300" r="280" fill="${paper === "bright" ? "#eebc7c" : "#253d69"}"/></svg>`,
}));
await page.goto(`${BASE}?pack=photo&glow=${glow}&turns=2&ws=1&sess=2`, { waitUntil: "networkidle" });
await page.waitForSelector('.flow:not([data-empty])');
await page.evaluate(() => document.fonts.ready);
await page.waitForFunction(({ before, effects }) => {
const value = getComputedStyle(document.querySelector('.flow:not([data-empty])')).textShadow;
return before && effects === "full" ? value.includes("7px") : value === "none";
}, { before, effects });
return { context, page };
}
async function styles(page) {
return page.locator('.flow:not([data-empty])').first().evaluate((el) => {
const own = getComputedStyle(el);
const backing = getComputedStyle(el, "::before");
return {
shadow: own.textShadow,
blur: backing.backdropFilter || backing.webkitBackdropFilter,
backing: backing.backgroundColor,
content: backing.content,
frameShadow: getComputedStyle(document.querySelector(".rail")).textShadow,
};
});
}
try {
for (const scheme of ["light", "dark"]) {
for (const width of [1440, 390]) {
for (const paper of before ? [scheme === "light" ? "bright" : "dark"] : ["bright", "dark"]) {
for (const effects of before ? ["full"] : ["full", "reduced"]) {
for (const motion of before ? ["no-preference"] : ["no-preference", "reduce"]) {
const { context, page } = await open(scheme, paper, width, effects, motion, "none");
const off = await styles(page);
if (before) assert.notEqual(off.shadow, "none");
else assert.equal(off.shadow, "none");
assert.notEqual(off.content, "none");
if (effects === "full") {
assert.match(off.blur, /blur\(11px\)/);
assert.notEqual(off.frameShadow, "none");
} else {
assert.equal(off.frameShadow, "none");
}
if (shots && paper === (scheme === "light" ? "bright" : "dark") && effects === "full" && motion === "no-preference") {
await page.screenshot({ path: `${shots}/${scheme}-${width === 390 ? "phone" : "wide"}-${before ? "before" : "after"}.jpg`, type: "jpeg", quality: 88 });
}
if (!before) {
const samples = await page.locator('.flow p').evaluateAll((els) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
return els.flatMap((el) => {
const r = el.getBoundingClientRect();
if (r.width < 10 || r.height < 10 || r.top < 0 || r.bottom > innerHeight) return [];
if (!el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return [];
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (!hit || !el.contains(hit)) return [];
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = getComputedStyle(el).color;
ctx.fillRect(0, 0, 1, 1);
return [{ x: Math.floor(r.left + r.width / 2), y: Math.floor(r.top + r.height / 2), text: el.textContent, fg: [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3) }];
});
});
assert.ok(samples.length, "visible transcript prose must be measured");
const hiding = await page.addStyleTag({ content: ".flow * { color: transparent !important; text-shadow: none !important }" });
const blank = (await page.screenshot()).toString("base64");
const ratios = await page.evaluate(async ({ blank, samples }) => {
const img = new Image();
img.src = `data:image/png;base64,${blank}`;
await img.decode();
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
ctx.canvas.width = img.width; ctx.canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const lum = (c) => c.map((v) => v / 255).map((v) => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0);
return samples.map(({ x, y, fg }) => {
const bg = [...ctx.getImageData(x, y, 1, 1).data].slice(0, 3);
const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a);
return (hi + .05) / (lo + .05);
});
}, { blank, samples });
await hiding.evaluate((el) => el.remove());
assert.ok(Math.min(...ratios) >= 4.5, `prose contrast ${Math.min(...ratios).toFixed(2)}:1 ${JSON.stringify(samples)}`);
await page.evaluate(async () => {
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" },
};
const background = { image: true, focusX: .5, focusY: .5, homeOpacity: .9, taskOpacity: .5, overlayStrength: .4 };
apply({ id: "bench-photo", name: "Old pack", tokens, background }, document.documentElement.dataset.theme);
});
if (effects === "full") await page.waitForFunction(() => getComputedStyle(document.querySelector('.flow:not([data-empty])')).textShadow.includes("7px"));
const legacy = await styles(page);
assert.equal(legacy.backing, off.backing);
assert.equal(legacy.blur, off.blur);
if (effects === "full") assert.match(legacy.shadow, /7px/);
else assert.equal(legacy.shadow, "none");
checked += 2;
console.log(`ok ${scheme} ${width} ${paper} ${effects} ${motion}; prose min ${Math.min(...ratios).toFixed(2)}:1`);
}
await context.close();
}
}
}
}
}
assert.deepEqual(errors, []);
console.log(before ? "4 baseline views captured" : `${checked} glow/default scenarios passed; no page errors`);
} finally {
await browser.close();
}
+2 -1
View File
@@ -13,6 +13,7 @@ export interface ThemeBackground {
homeOpacity: number;
taskOpacity: number;
overlayStrength: number;
textGlow?: "default" | "none";
}
// A live backdrop the window draws, rather than a picture it places. Absent on
@@ -36,7 +37,7 @@ export interface ThemePack {
sky?: ThemeSky;
hasPreview?: boolean;
removable?: boolean;
// Tokens the kernel dropped and why: an unknown name, or a value that could
// Values the kernel dropped and why: an unknown name, or a value that could
// not be let into a stylesheet. The pack still loads without them.
warnings?: string[];
}
@@ -244,6 +244,11 @@ raised with its ratios kept.
- Default 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`.
- `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
backing remains. Reduced effects overrides the text shadow.
- 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
Latin still holds, so the Chinese interface starts at medium.
+2 -2
View File
@@ -82,8 +82,8 @@
}
:root[data-bg] .flow:not([data-empty]) {
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
0 0 7px color-mix(in srgb, var(--page) 72%, transparent);
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);
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { apply } from "./theme";
import type { ThemePack } from "../port/port";
const pack: ThemePack = {
id: "glow-probe", name: "Glow probe",
tokens: { light: { bg: "#ffffff", fg: "#111111" }, dark: { bg: "#111111", fg: "#eeeeee" } },
background: { image: true, focusX: .5, focusY: .5, homeOpacity: 1, taskOpacity: .2, overlayStrength: .65 },
};
const withGlow = (textGlow: unknown) => ({ ...pack, background: { ...pack.background, textGlow } }) as ThemePack;
const read = () => {
const sheet = (document.getElementById("pack-theme") as HTMLStyleElement | null)?.sheet;
return (sheet?.cssRules[0] as CSSStyleRule | undefined)?.style.getPropertyValue("--flow-text-shadow") ?? "";
};
afterEach(() => apply(null, "light"));
describe("wallpaper transcript glow", () => {
it.each(["light", "dark"] as const)("turns off only the transcript glow in %s", (scheme) => {
apply(withGlow("none"), scheme);
expect(read()).toBe("none");
expect(document.documentElement.dataset.bg).toBe("on");
});
it.each([undefined, "default", "bright", "none; color: red", false, null])("keeps the stylesheet default for %s", (value) => {
apply(withGlow("none"), "light");
apply(withGlow(value), "light");
expect(read()).toBe("");
});
it("cleans up when switching to an old pack, a solid pack, or default", () => {
for (const next of [{ ...pack, id: "legacy" }, { ...pack, id: "solid", background: undefined }, null]) {
apply(withGlow("none"), "dark");
expect(read()).toBe("none");
apply(next, "light");
expect(read()).toBe("");
}
});
it("retains the choice across scheme and running-state changes", () => {
apply(withGlow("none"), "light", false);
apply(withGlow("none"), "dark", true);
expect(read()).toBe("none");
});
});
+1
View File
@@ -161,6 +161,7 @@ function declarations(pack: ThemePack, scheme: "light" | "dark", busy: boolean,
}
const bg = pack.background;
if (bg?.textGlow === "none") out.push(["--flow-text-shadow", "none"]);
if (!bg?.image) return out;
// The server owns freshness for this mutable URL. encodeURIComponent keeps
// a pack id out of the CSS url() grammar.
+27
View File
@@ -115,6 +115,33 @@ 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.
### Transcript text glow
Set `background.textGlow` to `"none"` to disable the transcript's wallpaper
text shadow. Omit it, or use `"default"`, to retain the two existing shadows
(1px offset with 2px blur, plus a 7px halo). For example:
```json
"background": { "textGlow": "none", "taskOpacity": 0.2 }
```
This choice applies in both schemes whenever a backdrop is active, including
a sky or the reader's own wallpaper. It also works without a packaged image.
The composer, navigation shadows and frosted transcript backing retain their
own treatments. Reduced effects still overrides text shadows;
reduced motion changes animation only.
Switching or clearing a pack
removes its glow choice, so older packs retain the default.
Only `"default"` and `"none"` are accepted. An unknown string is dropped with
a theme warning. Null behaves as omitted; booleans, numbers, arrays and objects
fail manifest decoding. CSS values are not accepted.
Check ordinary and muted text over bright and dark images in
both schemes and at phone width before sharing a pack.
## Disable and clean up
```bash
+1
View File
@@ -49,6 +49,7 @@
"homeOpacity": { "type": "number", "minimum": 0, "maximum": 1 },
"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." },
"paneOpacity": { "type": "number", "minimum": 0, "maximum": 1 }
}
},
+60
View File
@@ -0,0 +1,60 @@
package theme
import (
"encoding/json"
"strings"
"testing"
)
func TestTextGlowSurvivesThemeLoading(t *testing.T) {
for _, tc := range []struct {
name, field, want string
warnings int
}{
{"legacy", ``, "", 0},
{"null", `"textGlow":null`, "", 0},
{"default", `"textGlow":"default"`, "default", 0},
{"disabled", `"textGlow":"none"`, "none", 0},
{"unknown", `"textGlow":"bright"`, "", 1},
{"css", `"textGlow":"none; color: red"`, "", 1},
} {
t.Run(tc.name, func(t *testing.T) {
raw := strings.TrimSuffix(minimal, "}") + `,"background":{` + tc.field + `}}`
pack, err := decode([]byte(raw), "glow-probe")
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(pack.Background)
if err != nil {
t.Fatal(err)
}
var bg map[string]any
if err := json.Unmarshal(body, &bg); err != nil {
t.Fatal(err)
}
got, _ := bg["textGlow"].(string)
if got != tc.want || len(pack.Warnings) != tc.warnings {
t.Fatalf("background=%s warnings=%v, want textGlow=%q and %d warnings", body, pack.Warnings, tc.want, tc.warnings)
}
if tc.warnings > 0 && !strings.Contains(pack.Warnings[0], "textGlow") {
t.Fatalf("warning does not identify textGlow: %v", pack.Warnings)
}
})
}
}
func TestTextGlowWithoutBackgroundStaysAbsent(t *testing.T) {
pack, err := decode([]byte(minimal), "glow-probe")
if err != nil || pack.Background != nil || len(pack.Warnings) != 0 {
t.Fatalf("pack=%+v err=%v", pack, err)
}
}
func TestTextGlowWrongTypeRefusesManifest(t *testing.T) {
for _, value := range []string{`false`, `7`, `{}`, `[]`} {
raw := strings.TrimSuffix(minimal, "}") + `,"background":{"textGlow":` + value + `}}`
if _, err := decode([]byte(raw), "glow-probe"); err == nil {
t.Fatalf("accepted non-string textGlow %s", value)
}
}
}
+12 -1
View File
@@ -47,7 +47,7 @@ type Pack struct {
// Removable is true for a pack that lives in the user's own library, which
// is the only place Remove reaches.
Removable bool `json:"removable,omitempty"`
// Warnings names the tokens that were dropped and why. The pack still
// Warnings names the values that were dropped and why. The pack still
// loads: one bad value must not cost the author every good one.
Warnings []string `json:"warnings,omitempty"`
}
@@ -70,6 +70,7 @@ type Background struct {
HomeOpacity float64 `json:"homeOpacity"`
TaskOpacity float64 `json:"taskOpacity"`
OverlayStrength float64 `json:"overlayStrength"`
TextGlow string `json:"textGlow,omitempty"`
}
// Sky is a live backdrop rather than a picture: drifting cloud, a sun, and a
@@ -115,6 +116,7 @@ type manifestBackground struct {
HomeOpacity *float64 `json:"homeOpacity"`
TaskOpacity *float64 `json:"taskOpacity"`
OverlayStrength *float64 `json:"overlayStrength"`
TextGlow string `json:"textGlow"`
}
// Dir is where user-installed packs live: one directory per pack, each with a
@@ -260,6 +262,14 @@ func decodeManifest(raw []byte, dirID string) (Pack, manifest, error) {
}
tokens[scheme] = copied
}
if bg := m.Background; bg != nil {
switch bg.TextGlow {
case "", "default", "none":
default:
warnings = append(warnings, fmt.Sprintf("background: %q is not a valid textGlow value", bg.TextGlow))
bg.TextGlow = ""
}
}
// Map iteration order would otherwise make the same pack report its
// problems in a different order on every read.
slices.Sort(warnings)
@@ -309,6 +319,7 @@ func backgroundOf(m *manifestBackground, image bool) *Background {
if m == nil {
return out
}
out.TextGlow = m.TextGlow
if m.FocusX != nil {
out.FocusX = clamp01(*m.FocusX)
}
@@ -0,0 +1,55 @@
package serve
import (
"encoding/base64"
"encoding/json"
"net/http"
"testing"
"reasonix/internal/base/testenv"
)
func TestThemeGlowImportListAndReload(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
srv := themeServer(t)
const raw = `{"schemaVersion":1,"id":"glow-probe","name":"Glow probe","tokens":{"light":{"bg":"#fff"},"dark":{"bg":"#111"}},"background":{"textGlow":"none"}}`
response := postJSON(t, srv.URL+"/themes/import", map[string]any{
"files": map[string]string{"theme.json": base64.StdEncoding.EncodeToString([]byte(raw))},
})
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("import status=%d", response.StatusCode)
}
var imported struct {
Pack struct {
Background struct {
TextGlow string `json:"textGlow"`
} `json:"background"`
} `json:"pack"`
}
if err := json.NewDecoder(response.Body).Decode(&imported); err != nil {
t.Fatal(err)
}
if imported.Pack.Background.TextGlow != "none" {
t.Fatalf("import lost glow: %+v", imported)
}
activation := postJSON(t, srv.URL+"/themes", map[string]string{"id": "glow-probe"})
activation.Body.Close()
if activation.StatusCode != http.StatusNoContent {
t.Fatalf("activate status=%d", activation.StatusCode)
}
for _, base := range []string{srv.URL, themeServer(t).URL} {
row := find(t, listThemes(t, base), "glow-probe")
body, err := json.Marshal(row.Background)
if err != nil {
t.Fatal(err)
}
var bg map[string]any
if err := json.Unmarshal(body, &bg); err != nil {
t.Fatal(err)
}
if !row.Active || bg["textGlow"] != "none" {
t.Fatalf("listing lost active glow after load: row=%+v background=%s", row, body)
}
}
}