Skip to content

Commit d3f9e97

Browse files
authored
feat(appearance): add new-thread background effects (hardbeat920#347)
* feat: add new-thread background effects * fix(appearance): address background effect review
1 parent e0a9ab7 commit d3f9e97

9 files changed

Lines changed: 773 additions & 19 deletions

‎src-tauri/tauri.conf.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
}
2929
],
3030
"security": {
31-
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'",
32-
"devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost http://localhost:1420 ws://localhost:1420 http://127.0.0.1:1420 ws://127.0.0.1:1420; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'",
31+
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost asset: http://asset.localhost https://asset.localhost; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'",
32+
"devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost asset: http://asset.localhost https://asset.localhost http://localhost:1420 ws://localhost:1420 http://127.0.0.1:1420 ws://127.0.0.1:1420; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'",
3333
"dangerousDisableAssetCspModification": ["style-src"],
3434
"assetProtocol": {
3535
"enable": true,

‎src/features/settings/model/appearance.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
loadAccentColor,
88
loadChatBackgroundPath,
99
loadChatBackgroundScope,
10+
loadNewThreadBackgroundEffect,
1011
loadTranscriptLayout,
1112
saveChatBackgroundOpacity,
1213
saveAccentColor,
1314
saveChatBackgroundPath,
1415
saveChatBackgroundScope,
16+
saveNewThreadBackgroundEffect,
1517
saveTranscriptLayout,
1618
TRANSCRIPT_LAYOUT_DEFAULT,
1719
loadTranscriptAnchor,
@@ -27,6 +29,7 @@ import {
2729
resolveColorScheme,
2830
THEME_PREFERENCE_DEFAULT,
2931
THEME_DARK_LIGHTNESS_DEFAULT,
32+
NEW_THREAD_BACKGROUND_EFFECT_DEFAULT,
3033
} from "./appearance";
3134

3235
const KEY = "monocode.transcriptLayout";
@@ -37,6 +40,7 @@ const SHOW_EXCLUDED_FILES_KEY = "monocode.showExcludedFiles";
3740
const CHAT_BACKGROUND_PATH_KEY = "monocode.chatBackgroundPath";
3841
const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity";
3942
const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope";
43+
const NEW_THREAD_BACKGROUND_EFFECT_KEY = "monocode.newThreadBackgroundEffect";
4044
const THEME_DARK_LIGHTNESS_KEY = "monocode.themeDarkLightness";
4145

4246
function mockLocalStorage() {
@@ -157,6 +161,7 @@ describe("chat background setting", () => {
157161
localStorage.removeItem(CHAT_BACKGROUND_PATH_KEY);
158162
localStorage.removeItem(CHAT_BACKGROUND_OPACITY_KEY);
159163
localStorage.removeItem(CHAT_BACKGROUND_SCOPE_KEY);
164+
localStorage.removeItem(NEW_THREAD_BACKGROUND_EFFECT_KEY);
160165
});
161166

162167
it("stores and clears the app-owned background path", () => {
@@ -186,6 +191,26 @@ describe("chat background setting", () => {
186191
localStorage.setItem(CHAT_BACKGROUND_SCOPE_KEY, "transcript");
187192
expect(loadChatBackgroundScope()).toBe(CHAT_BACKGROUND_SCOPE_DEFAULT);
188193
});
194+
195+
it("defaults, persists, and validates the new-thread background effect", () => {
196+
expect(loadNewThreadBackgroundEffect()).toBe(
197+
NEW_THREAD_BACKGROUND_EFFECT_DEFAULT,
198+
);
199+
for (const effect of [
200+
"none",
201+
"dither",
202+
"ascii",
203+
"halftone",
204+
"scanlines",
205+
] as const) {
206+
saveNewThreadBackgroundEffect(effect);
207+
expect(loadNewThreadBackgroundEffect()).toBe(effect);
208+
}
209+
localStorage.setItem(NEW_THREAD_BACKGROUND_EFFECT_KEY, "blur");
210+
expect(loadNewThreadBackgroundEffect()).toBe(
211+
NEW_THREAD_BACKGROUND_EFFECT_DEFAULT,
212+
);
213+
});
189214
});
190215

191216
function mockSystemScheme(scheme: "dark" | "light") {

‎src/features/settings/model/appearance.ts‎

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { isHexColor } from "../../../shared/lib/colorUtils";
33
import { HAS_NATIVE_GLASS, IS_MAC } from "../../../platform/tauri/platform";
44
import { readFlag, writeFlag } from "./storageFlags";
55
import { applyUiScale, loadUiScale } from "./uiScale";
6+
import {
7+
applyPreparedNewThreadBackground,
8+
clearPreparedNewThreadBackground,
9+
} from "./newThreadBackgroundEffects";
610

711
const ACCENT_COLOR_KEY = "monocode.accentColor";
812
const THEME_HUE_KEY = "monocode.themeHue";
@@ -23,6 +27,7 @@ const CHAT_BACKGROUND_EMPTY_OPACITY_KEY = "monocode.chatBackgroundEmptyOpacity";
2327
const CHAT_BACKGROUND_SESSION_OPACITY_KEY =
2428
"monocode.chatBackgroundSessionOpacity";
2529
const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope";
30+
const NEW_THREAD_BACKGROUND_EFFECT_KEY = "monocode.newThreadBackgroundEffect";
2631
const CHANGES_VIEW_KEY = "monocode.changesView";
2732
const SHOW_EXCLUDED_FILES_KEY = "monocode.showExcludedFiles";
2833
let chatBackgroundRevision = Date.now();
@@ -35,8 +40,38 @@ export type ColorScheme = "dark" | "light";
3540
export type ThemePreference = ColorScheme | "system";
3641
export type TranscriptLayout = "full" | "chat";
3742
export type ChatBackgroundScope = "empty" | "all";
43+
export type NewThreadBackgroundEffect =
44+
"none" | "dither" | "ascii" | "halftone" | "scanlines";
3845
export type ChangesView = "list" | "tree";
3946

47+
export const NEW_THREAD_BACKGROUND_EFFECTS: readonly NewThreadBackgroundEffect[] =
48+
["none", "dither", "ascii", "halftone", "scanlines"];
49+
50+
export const NEW_THREAD_BACKGROUND_EFFECT_DEFAULT: NewThreadBackgroundEffect =
51+
"none";
52+
53+
export const NEW_THREAD_BACKGROUND_EFFECT_LABELS: Record<
54+
NewThreadBackgroundEffect,
55+
string
56+
> = {
57+
none: "None",
58+
dither: "Dither",
59+
ascii: "ASCII",
60+
halftone: "Halftone",
61+
scanlines: "Scanlines",
62+
};
63+
64+
export const NEW_THREAD_BACKGROUND_EFFECT_DESCRIPTIONS: Record<
65+
NewThreadBackgroundEffect,
66+
string
67+
> = {
68+
none: "Shows the original artwork.",
69+
dither: "Rebuilds the artwork with a dithered color palette.",
70+
ascii: "Recreates the artwork with colored characters on black.",
71+
halftone: "Recreates the artwork with colored print dots on black.",
72+
scanlines: "Adds a pronounced horizontal display-line texture.",
73+
};
74+
4075
export const THEME_PREFERENCE_DEFAULT: ThemePreference = "dark";
4176

4277
export const ACCENT_COLOR_DEFAULT = null;
@@ -324,6 +359,13 @@ export function applyThemePreference(value: ThemePreference): ColorScheme {
324359
window.dispatchEvent(
325360
new CustomEvent<ColorScheme>(SCHEME_CHANGE_EVENT, { detail: next }),
326361
);
362+
const backgroundPath = loadChatBackgroundPath();
363+
if (
364+
backgroundPath &&
365+
document.documentElement.classList.contains("has-chat-background")
366+
) {
367+
renderChatBackground(backgroundPath);
368+
}
327369
return next;
328370
}
329371

@@ -437,22 +479,77 @@ export function applyChatBackground(path: string | null) {
437479
const root = document.documentElement;
438480
root.classList.toggle("has-chat-background", !!path);
439481
if (!path) {
440-
root.style.removeProperty("--chat-background-image");
482+
clearPreparedNewThreadBackground();
441483
return null;
442484
}
443485
chatBackgroundRevision += 1;
444-
const src = chatBackgroundSrc(path);
445-
root.style.setProperty(
446-
"--chat-background-image",
447-
`url(${JSON.stringify(src)})`,
448-
);
486+
renderChatBackground(path);
449487
return path;
450488
}
451489

452490
export function chatBackgroundSrc(path: string | null): string | null {
453491
return path ? `${convertFileSrc(path)}?v=${chatBackgroundRevision}` : null;
454492
}
455493

494+
function isNewThreadBackgroundEffect(
495+
value: unknown,
496+
): value is NewThreadBackgroundEffect {
497+
return NEW_THREAD_BACKGROUND_EFFECTS.includes(
498+
value as NewThreadBackgroundEffect,
499+
);
500+
}
501+
502+
export function loadNewThreadBackgroundEffect(): NewThreadBackgroundEffect {
503+
try {
504+
const raw = localStorage.getItem(NEW_THREAD_BACKGROUND_EFFECT_KEY);
505+
return isNewThreadBackgroundEffect(raw)
506+
? raw
507+
: NEW_THREAD_BACKGROUND_EFFECT_DEFAULT;
508+
} catch {
509+
return NEW_THREAD_BACKGROUND_EFFECT_DEFAULT;
510+
}
511+
}
512+
513+
export function saveNewThreadBackgroundEffect(
514+
effect: NewThreadBackgroundEffect,
515+
) {
516+
try {
517+
localStorage.setItem(NEW_THREAD_BACKGROUND_EFFECT_KEY, effect);
518+
} catch {
519+
// private mode / quota
520+
}
521+
}
522+
523+
function renderChatBackground(
524+
path: string,
525+
effect = loadNewThreadBackgroundEffect(),
526+
) {
527+
const src = chatBackgroundSrc(path);
528+
if (!src) return;
529+
void applyPreparedNewThreadBackground(
530+
`${path}?v=${chatBackgroundRevision}`,
531+
src,
532+
effect,
533+
isLightScheme(),
534+
);
535+
}
536+
537+
export function applyNewThreadBackgroundEffect(
538+
effect: NewThreadBackgroundEffect,
539+
) {
540+
const path = loadChatBackgroundPath();
541+
if (path) renderChatBackground(path, effect);
542+
return effect;
543+
}
544+
545+
export function setNewThreadBackgroundEffect(
546+
effect: NewThreadBackgroundEffect,
547+
) {
548+
saveNewThreadBackgroundEffect(effect);
549+
applyNewThreadBackgroundEffect(effect);
550+
window.dispatchEvent(new Event(CHAT_BACKGROUND_PATH_CHANGE_EVENT));
551+
}
552+
456553
export function loadChatBackgroundOpacity(): number {
457554
return loadChatBackgroundEmptyOpacity();
458555
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// @vitest-environment happy-dom
2+
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import {
5+
applyPreparedNewThreadBackground,
6+
clearPreparedNewThreadBackground,
7+
prepareNewThreadBackgroundEffect,
8+
} from "./newThreadBackgroundEffects";
9+
10+
afterEach(() => {
11+
clearPreparedNewThreadBackground();
12+
vi.unstubAllGlobals();
13+
vi.restoreAllMocks();
14+
});
15+
16+
describe("new-thread background effects", () => {
17+
it("uses the original asset directly for None so animation is preserved", async () => {
18+
const fetch = vi.fn();
19+
const worker = vi.fn(() => {
20+
throw new Error("None must not start a worker");
21+
});
22+
vi.stubGlobal("fetch", fetch);
23+
vi.stubGlobal("Worker", worker);
24+
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
25+
callback(0);
26+
return 1;
27+
});
28+
29+
await applyPreparedNewThreadBackground(
30+
"/background.gif?v=101",
31+
"asset://localhost/background.gif?v=101",
32+
"none",
33+
false,
34+
);
35+
36+
expect(fetch).not.toHaveBeenCalled();
37+
expect(worker).not.toHaveBeenCalled();
38+
expect(
39+
document.documentElement.style.getPropertyValue(
40+
"--chat-background-image",
41+
),
42+
).toContain("background.gif?v=101");
43+
expect(document.documentElement.classList).toContain(
44+
"chat-background-effect-ready",
45+
);
46+
});
47+
48+
it("drops rejected source promises so transient failures can retry", async () => {
49+
const fetch = vi.fn().mockRejectedValue(new Error("temporary failure"));
50+
vi.stubGlobal("fetch", fetch);
51+
const sourceKey = `/background.png?v=${Date.now()}`;
52+
53+
await expect(
54+
prepareNewThreadBackgroundEffect(
55+
sourceKey,
56+
"asset://localhost/background.png",
57+
"dither",
58+
false,
59+
),
60+
).rejects.toThrow("temporary failure");
61+
await expect(
62+
prepareNewThreadBackgroundEffect(
63+
sourceKey,
64+
"asset://localhost/background.png",
65+
"dither",
66+
false,
67+
),
68+
).rejects.toThrow("temporary failure");
69+
70+
expect(fetch).toHaveBeenCalledTimes(2);
71+
});
72+
});

0 commit comments

Comments
 (0)