Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
431d622
feat: themes carry custom CSS, and agents can restyle T3 Code
t3dotgg Nov 12, 2026
4331bff
test(web): allow the node imports in the theme hook scan
t3dotgg Nov 12, 2026
b4ca9a2
fix: close theme CSS guard bypasses and make theme publish safe
t3dotgg Nov 12, 2026
f9c86dd
fix: theme CSS guard checks keyframes and rejects padded data URLs
t3dotgg Nov 12, 2026
5c8174a
refactor(server): the theme service owns publish-and-select
t3dotgg Nov 12, 2026
67a0aab
docs(server): the theme guide covers embedded images and rule weight
t3dotgg Nov 12, 2026
450d3b8
fix(server): theme publish reads the previous file with the guarded r…
t3dotgg Nov 12, 2026
097693a
fix(server): a theme publish cannot push another theme out
t3dotgg Nov 12, 2026
8fda50e
test(server): the displacement case uses a theme that sorts first
t3dotgg Nov 12, 2026
425e818
fix(server): theme publish does not write through a staging symlink
t3dotgg Nov 12, 2026
84a2053
feat(web): the project icon has a stable selector for themes
t3dotgg Nov 12, 2026
1effe2c
fix(server): a theme that cannot be selected leaves the old file in p…
t3dotgg Nov 12, 2026
c6cbc52
chore(server): agent context budget counts the theme tools
t3dotgg Nov 13, 2026
5c0c4a2
test(server): theme handler test input fits the Json tool params
t3dotgg Nov 13, 2026
f206405
fix(web): theme CSS with a @namespace rule applies instead of throwing
t3dotgg Nov 13, 2026
b4997ae
fix(server): selecting a published theme waits for a pending publish
t3dotgg Nov 13, 2026
3a13b97
fix(server): theme publish refuses to replace a file it cannot back up
t3dotgg Nov 13, 2026
cab98c0
fix: clients cannot set theme defaults through the settings RPC
t3dotgg Nov 14, 2026
6023b60
feat: agents can switch T3 Code between light, dark, and system mode
t3dotgg Nov 14, 2026
e89fbc2
fix(web): a set mode waits for the default theme it came with
t3dotgg Nov 14, 2026
2601ed7
fix(web): a set mode applies even when the default theme is missing
t3dotgg Nov 14, 2026
c1c34a5
refactor(server): theme failures are two tagged errors, and the servi…
t3dotgg Nov 14, 2026
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
180 changes: 179 additions & 1 deletion apps/server/src/environmentTheme.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { EnvironmentThemeFile } from "@t3tools/contracts";
import {
DEFAULT_SERVER_SETTINGS,
EnvironmentThemeFile,
ServerSettingsError,
} from "@t3tools/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
Expand All @@ -12,6 +16,7 @@ import * as Stream from "effect/Stream";

import * as ServerConfig from "./config.ts";
import * as EnvironmentTheme from "./environmentTheme.ts";
import { ServerSettingsService } from "./serverSettings.ts";
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";

const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile));
Expand All @@ -30,6 +35,9 @@ const SHARED_THEME: EnvironmentThemeFile = {
colors: { canvas: "#eff1f5", accent: "#1e66f5" },
};

/** The settings mock refuses to make this theme the default, as a managed policy can. */
const REFUSED_THEME_ID = "refused";

const withEnvironmentThemes = <A, E>(
seeds: Readonly<Record<string, string>>,
body: Effect.Effect<
Expand All @@ -41,6 +49,8 @@ const withEnvironmentThemes = <A, E>(
| Path.Path
| Scope.Scope
>,
/** Receives each theme and appearance the service makes the environment default. */
selected: Array<{ readonly theme?: string; readonly appearance?: string }> = [],
) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand All @@ -56,6 +66,27 @@ const withEnvironmentThemes = <A, E>(
Effect.provide(
EnvironmentTheme.layer.pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
Layer.provideMerge(
Layer.mock(ServerSettingsService)({
updateSettings: (patch) =>
patch.defaultTheme === REFUSED_THEME_ID
? Effect.fail(
new ServerSettingsError({
settingsPath: "settings.json",
operation: "write-managed-setting",
}),
)
: Effect.sync(() => {
selected.push({
...(patch.defaultTheme === undefined ? {} : { theme: patch.defaultTheme }),
...(patch.defaultAppearance === undefined
? {}
: { appearance: patch.defaultAppearance }),
});
return DEFAULT_SERVER_SETTINGS;
}),
}),
),
),
),
);
Expand Down Expand Up @@ -144,6 +175,62 @@ it.layer(NodeServices.layer)("environment theme", (it) => {
),
);

it.effect("puts the previous theme back when the new one cannot be selected", () =>
withEnvironmentThemes(
{ [`${REFUSED_THEME_ID}.json`]: encodeThemeFile(NIGHTFALL_THEME) },
Effect.gen(function* () {
const themes = yield* EnvironmentTheme.EnvironmentThemeService;
const refused = yield* themes
.apply({ themeId: REFUSED_THEME_ID, theme: { ...NIGHTFALL_THEME, name: "Changed" } })
.pipe(Effect.flip);
assert.strictEqual(refused._tag, "EnvironmentThemeNotSelectedError");
assert.deepEqual(yield* themes.current, [{ id: REFUSED_THEME_ID, ...NIGHTFALL_THEME }]);
}),
),
);

