Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/components/file-proposal-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function FileProposalSession({ client, runID, source, onClose }: Parameters<type
const invalid = editorState !== "ready" || bytes > 256 * 1024 ||
draft === activeSource.content || expired || recovery.changedBase || recovery.conflict;
const language = languageForPath(activeSource.path);
const monacoTheme = readPrayuTheme() === "light" ? "vs" : "vs-dark";
const monacoTheme = readPrayuTheme() === "dark" ? "vs-dark" : "vs";
useFileEditLeaveEntry({ path: activeSource.path, dirty: Boolean(recovery.draft),
busy: mutation.isPending, canSave: !invalid,
save: () => mutation.mutateAsync(recovery.captureSubmission(draft)), keep: recovery.keep,
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/file-proposal-recovery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function FileProposalRecovery({ client, runID, editID, onClose }: {
modified={recovery.proposed_content} original={recovery.original_content}
options={{ automaticLayout: true, fontFamily: 'var(--prayu-font-mono, "JetBrains Mono", Consolas, monospace)',
fontSize: 13, letterSpacing: 0, minimap: { enabled: false }, readOnly: true, renderSideBySide: true,
scrollBeyondLastLine: false }} theme={readPrayuTheme() === "light" ? "vs" : "vs-dark"} /> :
scrollBeyondLastLine: false }} theme={readPrayuTheme() === "dark" ? "vs-dark" : "vs"} /> :
<div className={`monaco-local-state${editorState === "failed" ? " inline-warning" : ""}`}
role={editorState === "failed" ? "alert" : "status"}>
{editorState === "failed" ? t("无法加载本地编辑器资源", "Local editor assets could not be loaded") : t("正在加载本地编辑器", "Loading local editor")}
Expand Down
60 changes: 57 additions & 3 deletions web/src/lib/appearance.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,74 @@
import { applyPrayuTheme, readPrayuTheme } from "./appearance";
import {
applyPrayuGlassSettings,
applyPrayuTheme,
defaultPrayuGlassSettings,
prayuGlassPresets,
readPrayuGlassSettings,
readPrayuTheme,
resetPrayuGlassSettings,
} from "./appearance";

describe("Prayu appearance", () => {
beforeEach(() => {
window.localStorage.clear();
delete document.documentElement.dataset.prayuTheme;
delete document.documentElement.dataset.prayuGlassMode;
document.documentElement.style.colorScheme = "";
document.documentElement.style.removeProperty("--prayu-glass-blur");
document.documentElement.style.removeProperty("--prayu-glass-frost");
document.documentElement.style.removeProperty("--prayu-glass-brightness-white");
document.documentElement.style.removeProperty("--prayu-glass-glow");
});

afterEach(() => {
vi.restoreAllMocks();
});

it("persists and restores the transparent glass mode", () => {
it("persists and restores the Catppuccin Latte glass mode with default Mica parameters", () => {
applyPrayuTheme("glass");

expect(document.documentElement.dataset.prayuTheme).toBe("glass");
expect(document.documentElement.style.colorScheme).toBe("dark");
expect(document.documentElement.dataset.prayuGlassMode).toBe("mica");
expect(document.documentElement.style.colorScheme).toBe("light");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-blur")).toBe("5px");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-frost")).toBe("0.4");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-brightness-white")).toBe("0");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-glow")).toBe("0");
expect(window.localStorage.getItem("prayu.theme")).toBe("glass");
expect(readPrayuTheme()).toBe("glass");
expect(readPrayuGlassSettings()).toEqual(defaultPrayuGlassSettings);
});

it("clamps, persists, and resets glass material parameters", () => {
const updated = applyPrayuGlassSettings({
mode: "compat",
blur: 48,
frost: 45,
brightness: 75,
glow: true,
});

expect(updated).toEqual({
mode: "compat",
blur: 40,
frost: 45,
brightness: 75,
glow: true,
});
expect(document.documentElement.dataset.prayuGlassMode).toBe("compat");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-blur")).toBe("40px");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-frost")).toBe("0.9");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-brightness-white")).toBe("0.5");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-glow")).toBe("1");
expect(readPrayuGlassSettings()).toEqual(updated);

applyPrayuGlassSettings(prayuGlassPresets.clear);
expect(readPrayuGlassSettings()).toMatchObject({ blur: 0, frost: 8, brightness: 50 });

const reset = resetPrayuGlassSettings();
expect(reset).toEqual(defaultPrayuGlassSettings);
expect(document.documentElement.dataset.prayuGlassMode).toBe("mica");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-blur")).toBe("5px");
});