it.effect("does not replace a theme file it could not put back", () => {
const oversized = JSON.stringify({ ...NIGHTFALL_THEME, padding: "x".repeat(40_000) });
return withEnvironmentThemes(
{ [`${REFUSED_THEME_ID}.json`]: oversized },
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const config = yield* ServerConfig.ServerConfig;
const themes = yield* EnvironmentTheme.EnvironmentThemeService;
const refused = yield* themes
.apply({ themeId: REFUSED_THEME_ID, theme: NIGHTFALL_THEME })
.pipe(Effect.flip);
assert.match(refused.message, /could not be backed up/);
assert.strictEqual(
yield* fs.readFileString(`${config.environmentThemesDir}/${REFUSED_THEME_ID}.json`),
oversized,
);
}),
);
});

it.effect.skipIf(!symlinksSupported)("does not write through a symlink at its staging path", () =>
withEnvironmentThemes(
{},
Effect.gen(function* () {
const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const themes = yield* EnvironmentTheme.EnvironmentThemeService;
const outside = path.join(environmentThemesDir, "..", "outside.txt");
yield* fs.writeFileString(outside, "keep");
yield* fs.symlink(
outside,
path.join(environmentThemesDir, `look.json.staging-${process.pid}`),
);

yield* themes.apply({ themeId: "look", theme: NIGHTFALL_THEME });
assert.strictEqual(yield* fs.readFileString(outside), "keep");
assert.deepEqual(yield* themes.current, [{ id: "look", ...NIGHTFALL_THEME }]);
}),
),
);

it.effect.skipIf(!symlinksSupported)("ignores a symlinked theme file", () =>
withEnvironmentThemes(
{},
Expand All @@ -159,6 +246,97 @@ it.layer(NodeServices.layer)("environment theme", (it) => {
),
);

it.effect("publishes and selects a theme, and refuses unsafe CSS and unknown ids", () => {
const selected: Array<{ readonly theme?: string; readonly appearance?: string }> = [];
return withEnvironmentThemes(
{},
Effect.gen(function* () {
const themes = yield* EnvironmentTheme.EnvironmentThemeService;
// A comment may name an address. Only a rule that loads one is refused.
const css =
"/* from url(https://example.com/guide) */ [data-chat-header] { border-bottom: 1px solid red; }";
yield* themes.apply({ themeId: "agent-look", theme: { ...NIGHTFALL_THEME, css } });
assert.deepEqual(yield* themes.current, [{ id: "agent-look", ...NIGHTFALL_THEME, css }]);

const leaky = yield* themes
.apply({
themeId: "leaky",
theme: {
...NIGHTFALL_THEME,
css: 'input[value^="a"] { background: url(https://example.com/a); }',
},
})
.pipe(Effect.flip);
assert.match(leaky.message, /loads files/);
const reserved = yield* themes
.apply({ themeId: "dark", theme: NIGHTFALL_THEME })
.pipe(Effect.flip);
assert.match(reserved.message, /not a valid theme id/);
const unknown = yield* themes.apply({ themeId: "missing" }).pipe(Effect.flip);
assert.match(unknown.message, /not a built-in or published theme/);
yield* themes.apply({ themeId: "grove" });
yield* themes.apply({ themeId: "grove", appearance: "dark" });
yield* themes.apply({ appearance: "light" });

assert.deepEqual(
(yield* themes.current).map((theme) => theme.id),
["agent-look"],
);
// Only a theme that was published or already exists becomes the environment theme.
assert.deepEqual(selected, [
{ theme: "agent-look" },
{ theme: "grove" },
{ theme: "grove", appearance: "dark" },
{ appearance: "light" },
]);
}),
selected,
);
});

it.effect("keeps the served theme when an update does not fit the size limit", () => {
const large = (name: string): EnvironmentThemeFile => ({
...NIGHTFALL_THEME,
name,
css: `/* ${"x".repeat(19_900)} */`,
});
return withEnvironmentThemes(
{
...Object.fromEntries(
Array.from({ length: 9 }, (_, index) => [
`aa-${index}.json`,
encodeThemeFile(large(`Large ${index}`)),
]),
),
"zz-look.json": encodeThemeFile(NIGHTFALL_THEME),
},
Effect.gen(function* () {
const themes = yield* EnvironmentTheme.EnvironmentThemeService;
const served = Effect.map(themes.current, (all) =>
all.find((theme) => theme.id === "zz-look"),
);
assert.deepEqual(yield* served, { id: "zz-look", ...NIGHTFALL_THEME });

const refused = yield* themes
.apply({ themeId: "zz-look", theme: large("Too much") })
.pipe(Effect.flip);
assert.match(refused.message, /could not be published/);
assert.deepEqual(yield* served, { id: "zz-look", ...NIGHTFALL_THEME });

// A new theme that sorts first fits, and the last large one no longer does.
const before = (yield* themes.current).map((theme) => theme.id);
const displacing = yield* themes
.apply({ themeId: "a0-first", theme: large("Displacing") })
.pipe(Effect.flip);
assert.match(displacing.message, /could not be published/);
assert.deepEqual(
(yield* themes.current).map((theme) => theme.id),
before,
);
}),
);
});

it.effect("does not charge skipped files against the total size limit", () =>
withEnvironmentThemes(
{
Expand Down
Loading
Loading