it("keeps appearance changes non-blocking when storage is unavailable", () => {
Expand All @@ -26,6 +77,9 @@ describe("Prayu appearance", () => {
});

expect(() => applyPrayuTheme("glass")).not.toThrow();
expect(() => applyPrayuGlassSettings({ blur: 12, frost: 45 })).not.toThrow();
expect(document.documentElement.dataset.prayuTheme).toBe("glass");
expect(document.documentElement.style.getPropertyValue("--prayu-glass-blur")).toBe("12px");
});
});

111 changes: 110 additions & 1 deletion web/src/lib/appearance.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,115 @@
import { setDesktopWindowTheme } from "./desktop-window";

export type PrayuTheme = "light" | "dark" | "glass";
export type PrayuGlassMode = "mica" | "compat";
export type PrayuGlassPreset = "clear" | "standard" | "frosted";

export interface PrayuGlassSettings {
mode: PrayuGlassMode;
blur: number;
frost: number;
brightness: number;
glow: boolean;
}

const themeStorageKey = "prayu.theme";
const glassStorageKey = "prayu.glass";

export const defaultPrayuGlassSettings: Readonly<PrayuGlassSettings> = Object.freeze({
mode: "mica",
blur: 5,
frost: 20,
brightness: 50,
glow: false,
});

export const prayuGlassPresets: Readonly<
Record<PrayuGlassPreset, Readonly<Pick<PrayuGlassSettings, "blur" | "frost" | "brightness">>>
> = Object.freeze({
clear: Object.freeze({ blur: 0, frost: 8, brightness: 50 }),
standard: Object.freeze({ blur: 5, frost: 20, brightness: 50 }),
frosted: Object.freeze({ blur: 12, frost: 45, brightness: 50 }),
});

function clampStep(value: unknown, min: number, max: number, step: number, fallback: number): number {
const num = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(num)) return fallback;
const clamped = Math.min(max, Math.max(min, num));
const stepped = Math.round(clamped / step) * step;
return Number(stepped.toFixed(2));
}

export function normalizePrayuGlassSettings(
input?: Partial<PrayuGlassSettings> | null,
): PrayuGlassSettings {
const mode: PrayuGlassMode = input?.mode === "compat" ? "compat" : "mica";
const blur = clampStep(input?.blur, 0, 40, 0.5, defaultPrayuGlassSettings.blur);
const frost = clampStep(input?.frost, 0, 100, 1, defaultPrayuGlassSettings.frost);
const brightness = clampStep(
input?.brightness,
50,
100,
1,
defaultPrayuGlassSettings.brightness,
);
const glow = typeof input?.glow === "boolean" ? input.glow : defaultPrayuGlassSettings.glow;
return { mode, blur, frost, brightness, glow };
}

export function readPrayuGlassSettings(): PrayuGlassSettings {
if (typeof window === "undefined") return { ...defaultPrayuGlassSettings };
try {
const raw = window.localStorage.getItem(glassStorageKey);
if (!raw) return { ...defaultPrayuGlassSettings };
const parsed = JSON.parse(raw) as Partial<PrayuGlassSettings>;
return normalizePrayuGlassSettings(parsed);
} catch {
return { ...defaultPrayuGlassSettings };
}
}

function syncGlassVariables(settings: PrayuGlassSettings): void {
const root = document.documentElement;
root.dataset.prayuGlassMode = settings.mode;
const frostMultiplier = Number(Math.min(settings.frost / 50, 1.4).toFixed(3));
const brightnessWhite = Number(
Math.max(0, Math.min(1, (settings.brightness - 50) / 50)).toFixed(3),
);
root.style.setProperty("--prayu-glass-blur", `${settings.blur}px`);
root.style.setProperty("--prayu-glass-frost", String(frostMultiplier));
root.style.setProperty("--prayu-glass-brightness-white", String(brightnessWhite));
root.style.setProperty("--prayu-glass-glow", settings.glow ? "1" : "0");
}

export function applyPrayuGlassSettings(
next: Partial<PrayuGlassSettings>,
persist = true,
): PrayuGlassSettings {
const current = readPrayuGlassSettings();
const normalized = normalizePrayuGlassSettings({ ...current, ...next });
syncGlassVariables(normalized);
if (persist) {
try {
window.localStorage.setItem(glassStorageKey, JSON.stringify(normalized));
} catch {
// Display preferences must never block the workbench.
}
}
return normalized;
}

export function resetPrayuGlassSettings(persist = true): PrayuGlassSettings {
const normalized = { ...defaultPrayuGlassSettings };
syncGlassVariables(normalized);
if (persist) {
try {
window.localStorage.setItem(glassStorageKey, JSON.stringify(normalized));
} catch {
// Display preferences must never block the workbench.
}
}
return normalized;
}

export function readPrayuTheme(): PrayuTheme {
if (typeof window === "undefined") return "dark";
Expand All @@ -17,7 +124,8 @@ export function readPrayuTheme(): PrayuTheme {

export function applyPrayuTheme(theme: PrayuTheme, persist = true): void {
document.documentElement.dataset.prayuTheme = theme;
document.documentElement.style.colorScheme = theme === "light" ? "light" : "dark";
document.documentElement.style.colorScheme = theme === "dark" ? "dark" : "light";
syncGlassVariables(readPrayuGlassSettings());
setDesktopWindowTheme(theme);
if (!persist) return;
try {
Expand All @@ -30,3 +138,4 @@ export function applyPrayuTheme(theme: PrayuTheme, persist = true): void {
export function initializePrayuAppearance(): void {
applyPrayuTheme(readPrayuTheme(), false);
}

4 changes: 2 additions & 2 deletions web/src/lib/desktop-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ describe("desktop window controls", () => {

expect(runtime.WindowMinimise).toHaveBeenCalledTimes(1);
expect(runtime.WindowToggleMaximise).toHaveBeenCalledTimes(1);
expect(runtime.WindowSetLightTheme).toHaveBeenCalledTimes(1);
expect(runtime.WindowSetDarkTheme).toHaveBeenCalledTimes(2);
expect(runtime.WindowSetLightTheme).toHaveBeenCalledTimes(2);
expect(runtime.WindowSetDarkTheme).toHaveBeenCalledTimes(1);
expect(runtime.Quit).toHaveBeenCalledTimes(1);
});

Expand Down
7 changes: 4 additions & 3 deletions web/src/lib/desktop-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ export function closeDesktopWindow(): void {
}

export function setDesktopWindowTheme(theme: "light" | "dark" | "glass"): void {
if (theme === "light") {
window.runtime?.WindowSetLightTheme?.();
} else {
if (theme === "dark") {
window.runtime?.WindowSetDarkTheme?.();
} else {
window.runtime?.WindowSetLightTheme?.();
}
}

95 changes: 70 additions & 25 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2614,32 +2614,55 @@ body { background: transparent; }
}

[data-prayu-theme="glass"] {
--prayu-window: rgb(8 12 18 / 14%);
--prayu-titlebar: rgb(20 25 33 / 24%);
--prayu-sidebar: color-mix(in srgb, rgb(18 24 32 / 22%) 82%, #dff3f4 18%);
--prayu-panel: rgb(18 24 32 / 30%);
--dsh-glass-ground: color-mix(in srgb, #eff1f5, #ffffff calc(var(--prayu-glass-brightness-white, 0) * 100%));
--dsh-glass-ambience: calc(1 - var(--prayu-glass-brightness-white, 0));
--dsh-glass-card: color-mix(in srgb, #eff1f5 calc(52% * var(--prayu-glass-frost, 0.4)), transparent);
--dsh-glass-card-raised: color-mix(in srgb, #eff1f5 calc(64% * var(--prayu-glass-frost, 0.4)), transparent);
--dsh-glass-card-hover: color-mix(in srgb, #eff1f5 calc(76% * var(--prayu-glass-frost, 0.4)), transparent);
--dsh-glass-bubble: color-mix(in srgb, #eff1f5 calc(46% * var(--prayu-glass-frost, 0.4)), transparent);
--dsh-glass-popover: color-mix(in srgb, #eff1f5 calc(72% * var(--prayu-glass-frost, 0.4)), rgb(239 241 245 / 82%));
--dsh-glass-rim: color-mix(in srgb, #4c4f69 18%, transparent);
--dsh-glass-rim-soft: color-mix(in srgb, #4c4f69 12%, transparent);
--dsh-glass-rim-strong: color-mix(in srgb, #4c4f69 30%, transparent);
--dsh-glass-edge: inset 0 1px 0 rgb(255 255 255 / 42%);
--dsh-glass-drop: 0 10px 32px color-mix(in srgb, rgb(0 0 0 / 12%) 38%, transparent);
--dsh-glass-input-shadow: 0 1px 2px rgb(76 79 105 / 4%), 0 4px 14px rgb(76 79 105 / 6%);
--prayu-window: var(--dsh-glass-ground);
--prayu-titlebar: color-mix(in srgb, #e6e9ef calc(52% * var(--prayu-glass-frost, 0.4)), transparent);
--prayu-sidebar: var(--dsh-glass-card);
--prayu-panel: var(--dsh-glass-card);
/* Floating menus need their own readable surface above the translucent workspace. */
--prayu-panel-strong: rgb(18 24 32 / 90%);
--prayu-control: rgb(255 255 255 / 10%);
--prayu-control-hover: rgb(255 255 255 / 18%);
--prayu-panel-strong: var(--dsh-glass-popover);
--prayu-control: rgb(38 49 72 / 6%);
--prayu-control-hover: rgb(38 49 72 / 10%);
--prayu-primary-bg: #566570;
--prayu-primary-fg: #ffffff;
--prayu-primary-hover: #45535e;
--prayu-primary-border: color-mix(in srgb, #566570 42%, #4c4f69 18%);
--prayu-primary-shadow: rgb(76 79 105 / 12%);
--prayu-selected: var(--prayu-primary-bg);
--prayu-selected-ink: var(--prayu-primary-fg);
--prayu-surface-solid: #202832;
--prayu-text: #f8f9fb;
--prayu-muted: #d5dae2;
--prayu-border: rgb(255 255 255 / 17%);
--prayu-border-strong: rgb(255 255 255 / 30%);
--prayu-shadow: rgb(0 0 0 / 28%);
--prayu-focus: #f4f4f4;
--prayu-accent: #f4f4f4;
--prayu-danger: #ff9189;
--prayu-success: var(--prayu-text);
--prayu-warning: var(--prayu-text);
--prayu-diff-add: #9cd4ae;
--prayu-glass-fill: rgb(20 27 36 / 24%);
--prayu-glass-fill-strong: rgb(20 27 36 / 44%);
--prayu-glass-highlight: rgb(255 255 255 / 30%);
--prayu-glass-shadow: rgb(0 0 0 / 30%);
--prayu-surface-solid: #eff1f5;
--prayu-text: #4c4f69;
--prayu-muted: #5c5f77;
--prayu-border: var(--dsh-glass-rim-soft);
--prayu-border-strong: var(--dsh-glass-rim);
--prayu-shadow: rgb(0 0 0 / 12%);
--prayu-focus: #657682;
--prayu-accent: #566570;
--prayu-danger: #d20f39;
--prayu-danger-fill: #d20f39;
--prayu-on-danger: #ffffff;
--prayu-danger-on-dark: #f38ba8;
--prayu-success: #40a02b;
--prayu-warning: #fe640b;
--prayu-diff-add: #40a02b;
--prayu-glass-fill: var(--dsh-glass-card);
--prayu-glass-fill-strong: var(--dsh-glass-popover);
--prayu-glass-highlight: rgb(255 255 255 / 42%);
--prayu-glass-shadow: rgb(0 0 0 / 12%);
--prayu-elevation-panel: var(--dsh-glass-edge), var(--dsh-glass-drop);
--prayu-elevation-floating: var(--dsh-glass-edge), var(--dsh-glass-input-shadow);
--prayu-danger-soft: color-mix(in srgb, var(--prayu-danger) 10%, var(--prayu-surface-solid));
--prayu-diff-delete: var(--prayu-danger);
--prayu-diff-add-soft: color-mix(in srgb, var(--prayu-diff-add) 12%, var(--prayu-surface-solid));
Expand All @@ -2656,6 +2679,11 @@ body { background: transparent; }
--prayu-panel-strong: var(--prayu-surface-solid);
--prayu-glass-fill: var(--prayu-surface-solid);
--prayu-glass-fill-strong: var(--prayu-surface-solid);
--dsh-glass-card: var(--prayu-surface-solid);
--dsh-glass-card-raised: var(--prayu-surface-solid);
--dsh-glass-card-hover: var(--prayu-control-hover);
--dsh-glass-bubble: var(--prayu-control-hover);
--dsh-glass-popover: var(--prayu-surface-solid);
}
}

Expand All @@ -2665,6 +2693,9 @@ body { background: transparent; }
--prayu-border: var(--prayu-text);
--prayu-border-strong: var(--prayu-text);
--prayu-primary-border: var(--prayu-primary-fg);
--dsh-glass-rim: var(--prayu-text);
--dsh-glass-rim-soft: var(--prayu-text);
--dsh-glass-rim-strong: var(--prayu-text);
}
}

Expand Down Expand Up @@ -2708,6 +2739,17 @@ body { background: transparent; }
--prayu-glass-highlight: transparent;
--prayu-shadow: transparent;
--prayu-glass-shadow: transparent;
--dsh-glass-card: Canvas;
--dsh-glass-card-raised: Canvas;
--dsh-glass-card-hover: Canvas;
--dsh-glass-bubble: Canvas;
--dsh-glass-popover: Canvas;
--dsh-glass-rim: CanvasText;
--dsh-glass-rim-soft: CanvasText;
--dsh-glass-rim-strong: CanvasText;
--dsh-glass-edge: none;
--dsh-glass-drop: none;
--dsh-glass-input-shadow: none;
--prayu-terminal-bg: Canvas;
--prayu-terminal-control: Canvas;
--prayu-terminal-control-hover: Canvas;
Expand Down Expand Up @@ -2774,10 +2816,13 @@ summary:focus-visible {
}

[data-prayu-theme="dark"] .prayu-shell .main-workspace,
[data-prayu-theme="dark"] .prayu-shell .main-workspace.prayu-conversation-panel,
[data-prayu-theme="dark"] .prayu-shell .main-workspace.prayu-conversation-panel {
backdrop-filter: blur(46px);
}

[data-prayu-theme="glass"] .prayu-shell .main-workspace,
[data-prayu-theme="glass"] .prayu-shell .main-workspace.prayu-conversation-panel {
backdrop-filter: blur(46px);
backdrop-filter: blur(var(--prayu-glass-blur, 5px));
}

.prayu-shell .main-workspace,
Expand Down
Loading
Loading