authorgravatar for ryan@heyoodle.comRyan Hughes <ryan@heyoodle.com> 2026-08-29 01:46:09-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 22:46:09-07:00
logc1c2d5401de5a352cf6722959de076c3da63d233
tree43aa46d19c39dd08de530dea0751d9a5ab53e63a
parentbe218ac768d809087bfcea4d8f15c431f0d70e16
signature Signed by PGP key B5690EEEBB952194

feat: let an environment publish themes as a file (#8569)

Co-authored-by: Theo Browne <me@t3.gg>

32 files changed, 3121 insertions(+), 76 deletions(-)

apps/server/src/bin.ts+2
...@@ -17,6 +17,7 @@ import { projectCommand } from "./cli/project.ts";...@@ -17,6 +17,7 @@ import { projectCommand } from "./cli/project.ts";
17import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";17import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
18import { serviceCommand } from "./cli/service.ts";18import { serviceCommand } from "./cli/service.ts";
19import { servicePreflightCommand } from "./cli/servicePreflight.ts";19import { servicePreflightCommand } from "./cli/servicePreflight.ts";
20import { themeCommand } from "./cli/theme.ts";
20import { triageCommand } from "./cli/triage.ts";21import { triageCommand } from "./cli/triage.ts";
2122
22const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);23const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
...@@ -57,6 +58,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>...@@ -57,6 +58,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
57 projectCommand,58 projectCommand,
58 serviceCommand,59 serviceCommand,
59 servicePreflightCommand,60 servicePreflightCommand,
61 themeCommand,
60 triageCommand,62 triageCommand,
61 cloudEnabled ? connectCommand : connectUnavailableCommand,63 cloudEnabled ? connectCommand : connectUnavailableCommand,
62 ]),64 ]),
apps/server/src/cli/theme.test.ts created+478
...@@ -0,0 +1,478 @@
1// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary.
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as ConfigProvider from "effect/ConfigProvider";
8import * as NetService from "@t3tools/shared/Net";
9import { assert, describe, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as TestConsole from "effect/testing/TestConsole";
13import { Command } from "effect/unstable/cli";
14
15import { cli } from "../bin.ts";
16
17const runCli = (args: ReadonlyArray<string>) =>
18 Command.runWith(cli, { version: "0.0.0" })(args).pipe(
19 Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer, TestConsole.layer)),
20 );
21
22const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-theme-cli-"));
23
24const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json");
25
26const NIGHTFALL_THEME_JSON = `${JSON.stringify({
27 name: "Nightfall",
28 appearance: "dark",
29 canvas: "#1a1b26",
30 accent: "#7aa2f7",
31})}\n`;
32const JUNK_THEME_JSON = `${JSON.stringify({ name: "Junk" })}\n`;
33
34const readSettings = (baseDir: string): Record<string, unknown> => {
35 const raw = NodeFS.readFileSync(settingsPathFor(baseDir), "utf8");
36 return JSON.parse(raw) as Record<string, unknown>;
37};
38
39const writeSettings = (baseDir: string, settings: Record<string, unknown>) => {
40 NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true });
41 NodeFS.writeFileSync(settingsPathFor(baseDir), `${JSON.stringify(settings, null, 2)}\n`);
42};
43
44describe("t3 theme", () => {
45 it.effect("writes a default theme when no settings file exists yet", () =>
46 Effect.gen(function* () {
47 const baseDir = makeBaseDir();
48 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
49 assert.equal(readSettings(baseDir).defaultTheme, "ocean");
50 }),
51 );
52
53 // A provisioning command runs against settings written by whatever version
54 // happens to be installed, so it must not drop what it cannot interpret.
55 it.effect("preserves settings it does not recognise", () =>
56 Effect.gen(function* () {
57 const baseDir = makeBaseDir();
58 writeSettings(baseDir, {
59 enableProviderUpdateChecks: false,
60 somethingFromANewerBuild: { nested: true },
61 });
62
63 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
64
65 const settings = readSettings(baseDir);
66 assert.equal(settings.defaultTheme, "ocean");
67 assert.equal(settings.enableProviderUpdateChecks, false);
68 assert.deepEqual(settings.somethingFromANewerBuild, { nested: true });
69 }),
70 );
71
72 it.effect("clears the default back to leaving fresh clients alone", () =>
73 Effect.gen(function* () {
74 const baseDir = makeBaseDir();
75 writeSettings(baseDir, { enableProviderUpdateChecks: false });
76
77 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
78 yield* runCli(["theme", "clear", "--base-dir", baseDir]);
79
80 const settings = readSettings(baseDir);
81 assert.equal(Object.hasOwn(settings, "defaultTheme"), false);
82 assert.equal(settings.enableProviderUpdateChecks, false);
83 }),
84 );
85
86 // Publishing a file and pointing at it are one step, so an integration
87 // (a desktop's theme hook) needs no knowledge of the themes directory.
88 it.effect("publishes a theme file under its filename and sets it", () =>
89 Effect.gen(function* () {
90 const baseDir = makeBaseDir();
91 const themeFile = NodePath.join(baseDir, "nightfall.json");
92 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
93
94 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]);
95
96 const published = NodePath.join(baseDir, "userdata", "themes", "nightfall.json");
97 assert.equal(NodeFS.existsSync(published), true);
98 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
99 // No rollback or staging residue after a successful set.
100 const residue = NodeFS.readdirSync(NodePath.dirname(published)).filter(
101 (entry) => !entry.endsWith(".json"),
102 );
103 assert.deepEqual(residue, []);
104 }),
105 );
106
107 it.effect("publishes a theme file under an explicit id", () =>
108 Effect.gen(function* () {
109 const baseDir = makeBaseDir();
110 const themeFile = NodePath.join(baseDir, "t3code.json");
111 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
112
113 yield* runCli(["theme", "set", "--id", "nightfall", themeFile, "--base-dir", baseDir]);
114
115 assert.equal(
116 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")),
117 true,
118 );
119 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
120 }),
121 );
122
123 it.effect("rejects a file that is not a theme and sets nothing", () =>
124 Effect.gen(function* () {
125 const baseDir = makeBaseDir();
126 const themeFile = NodePath.join(baseDir, "junk.json");
127 NodeFS.writeFileSync(themeFile, JUNK_THEME_JSON);
128
129 const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(
130 Effect.flip,
131 );
132
133 assert.include(String(failure), "not a valid theme file");
134 assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false);
135 assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false);
136 }),
137 );
138
139 // Publish and set are one command, so a settings file the set step cannot
140 // use must fail it before the themes directory is mutated -- not after,
141 // with a half-applied publish left behind.
142 it.effect("publishes nothing when the settings file cannot be used", () =>
143 Effect.gen(function* () {
144 const baseDir = makeBaseDir();
145 NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true });
146 NodeFS.writeFileSync(settingsPathFor(baseDir), "{ not json");
147 const themeFile = NodePath.join(baseDir, "nightfall.json");
148 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
149
150 const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(
151 Effect.flip,
152 );
153
154 assert.include(String(failure), "not a JSON object");
155 assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false);
156 }),
157 );
158
159 // set means set: a publish that rode along with a failed default write is
160 // rolled back rather than left mutating the environment's theme set. The
161 // userdata directory is made read-only while themes stays writable, so the
162 // failure lands after the publish -- the case the rollback exists for.
163 it.effect("rolls back a publish when the default cannot be written", () =>
164 Effect.gen(function* () {
165 const baseDir = makeBaseDir();
166 writeSettings(baseDir, {});
167 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
168 const themesDir = NodePath.join(userdataDir, "themes");
169 NodeFS.mkdirSync(themesDir, { recursive: true });
170 const themeFile = NodePath.join(baseDir, "nightfall.json");
171 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
172
173 NodeFS.chmodSync(userdataDir, 0o555);
174 try {
175 const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(
176 Effect.flip,
177 );
178 assert.include(String(failure), "Could not write");
179 assert.equal(NodeFS.existsSync(NodePath.join(themesDir, "nightfall.json")), false);
180 } finally {
181 NodeFS.chmodSync(userdataDir, 0o755);
182 }
183 }),
184 );
185
186 // A symlink is a normal way to hand this command a theme -- desktop hooks
187 // symlink the current palette -- so the source is resolved, not refused.
188 it.effect("publishes a theme file through a symlinked source path", () =>
189 Effect.gen(function* () {
190 const baseDir = makeBaseDir();
191 const realFile = NodePath.join(baseDir, "real-nightfall.json");
192 NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON);
193 const linkPath = NodePath.join(baseDir, "nightfall.json");
194 NodeFS.symlinkSync(realFile, linkPath);
195
196 yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]);
197
198 assert.equal(
199 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")),
200 true,
201 );
202 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
203 }),
204 );
205
206 // The staging entry is created fresh with O_EXCL, so a symlink planted at
207 // its predictable name is cleared, never followed and written through.
208 it.effect("never writes through a symlink at the staging path", () =>
209 Effect.gen(function* () {
210 const baseDir = makeBaseDir();
211 const themesDir = NodePath.join(baseDir, "userdata", "themes");
212 NodeFS.mkdirSync(themesDir, { recursive: true });
213 const victim = NodePath.join(baseDir, "victim.txt");
214 NodeFS.writeFileSync(victim, "precious");
215 NodeFS.symlinkSync(victim, NodePath.join(themesDir, `nightfall.json.staging-${process.pid}`));
216 const themeFile = NodePath.join(baseDir, "nightfall.json");
217 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
218
219 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]);
220
221 assert.equal(NodeFS.readFileSync(victim, "utf8"), "precious");
222 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
223 }),
224 );
225
226 // Rollback moves the previous directory entry aside and back, so even an
227 // entry the watcher would never publish -- here a symlink -- comes back
228 // exactly as it was when the set fails.
229 it.effect("restores a non-theme destination entry when the set fails", () =>
230 Effect.gen(function* () {
231 const baseDir = makeBaseDir();
232 writeSettings(baseDir, {});
233 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
234 const themesDir = NodePath.join(userdataDir, "themes");
235 NodeFS.mkdirSync(themesDir, { recursive: true });
236 const outside = NodePath.join(baseDir, "outside.json");
237 NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON);
238 const destination = NodePath.join(themesDir, "nightfall.json");
239 NodeFS.symlinkSync(outside, destination);
240 const themeFile = NodePath.join(baseDir, "nightfall.json");
241 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
242
243 NodeFS.chmodSync(userdataDir, 0o555);
244 try {
245 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip);
246 assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true);
247 } finally {
248 NodeFS.chmodSync(userdataDir, 0o755);
249 }
250 }),
251 );
252
253 it.effect("restores the previous theme when a re-publish fails to set", () =>
254 Effect.gen(function* () {
255 const baseDir = makeBaseDir();
256 writeSettings(baseDir, {});
257 const userdataDir = NodePath.dirname(settingsPathFor(baseDir));
258 const themesDir = NodePath.join(userdataDir, "themes");
259 NodeFS.mkdirSync(themesDir, { recursive: true });
260 const publishedPath = NodePath.join(themesDir, "nightfall.json");
261 const previous =
262 '{ "name": "Old Nightfall", "appearance": "dark", "canvas": "#000000", "accent": "#ffffff" }\n';
263 NodeFS.writeFileSync(publishedPath, previous);
264 const themeFile = NodePath.join(baseDir, "nightfall.json");
265 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
266
267 NodeFS.chmodSync(userdataDir, 0o555);
268 try {
269 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip);
270 assert.equal(NodeFS.readFileSync(publishedPath, "utf8"), previous);
271 } finally {
272 NodeFS.chmodSync(userdataDir, 0o755);
273 }
274 }),
275 );
276
277 // A typo'd id written as the theme would silently never resolve anywhere;
278 // the id branch is as strict as the filename rule.
279 it.effect("rejects an id no client could resolve", () =>
280 Effect.gen(function* () {
281 const baseDir = makeBaseDir();
282 const failure = yield* runCli(["theme", "set", "Nightfall", "--base-dir", baseDir]).pipe(
283 Effect.flip,
284 );
285 assert.include(String(failure), "not a valid theme id");
286 assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false);
287 }),
288 );
289
290 it.effect("rejects a path that does not exist instead of storing it as an id", () =>
291 Effect.gen(function* () {
292 const baseDir = makeBaseDir();
293 const failure = yield* runCli([
294 "theme",
295 "set",
296 `${baseDir}/missing.json`,
297 "--base-dir",
298 baseDir,
299 ]).pipe(Effect.flip);
300 assert.include(String(failure), "Could not read");
301 }),
302 );
303
304 // File-ness is decided by existence, not extension, so a generated file
305 // named for its target app still publishes.
306 it.effect("publishes an extensionless file", () =>
307 Effect.gen(function* () {
308 const baseDir = makeBaseDir();
309 const themeFile = NodePath.join(baseDir, "brand");
310 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
311
312 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]);
313
314 assert.equal(
315 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "brand.json")),
316 true,
317 );
318 assert.equal(readSettings(baseDir).defaultTheme, "brand");
319 }),
320 );
321
322 it.effect("records a set generation and clears it with the theme", () =>
323 Effect.gen(function* () {
324 const baseDir = makeBaseDir();
325 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
326 const setAt = readSettings(baseDir).defaultThemeSetAt;
327 assert.equal(typeof setAt, "string");
328
329 yield* runCli(["theme", "clear", "--base-dir", baseDir]);
330 const cleared = readSettings(baseDir);
331 assert.equal(Object.hasOwn(cleared, "defaultTheme"), false);
332 assert.equal(Object.hasOwn(cleared, "defaultThemeSetAt"), false);
333 }),
334 );
335
336 it.effect("honors T3CODE_HOME like the rest of the CLI", () =>
337 Effect.gen(function* () {
338 const baseDir = makeBaseDir();
339 yield* runCli(["theme", "set", "ocean"]).pipe(
340 Effect.provide(
341 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: baseDir } })),
342 ),
343 );
344 assert.equal(readSettings(baseDir).defaultTheme, "ocean");
345 }),
346 );
347
348 // An unreadable settings file must never read as "no settings": writing a
349 // fresh sparse file over it would discard every key the user had.
350 it.effect("refuses to write when the settings file cannot be read", () =>
351 Effect.gen(function* () {
352 const baseDir = makeBaseDir();
353 writeSettings(baseDir, { enableProviderUpdateChecks: false });
354 NodeFS.chmodSync(settingsPathFor(baseDir), 0o000);
355
356 const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe(
357 Effect.flip,
358 );
359
360 NodeFS.chmodSync(settingsPathFor(baseDir), 0o644);
361 assert.include(String(failure), "Could not read");
362 assert.equal(readSettings(baseDir).enableProviderUpdateChecks, false);
363 assert.equal(Object.hasOwn(readSettings(baseDir), "defaultTheme"), false);
364 }),
365 );
366
367 // A typo is syntactically a valid id, so shape validation alone would write
368 // a theme no client can resolve and report success.
369 it.effect("rejects an id that names no theme", () =>
370 Effect.gen(function* () {
371 const baseDir = makeBaseDir();
372 const failure = yield* runCli(["theme", "set", "ocian", "--base-dir", baseDir]).pipe(
373 Effect.flip,
374 );
375 assert.include(String(failure), "No theme named");
376 assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false);
377 }),
378 );
379
380 it.effect("accepts an id a published file provides", () =>
381 Effect.gen(function* () {
382 const baseDir = makeBaseDir();
383 const themeFile = NodePath.join(baseDir, "nightfall.json");
384 NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON);
385 yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]);
386
387 // Now resolvable by bare id, because the file published it.
388 yield* runCli(["theme", "clear", "--base-dir", baseDir]);
389 yield* runCli(["theme", "set", "nightfall", "--base-dir", baseDir]);
390 assert.equal(readSettings(baseDir).defaultTheme, "nightfall");
391 }),
392 );
393
394 // The watcher skips files it cannot use, so accepting their filename would
395 // set a theme no client ever receives.
396 it.effect("rejects an id whose published file the watcher would skip", () =>
397 Effect.gen(function* () {
398 const baseDir = makeBaseDir();
399 const themesDir = NodePath.join(baseDir, "userdata", "themes");
400 NodeFS.mkdirSync(themesDir, { recursive: true });
401 NodeFS.writeFileSync(NodePath.join(themesDir, "broken.json"), "{ not json\n");
402
403 const failure = yield* runCli(["theme", "set", "broken", "--base-dir", baseDir]).pipe(
404 Effect.flip,
405 );
406 assert.include(String(failure), "No theme named");
407 }),
408 );
409
410 // Web and desktop cannot resolve the mobile default, and mobile does not
411 // follow this setting, so naming it would be a silent no-op.
412 it.effect("rejects the mobile default theme id", () =>
413 Effect.gen(function* () {
414 const baseDir = makeBaseDir();
415 const failure = yield* runCli(["theme", "set", "t3-code", "--base-dir", baseDir]).pipe(
416 Effect.flip,
417 );
418 assert.include(String(failure), "No theme named");
419 }),
420 );
421
422 // Deciding on existence alone would publish ./ocean instead of selecting the
423 // built-in, purely because of what happens to be in the working directory.
424 it.effect("treats a bare id as an id even when a file shares its name", () =>
425 Effect.gen(function* () {
426 const baseDir = makeBaseDir();
427 const cwdFile = NodePath.join(baseDir, "ocean");
428 NodeFS.writeFileSync(cwdFile, NIGHTFALL_THEME_JSON);
429
430 const previous = process.cwd();
431 process.chdir(baseDir);
432 try {
433 yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]);
434 } finally {
435 process.chdir(previous);
436 }
437
438 assert.equal(readSettings(baseDir).defaultTheme, "ocean");
439 assert.equal(
440 NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "ocean.json")),
441 false,
442 );
443 }),
444 );
445
446 // The watcher would skip an oversized file, so publishing one must not
447 // report success for a theme no client receives.
448 it.effect("rejects a theme file larger than the watcher will read", () =>
449 Effect.gen(function* () {
450 const baseDir = makeBaseDir();
451 const themeFile = NodePath.join(baseDir, "huge.json");
452 const padding = "x".repeat(40 * 1024);
453 NodeFS.writeFileSync(
454 themeFile,
455 `{ "name": "Huge", "appearance": "dark", "canvas": "#1a1b26", "accent": "#7aa2f7", "note": "${padding}" }\n`,
456 );
457
458 const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(
459 Effect.flip,
460 );
461 assert.include(String(failure), "larger than");
462 }),
463 );
464
465 it.effect("refuses a settings file that is not a JSON object", () =>
466 Effect.gen(function* () {
467 const baseDir = makeBaseDir();
468 NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true });
469 NodeFS.writeFileSync(settingsPathFor(baseDir), "[1, 2, 3]\n");
470
471 const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe(
472 Effect.flip,
473 );
474
475 assert.include(String(failure), "not a JSON object");
476 }),
477 );
478});
apps/server/src/cli/theme.ts created+587
...@@ -0,0 +1,587 @@
1// @effect-diagnostics nodeBuiltinImport:off - publish commits and rollbacks
2// move exact directory entries with rename, which the FileSystem service does
3// not expose atomically.
4/**
5 * `t3 theme` - inspect and set the environment's theme. Connected web and
6 * desktop clients switch when it is set; mobile keeps its own appearance
7 * settings. Each client applies one set once, so a theme the user picks in
8 * Settings afterwards sticks until the next `t3 theme set`.
9 *
10 * Writes `defaultTheme` (and `defaultThemeSetAt`, so a re-set of the same
11 * value still acts) into the environment's `settings.json`. A running server
12 * watches that file and pushes the change, so this works before the first
13 * launch and on a live server alike.
14 *
15 * The edit is deliberately a minimal one on the parsed JSON object rather than
16 * a schema round-trip. Settings files outlive the build that reads them, and a
17 * provisioning command must not drop keys this version does not recognise.
18 */
19import * as NodeFS from "node:fs";
20
21import {
22 EnvironmentThemeFile,
23 EnvironmentThemeId,
24 environmentThemeFileHasColors,
25} from "@t3tools/contracts";
26import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
27import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes";
28import * as Config from "effect/Config";
29import * as Console from "effect/Console";
30import * as DateTime from "effect/DateTime";
31import * as Effect from "effect/Effect";
32import * as FileSystem from "effect/FileSystem";
33import * as Option from "effect/Option";
34import * as Path from "effect/Path";
35import * as Schema from "effect/Schema";
36import { Argument, Command, Flag } from "effect/unstable/cli";
37
38import { writeFileStringAtomically } from "../atomicWrite.ts";
39import * as ServerConfig from "../config.ts";
40import {
41 MAX_THEME_FILE_BYTES,
42 readPublishedThemes,
43 readThemeFileGuarded,
44} from "../environmentTheme.ts";
45import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
46import { baseDirFlag } from "./config.ts";
47
48/** Settings files outlive the build that reads them, so the object is carried
49 * as-is and only the theme keys are touched. */
50const SparseSettings = Schema.Record(Schema.String, Schema.Unknown);
51const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings));
52const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings));
53const decodeThemeFileJsonExit = Schema.decodeUnknownExit(
54 Schema.fromJsonString(EnvironmentThemeFile),
55);
56const isEnvironmentThemeId = Schema.is(EnvironmentThemeId);
57
58export class ThemeSettingsUnreadableError extends Schema.TaggedErrorClass<ThemeSettingsUnreadableError>()(
59 "ThemeSettingsUnreadableError",
60 { settingsPath: Schema.String, cause: Schema.Defect() },
61) {
62 override get message(): string {
63 return `Could not read ${this.settingsPath}. Fix its permissions, then run this again.`;
64 }
65}
66
67export class ThemeSettingsMalformedError extends Schema.TaggedErrorClass<ThemeSettingsMalformedError>()(
68 "ThemeSettingsMalformedError",
69 { settingsPath: Schema.String, cause: Schema.Defect() },
70) {
71 override get message(): string {
72 return `${this.settingsPath} is not a JSON object. Fix or remove it, then run this again.`;
73 }
74}
75
76export class ThemeSettingsBusyError extends Schema.TaggedErrorClass<ThemeSettingsBusyError>()(
77 "ThemeSettingsBusyError",
78 { settingsPath: Schema.String, attempts: Schema.Number },
79) {
80 override get message(): string {
81 return `${this.settingsPath} kept changing while writing (gave up after ${this.attempts} attempts). Try again.`;
82 }
83}
84
85export class ThemeSettingsWriteError extends Schema.TaggedErrorClass<ThemeSettingsWriteError>()(
86 "ThemeSettingsWriteError",
87 { settingsPath: Schema.String, cause: Schema.Defect() },
88) {
89 override get message(): string {
90 return `Could not write ${this.settingsPath}.`;
91 }
92}
93
94export class ThemeFileUnreadableError extends Schema.TaggedErrorClass<ThemeFileUnreadableError>()(
95 "ThemeFileUnreadableError",
96 // Optional: a path that never existed has no underlying failure to carry,
97 // and a manufactured string there would only look like a real one.
98 { filePath: Schema.String, cause: Schema.optional(Schema.Defect()) },
99) {
100 override get message(): string {
101 return `Could not read ${this.filePath}.`;
102 }
103}
104
105export class ThemeFileInvalidError extends Schema.TaggedErrorClass<ThemeFileInvalidError>()(
106 "ThemeFileInvalidError",
107 { filePath: Schema.String, cause: Schema.Defect() },
108) {
109 override get message(): string {
110 return `${this.filePath} is not a valid theme file. Use a theme exported from T3 Code, or a seeded file with name, appearance, canvas, and accent.`;
111 }
112}
113
114export class ThemeFileTooLargeError extends Schema.TaggedErrorClass<ThemeFileTooLargeError>()(
115 "ThemeFileTooLargeError",
116 { filePath: Schema.String, limit: Schema.Number },
117) {
118 override get message(): string {
119 return `${this.filePath} is larger than ${this.limit} bytes, which is more than a theme can publish.`;
120 }
121}
122
123export class ThemeFileColorlessError extends Schema.TaggedErrorClass<ThemeFileColorlessError>()(
124 "ThemeFileColorlessError",
125 { filePath: Schema.String },
126) {
127 override get message(): string {
128 return `${this.filePath} has no colors to publish.`;
129 }
130}
131
132export class ThemePublishError extends Schema.TaggedErrorClass<ThemePublishError>()(
133 "ThemePublishError",
134 { themesDir: Schema.String, cause: Schema.Defect() },
135) {
136 override get message(): string {
137 return `Could not publish the theme into ${this.themesDir}.`;
138 }
139}
140
141const INVALID_THEME_ID_REASON =
142 "is not a valid theme id (lowercase letters, digits, and hyphens; not an appearance keyword)";
143
144export class ThemeIdUnknownError extends Schema.TaggedErrorClass<ThemeIdUnknownError>()(
145 "ThemeIdUnknownError",
146 { themeId: Schema.String, known: Schema.Array(Schema.String) },
147) {
148 override get message(): string {
149 return `No theme named "${this.themeId}". Available: ${this.known.join(", ")}. Publish one by passing a theme file instead of an id.`;
150 }
151}
152
153export class ThemeIdInvalidError extends Schema.TaggedErrorClass<ThemeIdInvalidError>()(
154 "ThemeIdInvalidError",
155 { themeId: Schema.String },
156) {
157 override get message(): string {
158 return `"${this.themeId}" ${INVALID_THEME_ID_REASON}.`;
159 }
160}
161
162/** A filename that cannot be a theme id, where --id is the way out. */
163export class ThemeFileIdInvalidError extends Schema.TaggedErrorClass<ThemeFileIdInvalidError>()(
164 "ThemeFileIdInvalidError",
165 { themeId: Schema.String, filePath: Schema.String },
166) {
167 override get message(): string {
168 return `"${this.themeId}" ${INVALID_THEME_ID_REASON}. Pass one with --id.`;
169 }
170}
171
172export class ThemeTargetMissingError extends Schema.TaggedErrorClass<ThemeTargetMissingError>()(
173 "ThemeTargetMissingError",
174 {},
175) {
176 override get message(): string {
177 return "Provide a theme id or file, or run `t3 theme clear` to remove the theme.";
178 }
179}
180
181const envT3Home = Config.string("T3CODE_HOME").pipe(Config.option);
182
183const resolveThemePaths = Effect.fn(function* (explicitBaseDir: Option.Option<string>) {
184 // Same precedence as the rest of the CLI: --base-dir, then T3CODE_HOME,
185 // then the default home. A provisioning script exporting T3CODE_HOME must
186 // not have this one command silently target the default install.
187 const envHome = Option.filter(yield* envT3Home, (value) => value.trim().length > 0);
188 const configuredBaseDir = Option.orElse(explicitBaseDir, () => envHome);
189 const baseDir = yield* resolveBaseDir(Option.getOrUndefined(configuredBaseDir));
190 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {
191 baseDirIsExplicit: Option.isSome(configuredBaseDir),
192 });
193 return {
194 settingsPath: derivedPaths.settingsPath,
195 themesDir: derivedPaths.environmentThemesDir,
196 };
197});
198
199/**
200 * Reads the sparse settings object, treating only a genuinely absent file as
201 * empty. A permission or I/O error must propagate: reading it as "no settings"
202 * would have the caller write a fresh sparse file over settings it never saw.
203 */
204const readSettingsObject = Effect.fn(function* (settingsPath: string) {
205 const fs = yield* FileSystem.FileSystem;
206 const exists = yield* fs
207 .exists(settingsPath)
208 .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause })));
209 if (!exists) return { raw: "", settings: {} };
210
211 const raw = yield* fs
212 .readFileString(settingsPath)
213 .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause })));
214 if (raw.trim().length === 0) return { raw, settings: {} };
215
216 const settings = yield* decodeSettingsJson(raw).pipe(
217 Effect.mapError((cause) => new ThemeSettingsMalformedError({ settingsPath, cause })),
218 );
219 return { raw, settings };
220});
221
222/**
223 * A running server owns this file too, and its write path is an in-process
224 * semaphore that cannot serialize against another process. So the document is
225 * re-read immediately before the rename and the whole edit is retried when it
226 * moved underneath us, which is what turns "last writer wins" into "last
227 * writer merges", and an edit that keeps losing the race fails loudly rather
228 * than overwriting. A write landing inside the remaining rename window is
229 * still possible; the server's own watcher reconciles the file either way.
230 */
231const CONCURRENT_WRITE_ATTEMPTS = 5;
232
233const writeDefaultTheme = Effect.fn(function* (input: {
234 readonly settingsPath: string;
235 readonly themeId: string;
236}) {
237 const fs = yield* FileSystem.FileSystem;
238
239 for (let attempt = 1; ; attempt++) {
240 const { raw, settings } = yield* readSettingsObject(input.settingsPath);
241 const setAt = DateTime.formatIso(yield* DateTime.now);
242 const next =
243 input.themeId.length > 0
244 ? // The timestamp is the set-generation: it lets clients apply a re-set
245 // of the same value they already applied once.
246 { ...settings, defaultTheme: input.themeId, defaultThemeSetAt: setAt }
247 : // Clearing removes the keys rather than storing empty strings, so the
248 // file reads the same as one that never set a theme.
249 Object.fromEntries(
250 Object.entries(settings).filter(
251 ([key]) => key !== "defaultTheme" && key !== "defaultThemeSetAt",
252 ),
253 );
254
255 const contents = yield* encodeSettingsJson(next);
256 const current = yield* fs
257 .readFileString(input.settingsPath)
258 .pipe(Effect.orElseSucceed(() => ""));
259 if (current !== raw) {
260 // Falling through here would overwrite whatever landed in between, which
261 // is exactly the loss this loop exists to prevent.
262 if (attempt >= CONCURRENT_WRITE_ATTEMPTS) {
263 return yield* Effect.fail(
264 new ThemeSettingsBusyError({
265 settingsPath: input.settingsPath,
266 attempts: CONCURRENT_WRITE_ATTEMPTS,
267 }),
268 );
269 }
270 continue;
271 }
272
273 yield* writeFileStringAtomically({
274 filePath: input.settingsPath,
275 contents: `${contents}\n`,
276 }).pipe(
277 Effect.mapError(
278 (cause) => new ThemeSettingsWriteError({ settingsPath: input.settingsPath, cause }),
279 ),
280 );
281 return;
282 }
283});
284
285/** Publishes a theme file into the environment's themes directory and returns
286 * the id it published under. */
287const publishThemeFile = Effect.fn(function* (input: {
288 readonly themesDir: string;
289 readonly filePath: string;
290 readonly explicitId: Option.Option<string>;
291}) {
292 const fs = yield* FileSystem.FileSystem;
293 const path = yield* Path.Path;
294 // A preflight for error quality only: it tells a FIFO from an oversized
295 // file. Enforcement happens at the guarded read below.
296 const info = yield* fs
297 .stat(input.filePath)
298 .pipe(
299 Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })),
300 );
301 if (info.type !== "File") {
302 return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath }));
303 }
304 if (Number(info.size) > MAX_THEME_FILE_BYTES) {
305 return yield* Effect.fail(
306 new ThemeFileTooLargeError({ filePath: input.filePath, limit: MAX_THEME_FILE_BYTES }),
307 );
308 }
309
310 // An explicit source path is the user's own input, and a symlink there is a
311 // normal way to point at a theme (desktop hooks symlink the current
312 // palette), so it is resolved before the guarded read. The read still goes
313 // through one opened handle whose type and size checks bind to the file
314 // actually read, so a FIFO cannot hang the command and an oversized target
315 // is refused.
316 const resolvedSource = yield* fs
317 .realPath(input.filePath)
318 .pipe(
319 Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })),
320 );
321 const raw = readThemeFileGuarded(resolvedSource, MAX_THEME_FILE_BYTES);
322 if (raw === null) {
323 return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath }));
324 }
325
326 const decoded = decodeThemeFileJsonExit(raw);
327 if (decoded._tag === "Failure") {
328 return yield* Effect.fail(
329 new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause }),
330 );
331 }
332 if (!environmentThemeFileHasColors(decoded.value)) {
333 return yield* Effect.fail(new ThemeFileColorlessError({ filePath: input.filePath }));
334 }
335
336 const fileBasename = path.basename(input.filePath, ".json");
337 const themeId = Option.getOrElse(input.explicitId, () => fileBasename);
338 // The same rules the watcher applies when it reads the directory back, so a
339 // publish cannot report success for a file that will then be skipped.
340 if (!isEnvironmentThemeId(themeId) || UNPUBLISHABLE_THEME_IDS.has(themeId)) {
341 return yield* Effect.fail(new ThemeFileIdInvalidError({ themeId, filePath: input.filePath }));
342 }
343
344 const destinationPath = path.join(input.themesDir, `${themeId}.json`);
345 // Neither ends in `.json`, so the watcher never mistakes them for themes.
346 // Both names carry the pid, so concurrent publishers of one id cannot
347 // unlink or restore over each other's staging and rollback copies.
348 const backupPath = `${destinationPath}.rollback-${process.pid}`;
349 const stagingPath = `${destinationPath}.staging-${process.pid}`;
350 yield* fs
351 .makeDirectory(input.themesDir, { recursive: true })
352 .pipe(Effect.mapError((cause) => new ThemePublishError({ themesDir: input.themesDir, cause })));
353
354 const publishFailure = (cause: unknown) =>
355 new ThemePublishError({ themesDir: input.themesDir, cause });
356
357 // Staged in full before anything moves, so the commit below is two adjacent
358 // renames with no I/O between them. The staging entry is created O_EXCL
359 // after clearing any stale leftover, so a symlink or file already at that
360 // predictable name is never followed or written through. Written verbatim:
361 // appending so much as a newline could push a file at the size limit past
362 // it and have the watcher skip what was just accepted.
363 const stagedIno = yield* Effect.try({
364 try: () => {
365 try {
366 NodeFS.unlinkSync(stagingPath);
367 } catch {
368 // Nothing stale to clear.
369 }
370 const fd = NodeFS.openSync(
371 stagingPath,
372 NodeFS.constants.O_WRONLY | NodeFS.constants.O_CREAT | NodeFS.constants.O_EXCL,
373 0o644,
374 );
375 try {
376 NodeFS.writeFileSync(fd, raw);
377 // Rename preserves the inode, so this identifies our published file
378 // at the destination for as long as it is actually ours.
379 return NodeFS.fstatSync(fd).ino;
380 } finally {
381 NodeFS.closeSync(fd);
382 }
383 },
384 catch: publishFailure,
385 });
386
387 // Whatever occupies the destination -- a theme, a symlink, anything -- is
388 // moved aside in one atomic step rather than inspected and then replaced:
389 // there is no window between a check and the commit, and rollback restores
390 // that exact directory entry instead of a re-read of it. Only "nothing
391 // there" continues; any other rename failure aborts before the destination
392 // is touched.
393 const hadPrevious = yield* Effect.try({
394 try: () => {
395 try {
396 NodeFS.renameSync(destinationPath, backupPath);
397 return true;
398 } catch (error) {
399 if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
400 throw error;
401 }
402 },
403 catch: publishFailure,
404 });
405
406 const revert = Effect.sync(() => {
407 try {
408 NodeFS.unlinkSync(stagingPath);
409 } catch {
410 // Usually already renamed away; a stray staging file is watcher-inert.
411 }
412 try {
413 // The destination is touched only while it is empty or still holds
414 // the exact file this process put there; a concurrent publisher's
415 // newer file wins, and this process's obsolete copy is discarded.
416 const destinationIno = (() => {
417 try {
418 return NodeFS.lstatSync(destinationPath).ino;
419 } catch {
420 return null;
421 }
422 })();
423 if (hadPrevious) {
424 if (destinationIno === null || destinationIno === stagedIno) {
425 NodeFS.renameSync(backupPath, destinationPath);
426 } else {
427 NodeFS.unlinkSync(backupPath);
428 }
429 } else if (destinationIno === stagedIno) {
430 NodeFS.unlinkSync(destinationPath);
431 }
432 } catch {
433 // Best effort; the failure that triggered the revert still surfaces.
434 }
435 });
436 const cleanup = Effect.sync(() => {
437 try {
438 if (hadPrevious) NodeFS.unlinkSync(backupPath);
439 } catch {
440 // A stray backup is inert: it is not `.json`, so nothing serves it.
441 }
442 });
443
444 yield* Effect.try({
445 try: () => NodeFS.renameSync(stagingPath, destinationPath),
446 catch: publishFailure,
447 }).pipe(Effect.onError(() => revert));
448
449 return { themeId, revert, cleanup };
450});
451
452/**
453 * Ids a client can actually resolve: this build's built-ins plus what the
454 * machine publishes, read through the same function the watcher uses so a file
455 * it would skip can never be accepted here. The mobile default is absent on
456 * purpose -- web and desktop cannot resolve it and mobile does not follow this
457 * setting, so naming it would be the silent no-op this check exists to stop.
458 */
459const resolvableThemeIds = Effect.fn(function* (themesDir: string) {
460 const published = yield* readPublishedThemes(themesDir);
461 return [...BUILT_IN_THEME_IDS, ...published.map((theme) => theme.id)].toSorted();
462});
463
464const themeSetCommand = Command.make("set", {
465 baseDir: baseDirFlag,
466 id: Flag.string("id").pipe(
467 Flag.withDescription("Theme id to publish a file under, instead of its filename."),
468 Flag.optional,
469 ),
470 theme: Argument.string("theme").pipe(
471 Argument.withDescription(
472 'A theme id (a built-in, or one this machine publishes — themes/nightfall.json is "nightfall"), or a path to a theme JSON file to publish and set in one step.',
473 ),
474 ),
475}).pipe(
476 Command.withDescription("Set the environment's theme; connected clients switch to it."),
477 Command.withHandler((flags) =>
478 Effect.gen(function* () {
479 const fs = yield* FileSystem.FileSystem;
480 const target = yield* expandHomePath(flags.theme.trim());
481 if (target.length === 0) {
482 return yield* Effect.fail(new ThemeTargetMissingError());
483 }
484 const paths = yield* resolveThemePaths(flags.baseDir);
485
486 // An existing file publishes; anything path-shaped that does not exist
487 // is a mistake to surface, not an id to store; everything else must be
488 // a well-formed id, so a typo cannot be written as a theme no client
489 // will ever resolve.
490 // Path-shaped first, existence second. Deciding on existence alone would
491 // make `t3 theme set ocean` publish ./ocean whenever the cwd happens to
492 // hold a file by that name, instead of selecting the built-in.
493 const looksLikePath =
494 target.endsWith(".json") ||
495 target.includes("/") ||
496 target.includes("\\") ||
497 target.startsWith("~");
498 const targetIsFile =
499 looksLikePath && (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)));
500 let themeId: string;
501 let revertPublish: Effect.Effect<void> = Effect.void;
502 let cleanupPublish: Effect.Effect<void> = Effect.void;
503 if (targetIsFile) {
504 // Settings are preflighted before publishing, so a settings file the
505 // set step cannot read or parse fails the command before it mutates
506 // the themes directory.
507 yield* readSettingsObject(paths.settingsPath);
508 const published = yield* publishThemeFile({
509 themesDir: paths.themesDir,
510 filePath: target,
511 explicitId: flags.id,
512 });
513 themeId = published.themeId;
514 revertPublish = published.revert;
515 cleanupPublish = published.cleanup;
516 } else if (looksLikePath) {
517 return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: target }));
518 } else if (isEnvironmentThemeId(target)) {
519 const known = yield* resolvableThemeIds(paths.themesDir);
520 if (!known.includes(target)) {
521 return yield* Effect.fail(new ThemeIdUnknownError({ themeId: target, known }));
522 }
523 themeId = target;
524 } else {
525 return yield* Effect.fail(new ThemeIdInvalidError({ themeId: target }));
526 }
527
528 // set means set: if the default cannot be written, the publish that
529 // rode along with it is undone rather than left as a side effect of a
530 // command that reported failure.
531 yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId }).pipe(
532 Effect.onError(() => revertPublish),
533 );
534 yield* cleanupPublish;
535 yield* Console.log(
536 targetIsFile
537 ? `Published ${target} as "${themeId}" and set it as the environment theme.\n`
538 : `Environment theme set to "${themeId}" in ${paths.settingsPath}.\n`,
539 );
540 }),
541 ),
542);
543
544const themeClearCommand = Command.make("clear", { baseDir: baseDirFlag }).pipe(
545 Command.withDescription("Remove the environment's theme; clients keep what they have."),
546 Command.withHandler((flags) =>
547 Effect.gen(function* () {
548 const paths = yield* resolveThemePaths(flags.baseDir);
549 yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId: "" });
550 yield* Console.log(`Environment theme cleared in ${paths.settingsPath}.\n`);
551 }),
552 ),
553);
554
555const themeShowCommand = Command.make("show", { baseDir: baseDirFlag }).pipe(
556 Command.withDescription("Show the environment's theme and its published themes."),
557 Command.withHandler((flags) =>
558 Effect.gen(function* () {
559 const paths = yield* resolveThemePaths(flags.baseDir);
560 const { settings } = yield* readSettingsObject(paths.settingsPath);
561 const defaultTheme =
562 typeof settings.defaultTheme === "string" && settings.defaultTheme.length > 0
563 ? settings.defaultTheme
564 : null;
565
566 const published = (yield* readPublishedThemes(paths.themesDir))
567 .map((theme) => theme.id)
568 .toSorted();
569
570 yield* Console.log(
571 defaultTheme === null
572 ? "Environment theme: not set.\n"
573 : `Environment theme: "${defaultTheme}".\n`,
574 );
575 yield* Console.log(
576 published.length === 0
577 ? `Published themes: none (publish into ${paths.themesDir}).\n`
578 : `Published themes: ${published.join(", ")}.\n`,
579 );
580 }),
581 ),
582);
583
584export const themeCommand = Command.make("theme").pipe(
585 Command.withDescription("Inspect and set environment-wide theme defaults."),
586 Command.withSubcommands([themeSetCommand, themeClearCommand, themeShowCommand]),
587);
apps/server/src/config.ts+3
...@@ -33,6 +33,8 @@ export interface ServerDerivedPaths {...@@ -33,6 +33,8 @@ export interface ServerDerivedPaths {
33 readonly dbPath: string;33 readonly dbPath: string;
34 readonly keybindingsConfigPath: string;34 readonly keybindingsConfigPath: string;
35 readonly settingsPath: string;35 readonly settingsPath: string;
36 /** Palettes this machine publishes for clients to follow, one file per theme. */
37 readonly environmentThemesDir: string;
36 readonly providerStatusCacheDir: string;38 readonly providerStatusCacheDir: string;
37 readonly worktreesDir: string;39 readonly worktreesDir: string;
38 readonly attachmentsDir: string;40 readonly attachmentsDir: string;
...@@ -119,6 +121,7 @@ export const deriveServerPaths = Effect.fn(function* (...@@ -119,6 +121,7 @@ export const deriveServerPaths = Effect.fn(function* (
119 dbPath,121 dbPath,
120 keybindingsConfigPath: join(stateDir, "keybindings.json"),122 keybindingsConfigPath: join(stateDir, "keybindings.json"),
121 settingsPath: join(stateDir, "settings.json"),123 settingsPath: join(stateDir, "settings.json"),
124 environmentThemesDir: join(stateDir, "themes"),
122 providerStatusCacheDir,125 providerStatusCacheDir,
123 worktreesDir: join(baseDir, "worktrees"),126 worktreesDir: join(baseDir, "worktrees"),
124 attachmentsDir,127 attachmentsDir,
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -155,6 +155,7 @@ export const make = Effect.gen(function* () {...@@ -155,6 +155,7 @@ export const make = Effect.gen(function* () {
155 pullRequests: true,155 pullRequests: true,
156 threadSettlement: true,156 threadSettlement: true,
157 threadSnooze: true,157 threadSnooze: true,
158 environmentThemes: true,
158 threadPinning: true,159 threadPinning: true,
159 threadPinReorder: true,160 threadPinReorder: true,
160 threadTitleRegeneration: true,161 threadTitleRegeneration: true,
apps/server/src/environmentTheme.test.ts created+272
...@@ -0,0 +1,272 @@
1import { EnvironmentThemeFile } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Option from "effect/Option";
9import * as Queue from "effect/Queue";
10import * as Schema from "effect/Schema";
11import * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
13
14import * as ServerConfig from "./config.ts";
15import * as EnvironmentTheme from "./environmentTheme.ts";
16
17const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile));
18
19const NIGHTFALL_THEME: EnvironmentThemeFile = {
20 name: "Nightfall",
21 appearance: "dark",
22 canvas: "#1a1b26",
23 accent: "#7aa2f7",
24};
25
26/** The standard exported form: a full palette, no seeds. */
27const SHARED_THEME: EnvironmentThemeFile = {
28 version: 1,
29 name: "Shared Light",
30 appearance: "light",
31 colors: { canvas: "#eff1f5", accent: "#1e66f5" },
32};
33
34/** Seeds theme files before the service starts, as a real machine would. */
35const withEnvironmentThemes = <A, E>(
36 seeds: Readonly<Record<string, string>>,
37 body: Effect.Effect<
38 A,
39 E,
40 | EnvironmentTheme.EnvironmentThemeService
41 | ServerConfig.ServerConfig
42 | FileSystem.FileSystem
43 | Path.Path
44 | Scope.Scope
45 >,
46) =>
47 Effect.gen(function* () {
48 const fs = yield* FileSystem.FileSystem;
49 const path = yield* Path.Path;
50 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-environment-theme-" });
51 const themesDir = path.join(baseDir, "userdata", "themes");
52 yield* fs.makeDirectory(themesDir, { recursive: true });
53 for (const [filename, contents] of Object.entries(seeds)) {
54 yield* fs.writeFileString(path.join(themesDir, filename), contents);
55 }
56
57 return yield* body.pipe(
58 Effect.provide(
59 EnvironmentTheme.layer.pipe(
60 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
61 ),
62 ),
63 );
64 }).pipe(Effect.scoped);
65
66const currentThemes = Effect.gen(function* () {
67 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
68 return yield* environmentTheme.current;
69});
70
71it.layer(NodeServices.layer)("environment theme", (it) => {
72 it.effect("publishes nothing when the machine has no theme files", () =>
73 withEnvironmentThemes(
74 {},
75 Effect.gen(function* () {
76 assert.deepEqual(yield* currentThemes, []);
77 }),
78 ),
79 );
80
81 it.effect("publishes each file under its filename as the id", () =>
82 withEnvironmentThemes(
83 {
84 "nightfall.json": encodeThemeFile(NIGHTFALL_THEME),
85 "shared-light.json": encodeThemeFile(SHARED_THEME),
86 },
87 Effect.gen(function* () {
88 const themes = yield* currentThemes;
89 assert.deepEqual(
90 themes.map((theme) => theme.id),
91 ["nightfall", "shared-light"],
92 );
93 assert.deepEqual(themes[0], { id: "nightfall", ...NIGHTFALL_THEME });
94 assert.deepEqual(themes[1], { id: "shared-light", ...SHARED_THEME });
95 }),
96 ),
97 );
98
99 // Read from disk rather than from the watcher's last observation, so a
100 // client connecting after a missed filesystem event still sees the truth.
101 it.effect("follows the directory rather than the set read at start", () =>
102 withEnvironmentThemes(
103 { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
104 Effect.gen(function* () {
105 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
106 const fs = yield* FileSystem.FileSystem;
107 const path = yield* Path.Path;
108
109 yield* fs.writeFileString(
110 path.join(environmentThemesDir, "shared-light.json"),
111 encodeThemeFile(SHARED_THEME),
112 );
113 assert.equal((yield* currentThemes).length, 2);
114
115 yield* fs.remove(path.join(environmentThemesDir, "nightfall.json"));
116 assert.deepEqual(
117 (yield* currentThemes).map((theme) => theme.id),
118 ["shared-light"],
119 );
120 }),
121 ),
122 );
123
124 // One bad file must not take down the machine's other themes: a theme
125 // script that leaves a template placeholder unresolved, a half-written
126 // file, or a stray name are each that file's problem alone.
127 // The subscription is acquired before the current set is read, so nothing
128 // published while a client connects can fall between snapshot and stream.
129 it.effect("streams the current set first", () =>
130 withEnvironmentThemes(
131 { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
132 Effect.gen(function* () {
133 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
134 const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead);
135 assert.deepEqual(Option.getOrNull(first), [{ id: "nightfall", ...NIGHTFALL_THEME }]);
136 }),
137 ),
138 );
139
140 // Subscribing happens before the snapshot read, so a publish landing in
141 // between is queued. It must not replay after the newer snapshot and walk
142 // clients back onto colors the machine has already moved past.
143 it.effect("never replays a set older than the snapshot it started from", () =>
144 withEnvironmentThemes(
145 { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
146 Effect.gen(function* () {
147 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
148 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
149 const fs = yield* FileSystem.FileSystem;
150 const path = yield* Path.Path;
151
152 // Advance the directory twice without the watcher running, so the
153 // second read is strictly newer than anything already observed.
154 yield* fs.writeFileString(
155 path.join(environmentThemesDir, "shared-light.json"),
156 encodeThemeFile(SHARED_THEME),
157 );
158 const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead);
159 assert.deepEqual(
160 Option.getOrNull(first)?.map((theme) => theme.id),
161 ["nightfall", "shared-light"],
162 );
163 }),
164 ),
165 );
166
167 it.effect("skips invalid files while keeping valid ones", () =>
168 withEnvironmentThemes(
169 {
170 "nightfall.json": encodeThemeFile(NIGHTFALL_THEME),
171 "unresolved.json":
172 '{ "name": "X", "appearance": "dark", "canvas": "{{ background }}", "accent": "#7aa2f7" }',
173 "malformed.json": "{ not json",
174 "no-colors.json": '{ "name": "Empty", "appearance": "dark" }',
175 "Bad Name.json": encodeThemeFile(SHARED_THEME),
176 "ocean.json": encodeThemeFile(SHARED_THEME),
177 "dark.json": encodeThemeFile(SHARED_THEME),
178 "notes.txt": "not a theme",
179 },
180 Effect.gen(function* () {
181 assert.deepEqual(
182 (yield* currentThemes).map((theme) => theme.id),
183 ["nightfall"],
184 );
185 }),
186 ),
187 );
188
189 // A symlinked themes directory stays usable, but a symlinked file inside it
190 // must not publish whatever it points at.
191 it.effect("ignores a symlinked theme file", () =>
192 withEnvironmentThemes(
193 {},
194 Effect.gen(function* () {
195 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
196 const fs = yield* FileSystem.FileSystem;
197 const path = yield* Path.Path;
198 const outside = path.join(environmentThemesDir, "..", "outside.json");
199 yield* fs.writeFileString(outside, encodeThemeFile(NIGHTFALL_THEME));
200 yield* fs.symlink(outside, path.join(environmentThemesDir, "nightfall.json"));
201 assert.deepEqual(yield* currentThemes, []);
202 }),
203 ),
204 );
205
206 // The aggregate size cap charges only accepted themes, so a pile of
207 // malformed files cannot spend the budget and hide a valid theme sorted
208 // after them.
209 it.effect("does not charge skipped files against the total size limit", () =>
210 withEnvironmentThemes(
211 {
212 ...Object.fromEntries(
213 Array.from({ length: 7 }, (_, index) => [`junk-${index}.json`, "{".repeat(30_000)]),
214 ),
215 "zz-valid.json": encodeThemeFile(NIGHTFALL_THEME),
216 },
217 Effect.gen(function* () {
218 assert.deepEqual(
219 (yield* currentThemes).map((theme) => theme.id),
220 ["zz-valid"],
221 );
222 }),
223 ),
224 );
225});
226
227// The feature's headline claim: rewrite a file and connected clients retint
228// without a restart. Live clock and a real filesystem event, so this proves
229// the watcher rather than a direct read. Kept outside the it.layer block above
230// because only the top-level `it` exposes `live`.
231describe("environment theme watching", () => {
232 it.live("streams a set for every change to the directory", () =>
233 Effect.gen(function* () {
234 const fs = yield* FileSystem.FileSystem;
235 const path = yield* Path.Path;
236 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-theme-watch-" });
237 const themesDir = path.join(baseDir, "userdata", "themes");
238 yield* fs.makeDirectory(themesDir, { recursive: true });
239
240 yield* Effect.gen(function* () {
241 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
242 const seen = yield* Queue.unbounded<ReadonlyArray<{ readonly id: string }>>();
243 yield* Stream.runForEach(environmentTheme.streamChanges, (themes) =>
244 Queue.offer(seen, themes),
245 ).pipe(Effect.forkScoped);
246
247 // Empty to start.
248 assert.deepEqual(yield* Queue.take(seen), []);
249
250 // Published atomically, the way a theme hook writes it.
251 const staging = path.join(baseDir, "staged.json");
252 yield* fs.writeFileString(staging, encodeThemeFile(NIGHTFALL_THEME));
253 yield* fs.rename(staging, path.join(themesDir, "nightfall.json"));
254 assert.deepEqual(
255 (yield* Queue.take(seen)).map((theme) => theme.id),
256 ["nightfall"],
257 );
258
259 // Removed again, and the set empties without a restart.
260 yield* fs.remove(path.join(themesDir, "nightfall.json"));
261 assert.deepEqual(yield* Queue.take(seen), []);
262 }).pipe(
263 Effect.provide(
264 EnvironmentTheme.layer.pipe(
265 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
266 ),
267 ),
268 Effect.timeout("30 seconds"),
269 );
270 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
271 );
272});
apps/server/src/environmentTheme.ts created+297
...@@ -0,0 +1,297 @@
1// @effect-diagnostics nodeBuiltinImport:off - the guarded file read needs open
2// flags (O_NOFOLLOW, O_NONBLOCK) the FileSystem service does not expose.
3/**
4 * EnvironmentTheme - palettes this machine publishes for clients to follow.
5 *
6 * A desktop that retints its apps when the user switches system theme writes
7 * `<stateDir>/themes/<id>.json`; this service watches that directory and
8 * streams the published set to connected clients so a theme change lands
9 * without a restart. The filename is the theme id: it stays stable while the
10 * machine rewrites the colors underneath, so `defaultTheme` and a client\'s
11 * selection keep pointing at the same theme across recolors. Theming is
12 * cosmetic, so every failure here degrades to "not published" rather than
13 * propagating.
14 *
15 * @module EnvironmentTheme
16 */
17import * as NodeFS from "node:fs";
18
19import {
20 EnvironmentTheme,
21 EnvironmentThemeFile,
22 EnvironmentThemeId,
23 environmentThemeFileHasColors,
24} from "@t3tools/contracts";
25import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes";
26import * as Cause from "effect/Cause";
27import * as Context from "effect/Context";
28import * as Duration from "effect/Duration";
29import * as Effect from "effect/Effect";
30import * as Equal from "effect/Equal";
31import * as Exit from "effect/Exit";
32import * as FileSystem from "effect/FileSystem";
33import * as Layer from "effect/Layer";
34import * as PubSub from "effect/PubSub";
35import * as Ref from "effect/Ref";
36import * as Schema from "effect/Schema";
37import * as Semaphore from "effect/Semaphore";
38import * as Scope from "effect/Scope";
39import * as Stream from "effect/Stream";
40
41import * as ServerConfig from "./config.ts";
42
43const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit(
44 Schema.fromJsonString(EnvironmentThemeFile),
45);
46const isEnvironmentThemeId = Schema.is(EnvironmentThemeId);
47
48const THEME_FILE_SUFFIX = ".json";
49
50/**
51 * Bounds on what a machine can publish. The directory is local, so this is not
52 * a trust boundary -- but an accidental dump of large files there would
53 * otherwise be read in full, streamed to every client, and repainted, so the
54 * cost of a mistake is capped rather than unbounded.
55 */
56const MAX_THEME_FILES = 32;
57/** Exported so the publish path cannot accept a file the watcher will skip. */
58export const MAX_THEME_FILE_BYTES = 32 * 1024;
59/**
60 * The set travels whole in a websocket event to every subscriber, so the sum
61 * matters more than any single file. An exported theme runs a few KB, leaving
62 * this far above any real directory while keeping a mistake off the wire.
63 */
64const MAX_THEME_TOTAL_BYTES = 192 * 1024;
65
66/** The published set with the sequence number it was observed at. */
67interface PublishedThemes {
68 readonly seq: number;
69 readonly themes: ReadonlyArray<EnvironmentTheme>;
70}
71
72export class EnvironmentThemeService extends Context.Service<
73 EnvironmentThemeService,
74 {
75 /**
76 * The set published right now, read from disk rather than from the
77 * watcher\'s last observation: a client connecting must see what the
78 * machine actually publishes even if it missed a filesystem event.
79 */
80 readonly current: Effect.Effect<ReadonlyArray<EnvironmentTheme>>;
81
82 /**
83 * The current set followed by every change, with repeats dropped. The
84 * subscription is acquired before the current set is read, so a publish
85 * landing while a client connects is delivered rather than lost.
86 */
87 readonly streamChanges: Stream.Stream<ReadonlyArray<EnvironmentTheme>>;
88 }
89>()("t3/environmentTheme/EnvironmentThemeService") {}
90
91/**
92 * Reads a theme file through one opened handle, so every check binds to the
93 * file actually read rather than to a path that may have been swapped since:
94 * O_NOFOLLOW rejects a symlink outright (a symlinked themes directory stays
95 * usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from
96 * blocking the open, and the fstat type and size gate examines the open
97 * descriptor. Returns null for anything that is not a small regular file.
98 */
99export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => {
100 let fd: number;
101 try {
102 fd = NodeFS.openSync(
103 filePath,
104 NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW | NodeFS.constants.O_NONBLOCK,
105 );
106 } catch {
107 return null;
108 }
109 try {
110 const info = NodeFS.fstatSync(fd);
111 if (!info.isFile() || info.size > maxBytes) return null;
112 const contents = Buffer.alloc(info.size);
113 let offset = 0;
114 while (offset < contents.length) {
115 const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset);
116 if (read <= 0) break;
117 offset += read;
118 }
119 return contents.subarray(0, offset).toString("utf8");
120 } catch {
121 return null;
122 } finally {
123 NodeFS.closeSync(fd);
124 }
125};
126
127/**
128 * Every theme the directory actually publishes. A file that is missing,
129 * unreadable, malformed, colorless, or misnamed is simply skipped; the rest of
130 * the set is unaffected. The one place that decides what "published" means, so
131 * a caller validating an id cannot disagree with the watcher serving it.
132 */
133export const readPublishedThemes = Effect.fn(function* (themesDir: string) {
134 const fs = yield* FileSystem.FileSystem;
135 const entries = yield* fs
136 .readDirectory(themesDir)
137 .pipe(Effect.orElseSucceed((): Array<string> => []));
138
139 const themes: Array<EnvironmentTheme> = [];
140 let examined = 0;
141 let totalBytes = 0;
142 for (const entry of entries.toSorted()) {
143 if (!entry.endsWith(THEME_FILE_SUFFIX)) continue;
144 const id = entry.slice(0, -THEME_FILE_SUFFIX.length);
145 // A reserved id is either shadowed by a built-in on the client or captures
146 // clients that never chose it, so it is not publishable.
147 if (!isEnvironmentThemeId(id) || UNPUBLISHABLE_THEME_IDS.has(id)) continue;
148
149 // Counts files examined, not themes accepted: capping the output would
150 // let a directory of malformed files be opened, read, and decoded in full
151 // on every refresh and every client connect.
152 examined += 1;
153 if (examined > MAX_THEME_FILES) {
154 yield* Effect.logWarning("ignoring environment theme files past the limit", {
155 path: themesDir,
156 limit: MAX_THEME_FILES,
157 });
158 break;
159 }
160
161 const filePath = `${themesDir}/${entry}`;
162 const raw = readThemeFileGuarded(filePath, MAX_THEME_FILE_BYTES);
163 if (raw === null) {
164 yield* Effect.logWarning("ignoring unusable environment theme file", {
165 path: filePath,
166 limit: MAX_THEME_FILE_BYTES,
167 });
168 continue;
169 }
170 if (raw.trim().length === 0) continue;
171
172 const decoded = decodeEnvironmentThemeFileJsonExit(raw);
173 if (decoded._tag === "Failure") {
174 yield* Effect.logWarning("ignoring invalid environment theme", {
175 path: filePath,
176 detail: Cause.pretty(decoded.cause),
177 });
178 continue;
179 }
180 const file = decoded.value;
181 if (!environmentThemeFileHasColors(file)) {
182 yield* Effect.logWarning("ignoring environment theme without colors", { path: filePath });
183 continue;
184 }
185
186 // Counted only once accepted: the cap bounds what travels to clients, so
187 // a skipped file must not eat the budget of valid themes sorted after it.
188 // Bytes, not string length -- the cap describes wire weight.
189 totalBytes += Buffer.byteLength(raw);
190 if (totalBytes > MAX_THEME_TOTAL_BYTES) {
191 yield* Effect.logWarning("ignoring environment themes past the total size limit", {
192 path: themesDir,
193 limit: MAX_THEME_TOTAL_BYTES,
194 });
195 break;
196 }
197
198 themes.push({ id, ...file });
199 }
200 return themes;
201});
202
203/**
204 * Reads the directory and folds it into the sequenced state, publishing only
205 * a genuine change. Every reader goes through here, so the snapshot a client
206 * connects on and the events it then receives come from one ordered source
207 * rather than from disk and the queue independently.
208 */
209
210const make = Effect.gen(function* () {
211 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
212 const fs = yield* FileSystem.FileSystem;
213 /**
214 * Sliding with capacity 1: every update carries the complete set, so a
215 * subscriber that stops consuming holds at most the newest set rather than
216 * an unbounded backlog. Every observed set carries a sequence number, so a
217 * subscriber can drop queued events that predate the snapshot it started
218 * from. Without it a publish landing between subscribing and reading
219 * replays after the newer value and walks clients backwards onto stale
220 * colors.
221 */
222 const changes = yield* PubSub.sliding<PublishedThemes>(1);
223 const published = yield* Ref.make<PublishedThemes>({ seq: 0, themes: [] });
224 /**
225 * Guards the whole read/compare/publish, not just the state update. The
226 * directory read is async, so two concurrent refreshes can finish out of
227 * order and a slower read of an older set would publish under a higher
228 * sequence -- which the subscriber filter, ordering publications rather than
229 * observations, could not then drop.
230 */
231 const refreshSemaphore = yield* Semaphore.make(1);
232 const watcherScope = yield* Scope.make("sequential");
233 yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));
234
235 const refresh = refreshSemaphore.withPermits(1)(
236 Effect.gen(function* () {
237 const themes = yield* readPublishedThemes(environmentThemesDir).pipe(
238 Effect.provideService(FileSystem.FileSystem, fs),
239 );
240 // Structural equality over the whole decoded value: a hand-rolled field
241 // list here silently drops republishes for any field it forgets.
242 const [changed, next] = yield* Ref.modify(
243 published,
244 (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => {
245 if (Equal.equals(previous.themes, themes)) return [[false, previous], previous];
246 const updated: PublishedThemes = { seq: previous.seq + 1, themes };
247 return [[true, updated], updated];
248 },
249 );
250 if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid);
251 return next;
252 }),
253 );
254
255 // The directory is created up front so the watcher has something to attach
256 // to before the first publisher writes into it.
257 yield* fs
258 .makeDirectory(environmentThemesDir, { recursive: true })
259 .pipe(Effect.ignoreCause({ log: true }));
260
261 // Debounced for the same reason settings watching is: a theme script emits
262 // several events per save and `fs.watch` can fire before the content is
263 // flushed. Every event triggers a full re-read, so no event needs filtering.
264 const watchEvents = fs.watch(environmentThemesDir).pipe(Stream.debounce(Duration.millis(100)));
265
266 // Seeds the dedupe so a watch event that reports no actual change (a touch,
267 // a rewrite with identical contents) does not retint every client.
268 yield* refresh;
269 yield* Stream.runForEach(watchEvents, () => refresh.pipe(Effect.ignoreCause({ log: true }))).pipe(
270 Effect.ignoreCause({ log: true }),
271 Effect.forkIn(watcherScope),
272 Effect.asVoid,
273 );
274
275 return {
276 current: Effect.map(refresh, (state) => state.themes),
277 get streamChanges() {
278 return Stream.unwrap(
279 Effect.gen(function* () {
280 // Subscribe first so nothing published during the read is missed,
281 // then drop anything the snapshot already accounts for.
282 const subscription = yield* PubSub.subscribe(changes);
283 const snapshot = yield* refresh;
284 return Stream.concat(
285 Stream.make(snapshot.themes),
286 Stream.fromSubscription(subscription).pipe(
287 Stream.filter((update) => update.seq > snapshot.seq),
288 Stream.map((update) => update.themes),
289 ),
290 );
291 }),
292 );
293 },
294 } satisfies EnvironmentThemeService["Service"];
295});
296
297export const layer = Layer.effect(EnvironmentThemeService, make);
apps/server/src/server.test.ts+94-7
...@@ -111,6 +111,7 @@ import {...@@ -111,6 +111,7 @@ import {
111} from "./ws.ts";111} from "./ws.ts";
112import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";112import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
113import * as GitManager from "./git/GitManager.ts";113import * as GitManager from "./git/GitManager.ts";
114import * as EnvironmentTheme from "./environmentTheme.ts";
114import * as Keybindings from "./keybindings.ts";115import * as Keybindings from "./keybindings.ts";
115import * as ExternalLauncher from "./process/externalLauncher.ts";116import * as ExternalLauncher from "./process/externalLauncher.ts";
116import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";117import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
...@@ -395,6 +396,7 @@ const buildAppUnderTest = (options?: {...@@ -395,6 +396,7 @@ const buildAppUnderTest = (options?: {
395 config?: Partial<ServerConfig.ServerConfig["Service"]>;396 config?: Partial<ServerConfig.ServerConfig["Service"]>;
396 layers?: {397 layers?: {
397 keybindings?: Partial<Keybindings.Keybindings["Service"]>;398 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
399 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;
398 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;400 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
399 providerService?: Partial<ProviderService.ProviderService["Service"]>;401 providerService?: Partial<ProviderService.ProviderService["Service"]>;
400 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;402 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
...@@ -630,14 +632,21 @@ const buildAppUnderTest = (options?: {...@@ -630,14 +632,21 @@ const buildAppUnderTest = (options?: {
630 },632 },
631 ).pipe(633 ).pipe(
632 Layer.provide(634 Layer.provide(
633 Layer.mock(Keybindings.Keybindings)({635 Layer.mergeAll(
634 loadConfigState: Effect.succeed({636 Layer.mock(Keybindings.Keybindings)({
635 keybindings: [],637 loadConfigState: Effect.succeed({
636 issues: [],638 keybindings: [],
639 issues: [],
640 }),
641 streamChanges: Stream.empty,
642 ...options?.layers?.keybindings,
637 }),643 }),
638 streamChanges: Stream.empty,644 Layer.mock(EnvironmentTheme.EnvironmentThemeService)({
639 ...options?.layers?.keybindings,645 current: Effect.succeed([]),
640 }),646 streamChanges: Stream.empty,
647 ...options?.layers?.environmentTheme,
648 }),
649 ),
641 ),650 ),
642 Layer.provide(651 Layer.provide(
643 Layer.mergeAll(652 Layer.mergeAll(
...@@ -4981,6 +4990,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4981,6 +4990,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4981 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4990 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4982 );4991 );
49834992
4993 // An already-shipped client decodes this stream against an event union
4994 // without environmentThemesUpdated, so an ungated emit would kill its whole
4995 // config subscription. Opting in is the only way to receive them.
4996 it.effect("subscribeServerConfig sends published themes to an opt-in subscriber", () =>
4997 Effect.gen(function* () {
4998 const themes = [
4999 {
5000 id: "nightfall",
5001 name: "Nightfall",
5002 appearance: "dark" as const,
5003 canvas: "#1a1b26",
5004 accent: "#7aa2f7",
5005 },
5006 ] as const;
5007
5008 yield* buildAppUnderTest({
5009 layers: {
5010 environmentTheme: {
5011 current: Effect.succeed(themes),
5012 streamChanges: Stream.succeed(themes),
5013 },
5014 },
5015 });
5016
5017 const wsUrl = yield* getWsServerUrl("/ws");
5018 const events = yield* Effect.scoped(
5019 withWsRpcClient(wsUrl, (client) =>
5020 client[WS_METHODS.subscribeServerConfig]({ environmentThemes: true }).pipe(
5021 Stream.take(2),
5022 Stream.runCollect,
5023 ),
5024 ),
5025 );
5026
5027 const [first, second] = Array.from(events);
5028 assert.equal(first?.type, "snapshot");
5029 // Not in the snapshot as well, or every opt-in client receives the same
5030 // array twice on every connect.
5031 if (first?.type === "snapshot") assert.equal(first.config.environmentThemes, undefined);
5032 assert.equal(second?.type, "environmentThemesUpdated");
5033 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5034 );
5035
5036 it.effect("subscribeServerConfig withholds published themes from other subscribers", () =>
5037 Effect.gen(function* () {
5038 const themes = [
5039 {
5040 id: "nightfall",
5041 name: "Nightfall",
5042 appearance: "dark" as const,
5043 canvas: "#1a1b26",
5044 accent: "#7aa2f7",
5045 },
5046 ] as const;
5047
5048 yield* buildAppUnderTest({
5049 layers: {
5050 environmentTheme: {
5051 current: Effect.succeed(themes),
5052 streamChanges: Stream.succeed(themes),
5053 },
5054 providerRegistry: { streamChanges: Stream.empty },
5055 },
5056 });
5057
5058 const wsUrl = yield* getWsServerUrl("/ws");
5059 const events = yield* Effect.scoped(
5060 withWsRpcClient(wsUrl, (client) =>
5061 client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(1), Stream.runCollect),
5062 ),
5063 );
5064
5065 const first = Array.from(events)[0];
5066 assert.equal(first?.type, "snapshot");
5067 if (first?.type === "snapshot") assert.equal(first.config.environmentThemes, undefined);
5068 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5069 );
5070
4984 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>5071 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
4985 Effect.gen(function* () {5072 Effect.gen(function* () {
4986 const nextProviders = [5073 const nextProviders = [
apps/server/src/server.ts+4-1
...@@ -53,6 +53,7 @@ import * as PreviewManager from "./preview/Manager.ts";...@@ -53,6 +53,7 @@ import * as PreviewManager from "./preview/Manager.ts";
53import * as PortScanner from "./preview/PortScanner.ts";53import * as PortScanner from "./preview/PortScanner.ts";
54import * as ProcessRunner from "./processRunner.ts";54import * as ProcessRunner from "./processRunner.ts";
55import * as GitManager from "./git/GitManager.ts";55import * as GitManager from "./git/GitManager.ts";
56import * as EnvironmentTheme from "./environmentTheme.ts";
56import * as Keybindings from "./keybindings.ts";57import * as Keybindings from "./keybindings.ts";
57import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";58import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
58import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts";59import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts";
...@@ -383,7 +384,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -383,7 +384,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
383 Layer.provideMerge(ProviderRuntimeLayerLive),384 Layer.provideMerge(ProviderRuntimeLayerLive),
384 Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)),385 Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)),
385 Layer.provideMerge(PersistenceLayerLive),386 Layer.provideMerge(PersistenceLayerLive),
386 Layer.provideMerge(Keybindings.layer),387 // Both read a user-owned file out of the state directory and stream changes
388 // to clients; neither depends on the other.
389 Layer.provideMerge(Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer)),
387 Layer.provideMerge(ProviderRegistryLive),390 Layer.provideMerge(ProviderRegistryLive),
388 // The instance registry is the new routing keystone — text generation,391 // The instance registry is the new routing keystone — text generation,
389 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`392 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`
apps/server/src/ws.ts+24-2
...@@ -73,6 +73,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";...@@ -73,6 +73,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
7373
74import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";74import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
75import * as ServerConfig from "./config.ts";75import * as ServerConfig from "./config.ts";
76import * as EnvironmentTheme from "./environmentTheme.ts";
76import * as Keybindings from "./keybindings.ts";77import * as Keybindings from "./keybindings.ts";
77import * as ExternalLauncher from "./process/externalLauncher.ts";78import * as ExternalLauncher from "./process/externalLauncher.ts";
78import {79import {
...@@ -492,6 +493,7 @@ const makeWsRpcLayer = (...@@ -492,6 +493,7 @@ const makeWsRpcLayer = (
492 };493 };
493 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;494 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
494 const keybindings = yield* Keybindings.Keybindings;495 const keybindings = yield* Keybindings.Keybindings;
496 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
495 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;497 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
496 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;498 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;
497 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;499 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
...@@ -2378,7 +2380,7 @@ const makeWsRpcLayer = (...@@ -2378,7 +2380,7 @@ const makeWsRpcLayer = (
2378 ),2380 ),
2379 { "rpc.aggregate": "preview" },2381 { "rpc.aggregate": "preview" },
2380 ),2382 ),
2381 [WS_METHODS.subscribeServerConfig]: (_input) =>2383 [WS_METHODS.subscribeServerConfig]: (input) =>
2382 observeRpcStreamEffect(2384 observeRpcStreamEffect(
2383 WS_METHODS.subscribeServerConfig,2385 WS_METHODS.subscribeServerConfig,
2384 Effect.gen(function* () {2386 Effect.gen(function* () {
...@@ -2400,6 +2402,23 @@ const makeWsRpcLayer = (...@@ -2400,6 +2402,23 @@ const makeWsRpcLayer = (
2400 })),2402 })),
2401 Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)),2403 Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)),
2402 );2404 );
2405 // The only source of published themes: the stream emits the
2406 // current set before any change, so the snapshot carrying it too
2407 // would just send every client the same array twice per connect.
2408 // Gated on the subscriber's capability flag because an
2409 // already-shipped client decodes this stream against the old
2410 // event union and its whole config subscription dies on an
2411 // unknown member.
2412 const environmentThemeUpdates =
2413 input.environmentThemes === true
2414 ? environmentTheme.streamChanges.pipe(
2415 Stream.map((themes) => ({
2416 version: 1 as const,
2417 type: "environmentThemesUpdated" as const,
2418 payload: { themes },
2419 })),
2420 )
2421 : Stream.empty;
2403 const settingsUpdates = serverSettings.streamChanges.pipe(2422 const settingsUpdates = serverSettings.streamChanges.pipe(
2404 Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)),2423 Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)),
2405 Stream.map((settings) => ({2424 Stream.map((settings) => ({
...@@ -2415,7 +2434,10 @@ const makeWsRpcLayer = (...@@ -2415,7 +2434,10 @@ const makeWsRpcLayer = (
24152434
2416 const liveUpdates = Stream.merge(2435 const liveUpdates = Stream.merge(
2417 keybindingsUpdates,2436 keybindingsUpdates,
2418 Stream.merge(providerStatuses, settingsUpdates),2437 Stream.merge(
2438 providerStatuses,
2439 Stream.merge(settingsUpdates, environmentThemeUpdates),
2440 ),
2419 );2441 );
24202442
2421 return Stream.concat(2443 return Stream.concat(
apps/web/src/components/settings/ThemeSettings.tsx+44-4
...@@ -10,9 +10,12 @@ import {...@@ -10,9 +10,12 @@ import {
10 UploadIcon,10 UploadIcon,
11} from "lucide-react";11} from "lucide-react";
12import { useCallback, useEffect, useState, type ReactElement } from "react";12import { useCallback, useEffect, useState, type ReactElement } from "react";
13import { useEnvironmentThemeDefinitions } from "../../hooks/useEnvironmentTheme";
14import { readThemeHalvesRaw } from "../../hooks/useTheme";
13import { cn } from "../../lib/utils";15import { cn } from "../../lib/utils";
14import {16import {
15 getThemeDefinition,17 getThemeDefinition,
18 singleAppearanceOf,
16 getThemeModes,19 getThemeModes,
17 removeCustomThemes,20 removeCustomThemes,
18 serializeThemeFile,21 serializeThemeFile,
...@@ -523,6 +526,7 @@ export function ThemeLibrary({...@@ -523,6 +526,7 @@ export function ThemeLibrary({
523 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;526 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;
524}) {527}) {
525 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);528 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);
529 const environmentThemes = useEnvironmentThemeDefinitions();
526 const [themeRemovalTarget, setThemeRemovalTarget] = useState<{530 const [themeRemovalTarget, setThemeRemovalTarget] = useState<{
527 theme: ThemeDefinition;531 theme: ThemeDefinition;
528 collectionThemes: ReadonlyArray<ThemeDefinition>;532 collectionThemes: ReadonlyArray<ThemeDefinition>;
...@@ -581,13 +585,17 @@ export function ThemeLibrary({...@@ -581,13 +585,17 @@ export function ThemeLibrary({
581 const removedIds = new Set(themeIdsToRemove);585 const removedIds = new Set(themeIdsToRemove);
582 if (removedIds.size === 0) return;586 if (removedIds.size === 0) return;
583 const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? "");587 const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? "");
588 // Captured raw before persistTheme clears the mix: a half naming a
589 // published theme whose set has not streamed in yet is pruned from the
590 // `themeHalves` prop, and rebuilding from that would drop it.
591 const storedHalves = readThemeHalvesRaw();
584 // Keep the themes installed if we cannot move the selection off one of592 // Keep the themes installed if we cannot move the selection off one of
585 // them; the dialog stays open so the user can retry or cancel.593 // them; the dialog stays open so the user can retry or cancel.
586 if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) {594 if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) {
587 return;595 return;
588 }596 }
589 for (const appearance of ["light", "dark"] as const) {597 for (const appearance of ["light", "dark"] as const) {
590 const half = themeHalves?.[appearance];598 const half = storedHalves[appearance];
591 if (half === undefined) continue;599 if (half === undefined) continue;
592 // Writing a base preference clears the whole mix, so halves that name600 // Writing a base preference clears the whole mix, so halves that name
593 // a surviving theme are written back; removed halves fall back to base.601 // a surviving theme are written back; removed halves fall back to base.
...@@ -610,7 +618,6 @@ export function ThemeLibrary({...@@ -610,7 +618,6 @@ export function ThemeLibrary({
610 persistTheme,618 persistTheme,
611 setThemeHalf,619 setThemeHalf,
612 theme,620 theme,
613 themeHalves,
614 themeIdsToRemove,621 themeIdsToRemove,
615 themeRemovalTarget,622 themeRemovalTarget,
616 ]);623 ]);
...@@ -629,7 +636,10 @@ export function ThemeLibrary({...@@ -629,7 +636,10 @@ export function ThemeLibrary({
629 // the base would still own that appearance. Convert the base into an636 // the base would still own that appearance. Convert the base into an
630 // explicit half on the other side so this side falls back to default.637 // explicit half on the other side so this side falls back to default.
631 if (cardId === null && baseCardId !== null) {638 if (cardId === null && baseCardId !== null) {
632 const otherOwner = themeHalves?.[otherAppearance] ?? baseCardId;639 // Read raw, before persistTheme clears the mix: the other half may
640 // name a published theme that has not streamed in yet, and falling
641 // back to the base would silently rewrite it.
642 const otherOwner = readThemeHalvesRaw()[otherAppearance] ?? baseCardId;
633 if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return;643 if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return;
634 if (!setThemeHalf(otherAppearance, otherOwner)) {644 if (!setThemeHalf(otherAppearance, otherOwner)) {
635 // Best-effort rollback: restore the whole-theme selection rather645 // Best-effort rollback: restore the whole-theme selection rather
...@@ -651,7 +661,6 @@ export function ThemeLibrary({...@@ -651,7 +661,6 @@ export function ThemeLibrary({
651 setTheme,661 setTheme,
652 setThemeHalf,662 setThemeHalf,
653 theme,663 theme,
654 themeHalves,
655 ],664 ],
656 );665 );
657666
...@@ -809,6 +818,37 @@ export function ThemeLibrary({...@@ -809,6 +818,37 @@ export function ThemeLibrary({
809 />818 />
810 );819 );
811 })}820 })}
821 {environmentThemes
822 .filter(
823 // A saved theme with the same id wins resolution, so its card is
824 // the one that must show; rendering both would also collide keys.
825 (environmentTheme) => !customThemes.some((theme) => theme.id === environmentTheme.id),
826 )
827 .map((environmentTheme) => (
828 // No edit or remove: the environment republishes these palettes on
829 // every change, so anything saved here would be overwritten.
830 // Duplicating is the way to keep a copy.
831 <ThemeLibraryCard
832 activeModes={pickedModesFor(environmentTheme.id)}
833 isActive={false}
834 key={environmentTheme.id}
835 onDuplicate={() =>
836 openThemeEditor({
837 editingThemeId: null,
838 seedThemeId: environmentTheme.id,
839 seedName: `${environmentTheme.label} copy`,
840 initialAppearance,
841 })
842 }
843 onUse={() => {
844 const half = singleAppearanceOf(environmentTheme);
845 if (half === null) persistTheme(environmentTheme.id);
846 else assignHalf(half, environmentTheme.id);
847 }}
848 onUseMode={handlePairPick(environmentTheme.id)}
849 theme={getThemeCardDefinition(environmentTheme)}
850 />
851 ))}
812 {customThemeCollections.map(([collectionId, themes]) => (852 {customThemeCollections.map(([collectionId, themes]) => (
813 <CustomThemeCollectionCard853 <CustomThemeCollectionCard
814 activeModesFor={pickedModesFor}854 activeModesFor={pickedModesFor}
apps/web/src/hooks/useDefaultTheme.test.ts created+58
...@@ -0,0 +1,58 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { defaultThemeGeneration, defaultThemeToApply } from "./useDefaultTheme";
4
5const BASE = {
6 environmentId: "env-1",
7 defaultTheme: "nightfall",
8 defaultThemeSetAt: "2026-08-28T00:00:00.000Z",
9 appliedGeneration: null,
10 resolves: true,
11} as const;
12
13describe("default theme adoption", () => {
14 it("applies a set this client has not seen", () => {
15 expect(defaultThemeToApply(BASE)).toBe(
16 defaultThemeGeneration(BASE.defaultTheme, BASE.defaultThemeSetAt),
17 );
18 });
19
20 it("does not replay a generation it already applied", () => {
21 const generation = defaultThemeGeneration(BASE.defaultTheme, BASE.defaultThemeSetAt);
22 expect(defaultThemeToApply({ ...BASE, appliedGeneration: generation })).toBe(null);
23 });
24
25 // `t3 theme set` of a theme this client already wears must still act, which
26 // is why the generation carries the set time and not just the value.
27 it("applies the same theme again when the environment re-sets it", () => {
28 const applied = defaultThemeGeneration(BASE.defaultTheme, "2026-08-28T00:00:00.000Z");
29 const next = defaultThemeToApply({
30 ...BASE,
31 defaultThemeSetAt: "2026-08-29T00:00:00.000Z",
32 appliedGeneration: applied,
33 });
34 expect(next).not.toBe(null);
35 expect(next).not.toBe(applied);
36 });
37
38 // Environments provisioned before the timestamp existed.
39 it("falls back to once-per-value without a set time", () => {
40 const generation = defaultThemeGeneration("nightfall", "");
41 expect(defaultThemeToApply({ ...BASE, defaultThemeSetAt: "", appliedGeneration: null })).toBe(
42 generation,
43 );
44 expect(
45 defaultThemeToApply({ ...BASE, defaultThemeSetAt: "", appliedGeneration: generation }),
46 ).toBe(null);
47 });
48
49 // The setting and the palette it names arrive independently.
50 it("waits for a theme that has not arrived yet", () => {
51 expect(defaultThemeToApply({ ...BASE, resolves: false })).toBe(null);
52 });
53
54 it("leaves a client alone with no environment or no theme set", () => {
55 expect(defaultThemeToApply({ ...BASE, environmentId: null })).toBe(null);
56 expect(defaultThemeToApply({ ...BASE, defaultTheme: "" })).toBe(null);
57 });
58});
apps/web/src/hooks/useDefaultTheme.ts created+115
...@@ -0,0 +1,115 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useEffect } from "react";
3
4import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment";
5import { primaryServerSettingsAtom } from "../state/server";
6import { getThemeDefinition, singleAppearanceOf } from "../themePalette";
7import { useEnvironmentThemeDefinitions } from "./useEnvironmentTheme";
8import { useTheme } from "./useTheme";
9
10/**
11 * Scoped per environment: each machine's `t3 theme set` is its own act, so
12 * hopping between primary environments neither replays one environment's
13 * theme over the user's pick nor swallows another's.
14 */
15const APPLIED_DEFAULT_THEME_STORAGE_PREFIX = "t3code:default-theme-applied:v2:";
16
17/**
18 * One generation per set: keyed on when the theme was set, not just its
19 * value, so re-asserting the same theme still acts. Environments provisioned
20 * by builds without the timestamp degrade to applying once per value.
21 */
22export function defaultThemeGeneration(theme: string, setAt: string): string {
23 return setAt.length > 0 ? `${theme}@${setAt}` : theme;
24}
25
26/**
27 * The generation to apply, or null to leave this client alone. Pure so the
28 * rule -- apply once per set, never replay one already applied, wait for a
29 * theme that has not arrived yet -- can be tested without a renderer.
30 */
31export function defaultThemeToApply(input: {
32 readonly environmentId: string | null;
33 readonly defaultTheme: string;
34 readonly defaultThemeSetAt: string;
35 readonly appliedGeneration: string | null;
36 readonly resolves: boolean;
37}): string | null {
38 if (input.environmentId === null || input.defaultTheme.length === 0) return null;
39 const generation = defaultThemeGeneration(input.defaultTheme, input.defaultThemeSetAt);
40 if (input.appliedGeneration === generation) return null;
41 // The setting and the palette it names arrive independently, so an id that
42 // does not resolve yet is not a failure -- the effect runs again when the
43 // published set changes.
44 if (!input.resolves) return null;
45 return generation;
46}
47
48function readAppliedGeneration(storageKey: string): string | null {
49 if (typeof window === "undefined") return null;
50 try {
51 return window.localStorage.getItem(storageKey);
52 } catch {
53 return null;
54 }
55}
56
57function writeAppliedGeneration(storageKey: string, generation: string): void {
58 try {
59 window.localStorage.setItem(storageKey, generation);
60 } catch {
61 // Unrecordable means the next config event applies again; harmless.
62 }
63}
64
65/**
66 * Applies the environment's theme (`t3 theme set <id>`). Each set switches
67 * this client once — live when connected, on the next connect otherwise —
68 * and then steps aside: a theme the user picks in Settings afterwards wins
69 * until the environment's theme is set again. The environment's own published
70 * themes are valid targets, which is why this waits for an id that does not
71 * resolve yet — the setting and the palette it names arrive independently.
72 */
73export function useDefaultThemeAdoption(): void {
74 const environmentId = useAtomValue(primaryEnvironmentIdAtom);
75 const settings = useAtomValue(primaryServerSettingsAtom);
76 const { defaultTheme, defaultThemeSetAt } = settings;
77 const { setTheme, setAppearanceMode } = useTheme();
78 // Re-runs adoption when a late-arriving published theme makes the
79 // requested id resolvable.
80 const environmentThemes = useEnvironmentThemeDefinitions();
81
82 useEffect(() => {
83 if (typeof window === "undefined" || environmentId === null) return;
84 const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`;
85 const definition = getThemeDefinition(defaultTheme);
86 const generation = defaultThemeToApply({
87 environmentId,
88 defaultTheme,
89 defaultThemeSetAt,
90 appliedGeneration: readAppliedGeneration(storageKey),
91 resolves: definition !== null,
92 });
93 if (generation === null || definition === null) return;
94
95 // Deliberately not the card's rule. Clicking a card is a user choosing one
96 // half of their own mix; a set theme is the environment saying what this
97 // client opens on, so it takes the base preference and, for a
98 // single-appearance theme, the matching mode -- otherwise a dark-only
99 // theme on a light client is recorded as applied while nothing changes.
100 if (!setTheme(defaultTheme)) return;
101 const half = singleAppearanceOf(definition);
102 // Recorded only once both land: marking the generation applied while the
103 // appearance switch failed would leave a dark-only theme rendering its
104 // light half with no retry.
105 if (half !== null && !setAppearanceMode(half)) return;
106 writeAppliedGeneration(storageKey, generation);
107 }, [
108 environmentId,
109 defaultTheme,
110 defaultThemeSetAt,
111 environmentThemes,
112 setTheme,
113 setAppearanceMode,
114 ]);
115}
apps/web/src/hooks/useEnvironmentTheme.test.ts created+212
...@@ -0,0 +1,212 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { environmentThemeDefinition, publishedThemeDefinitions } from "./useEnvironmentTheme";
4import {
5 getDefaultThemeColors,
6 getThemeDefinition,
7 installCustomTheme,
8 invalidateCustomThemes,
9 setEnvironmentThemes,
10 THEME_COLOR_ROLES,
11} from "../themePalette";
12
13const NIGHTFALL_THEME = {
14 id: "nightfall",
15 name: "Nightfall",
16 appearance: "dark",
17 canvas: "#1a1b26",
18 accent: "#7aa2f7",
19} as const;
20
21afterEach(() => {
22 setEnvironmentThemes([]);
23});
24
25describe("environment themes", () => {
26 it("generates every role from the two published seeds", () => {
27 const theme = environmentThemeDefinition(NIGHTFALL_THEME);
28
29 expect(theme.id).toBe("nightfall");
30 expect(theme.label).toBe("Nightfall");
31 expect(theme.appearance).toBe("dark");
32 for (const role of THEME_COLOR_ROLES) {
33 expect(theme.colors[role], `missing ${role}`).toBeTruthy();
34 }
35 });
36
37 it("layers published roles over the generated palette", () => {
38 const generated = environmentThemeDefinition(NIGHTFALL_THEME);
39 const overridden = environmentThemeDefinition({
40 ...NIGHTFALL_THEME,
41 colors: { terminalSelection: "#292e42", error: "#f7768e" },
42 });
43
44 expect(overridden.colors.terminalSelection).not.toBe(generated.colors.terminalSelection);
45 expect(overridden.colors.error).not.toBe(generated.colors.error);
46 // Roles the machine did not publish keep the generated value.
47 expect(overridden.colors.sidebar).toBe(generated.colors.sidebar);
48 });
49
50 // The standard exported theme file — the Download button's output — is a
51 // valid published theme, so any shared theme can be dropped into the
52 // machine's themes directory as-is.
53 it("renders an exported theme file on the stock defaults", () => {
54 const theme = environmentThemeDefinition({
55 id: "shared-light",
56 version: 1,
57 name: "Shared Light",
58 appearance: "light",
59 colors: { canvas: "oklch(0.95 0.01 250)", accent: "#1e66f5" },
60 variants: { dark: { canvas: "#1a1b26" } },
61 });
62
63 expect(theme.label).toBe("Shared Light");
64 expect(theme.colors.accent).toBeTruthy();
65 expect(theme.variants?.dark?.canvas).toBeTruthy();
66 for (const role of THEME_COLOR_ROLES) {
67 expect(theme.colors[role], `missing ${role}`).toBeTruthy();
68 expect(theme.variants?.dark?.[role], `missing dark ${role}`).toBeTruthy();
69 }
70 });
71
72 // The generator follows the seed canvas's luminance, so a dark theme's
73 // seeds must never produce its light variant: the variant builds on that
74 // appearance's stock defaults instead.
75 it("builds a seeded theme's variant on the variant appearance's defaults", () => {
76 const theme = environmentThemeDefinition({
77 ...NIGHTFALL_THEME,
78 variants: { light: { canvas: "#eff1f5" } },
79 });
80
81 const lightDefaults = getDefaultThemeColors("light");
82 expect(theme.variants?.light?.text).toBe(lightDefaults.text);
83 expect(theme.variants?.light?.canvas).not.toBe(theme.colors.canvas);
84 });
85
86 // A published `t3-iris.json` would show its palette on a card that applies
87 // the built-in, and a published `dark.json` would capture everyone whose
88 // stored preference is the stock "dark". Reserved ids never become cards.
89 it("drops published themes with reserved ids", () => {
90 const definitions = publishedThemeDefinitions([
91 NIGHTFALL_THEME,
92 { ...NIGHTFALL_THEME, id: "t3-iris", name: "Impostor Iris" },
93 { ...NIGHTFALL_THEME, id: "ocean", name: "Impostor Ocean" },
94 { ...NIGHTFALL_THEME, id: "dark", name: "Impostor Dark" },
95 ]);
96
97 expect(definitions.map((definition) => definition.id)).toEqual(["nightfall"]);
98 });
99
100 it("drops published palettes with no usable colors", () => {
101 const theme = { id: "invalid", name: "Invalid", appearance: "dark" } as const;
102 const definitions = publishedThemeDefinitions([
103 { ...theme, colors: { canvas: "not-a-color" } },
104 { ...theme, id: "unknown-roles", colors: { futureRole: "#ffffff" } },
105 {
106 ...theme,
107 id: "ignored-variant",
108 colors: { canvas: "not-a-color" },
109 variants: { dark: { canvas: "#112233" } },
110 },
111 ]);
112
113 expect(definitions).toEqual([]);
114 });
115
116 it("keeps seeds, partial palettes, and usable alternate appearances", () => {
117 const theme = { name: "Shared", appearance: "dark" } as const;
118 const definitions = publishedThemeDefinitions([
119 NIGHTFALL_THEME,
120 {
121 ...theme,
122 id: "partial",
123 colors: { canvas: "not-a-color", text: "#ffffff", futureRole: "#ffffff" },
124 },
125 {
126 ...theme,
127 id: "light-variant",
128 colors: { canvas: "not-a-color" },
129 variants: { light: { canvas: "#eff1f5" } },
130 },
131 ]);
132
133 expect(definitions.map((definition) => definition.id)).toEqual([
134 "nightfall",
135 "partial",
136 "light-variant",
137 ]);
138 });
139
140 // A machine may publish roles a newer client added; an older one has to
141 // ignore them rather than render a broken palette.
142 it("ignores published roles this build does not render", () => {
143 const theme = environmentThemeDefinition({
144 ...NIGHTFALL_THEME,
145 colors: { notARole: "#ff0000", text: "#ffffff" },
146 });
147
148 expect(theme.colors).not.toHaveProperty("notARole");
149 for (const role of THEME_COLOR_ROLES) {
150 expect(theme.colors[role], `missing ${role}`).toBeTruthy();
151 }
152 });
153
154 // ThemeEditorPanel opens Duplicate in the guided editor for managed themes,
155 // and the guided editor regenerates from canvas and accent -- which would
156 // discard any role the machine tuned by hand.
157 it("marks only the pure seeded form as managed", () => {
158 expect(environmentThemeDefinition(NIGHTFALL_THEME).managed).toBe(true);
159 expect(
160 environmentThemeDefinition({ ...NIGHTFALL_THEME, colors: { error: "#f7768e" } }).managed,
161 ).toBeUndefined();
162 expect(
163 environmentThemeDefinition({
164 id: "shared-light",
165 version: 1,
166 name: "Shared Light",
167 appearance: "light",
168 colors: { canvas: "#eff1f5", accent: "#1e66f5" },
169 }).managed,
170 ).toBeUndefined();
171 });
172
173 it("resolves published ids only while the machine publishes them", () => {
174 expect(getThemeDefinition("nightfall")).toBe(null);
175
176 setEnvironmentThemes([environmentThemeDefinition(NIGHTFALL_THEME)]);
177 expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall");
178
179 // The palettes are never saved, so they have to disappear with the
180 // machine that published them rather than linger as stale entries.
181 setEnvironmentThemes([]);
182 expect(getThemeDefinition("nightfall")).toBe(null);
183 });
184
185 // Published ids share one namespace with the user's saved themes, and the
186 // user was here first: their theme keeps working even if the machine later
187 // publishes under the same id.
188 it("lets a theme the user saved win an id collision", () => {
189 const store = new Map<string, string>();
190 vi.stubGlobal("window", {
191 addEventListener: vi.fn(),
192 removeEventListener: vi.fn(),
193 localStorage: {
194 getItem: (key: string) => store.get(key) ?? null,
195 setItem: (key: string, value: string) => store.set(key, value),
196 removeItem: (key: string) => store.delete(key),
197 },
198 });
199 invalidateCustomThemes();
200 try {
201 const environment = environmentThemeDefinition(NIGHTFALL_THEME);
202 setEnvironmentThemes([environment]);
203 installCustomTheme({ ...environment, label: "My Nightfall" });
204
205 expect(getThemeDefinition("nightfall")?.label).toBe("My Nightfall");
206 } finally {
207 vi.unstubAllGlobals();
208 invalidateCustomThemes();
209 }
210 expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall");
211 });
212});
apps/web/src/hooks/useEnvironmentTheme.ts created+125
...@@ -0,0 +1,125 @@
1import type { EnvironmentTheme } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";
3import * as Equal from "effect/Equal";
4import { useEffect, useRef, useSyncExternalStore } from "react";
5
6import { primaryServerEnvironmentThemesAtom } from "../state/server";
7import {
8 createVividThemeColors,
9 getDefaultThemeColors,
10 getEnvironmentThemes,
11 isReservedThemeId,
12 lenientThemeColorOverrides,
13 setEnvironmentThemes,
14 subscribeToCustomThemes,
15 type ThemeAppearance,
16 type ThemeColors,
17 type ThemeDefinition,
18} from "../themePalette";
19import { useTheme } from "./useTheme";
20
21function publishedThemeColors(
22 theme: EnvironmentTheme,
23 appearance: ThemeAppearance,
24 colors: Readonly<Record<string, string>> | undefined,
25): ThemeColors {
26 // Seeds generate the base with the guided theme editor's generator, so a
27 // desktop theme arrives as a coherent T3 Code palette rather than a foreign
28 // one — but only for the appearance they describe. A variant builds on that
29 // appearance's stock defaults: the generator follows the seed canvas's
30 // luminance, so dark seeds would give a light variant unreadable colors.
31 const base =
32 appearance === theme.appearance && theme.canvas !== undefined && theme.accent !== undefined
33 ? createVividThemeColors(appearance, theme.canvas, theme.accent)
34 : getDefaultThemeColors(appearance);
35 return { ...base, ...lenientThemeColorOverrides(colors ?? {}) };
36}
37
38/**
39 * A published theme as the theme library renders it. Both published forms are
40 * accepted: the seeded short form a desktop generates, and the standard
41 * exported theme file the Download button produces — so any theme someone
42 * shared can be dropped into the machine's themes directory as-is.
43 */
44export function environmentThemeDefinition(theme: EnvironmentTheme): ThemeDefinition {
45 const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {};
46 for (const [variantAppearance, variantColors] of Object.entries(theme.variants ?? {})) {
47 if (variantAppearance === theme.appearance) continue;
48 variants[variantAppearance as ThemeAppearance] = publishedThemeColors(
49 theme,
50 variantAppearance as ThemeAppearance,
51 variantColors,
52 );
53 }
54
55 return {
56 id: theme.id,
57 label: theme.name,
58 appearance: theme.appearance,
59 colors: publishedThemeColors(theme, theme.appearance, theme.colors),
60 ...(Object.keys(variants).length > 0 ? { variants } : {}),
61 // Only the pure seeded form is guided-generator output. An exported file,
62 // or seeds carrying explicit role overrides, must open Duplicate in the
63 // advanced editor -- the guided one regenerates from canvas and accent and
64 // would discard whatever the machine hand-tuned.
65 ...(theme.canvas !== undefined &&
66 theme.accent !== undefined &&
67 theme.colors === undefined &&
68 theme.variants === undefined
69 ? { managed: true }
70 : {}),
71 };
72}
73
74/**
75 * Published palettes this client can render. Reserved ids are dropped here rather
76 * than rendered: a published `t3-iris.json` would show this palette on its
77 * card while "Use" resolved the built-in, and a published `dark.json` would
78 * capture everyone whose stored preference is the stock `"dark"`.
79 */
80export function publishedThemeDefinitions(
81 themes: ReadonlyArray<EnvironmentTheme>,
82): ReadonlyArray<ThemeDefinition> {
83 return themes
84 .filter((theme) => {
85 if (isReservedThemeId(theme.id)) return false;
86 if (theme.canvas !== undefined && theme.accent !== undefined) return true;
87 const otherAppearance = theme.appearance === "dark" ? "light" : "dark";
88 return [theme.colors, theme.variants?.[otherAppearance]].some(
89 (colors) =>
90 colors !== undefined && Object.keys(lenientThemeColorOverrides(colors)).length > 0,
91 );
92 })
93 .map(environmentThemeDefinition);
94}
95
96/** The published themes as library entries; empty while none are published. */
97export function useEnvironmentThemeDefinitions(): ReadonlyArray<ThemeDefinition> {
98 return useSyncExternalStore(subscribeToCustomThemes, getEnvironmentThemes, () => []);
99}
100
101/**
102 * Keeps the machine's published themes in the theme library for as long as
103 * the primary environment publishes them. A client with a published theme
104 * selected retints the moment the machine rewrites it; everyone else just
105 * gains cards in the theme library.
106 */
107export function useEnvironmentThemeSync(): void {
108 const published = useAtomValue(primaryServerEnvironmentThemesAtom);
109 const { refreshTheme } = useTheme();
110 const lastPublished = useRef<ReadonlyArray<EnvironmentTheme> | null>(null);
111
112 useEffect(() => {
113 // Every reconnect snapshot delivers a fresh but usually identical array;
114 // regenerating palettes and repainting the document for it is exactly the
115 // wasted-frame class this codebase audits for.
116 if (lastPublished.current !== null && Equal.equals(lastPublished.current, published)) return;
117 lastPublished.current = published;
118
119 // The palette is painted from a snapshot taken when the theme last
120 // changed, so new colors only land if the active theme is re-applied.
121 if (setEnvironmentThemes(publishedThemeDefinitions(published))) {
122 refreshTheme({ preservePreview: true });
123 }
124 }, [published, refreshTheme]);
125}
apps/web/src/hooks/useEnvironmentThemeSync.test.ts created+181
...@@ -0,0 +1,181 @@
1import type { EnvironmentTheme } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4const NIGHTFALL_THEME = {
5 id: "nightfall",
6 name: "Nightfall",
7 appearance: "dark",
8 canvas: "#1a1b26",
9 accent: "#7aa2f7",
10} as const satisfies EnvironmentTheme;
11
12const LIGHT_THEME = {
13 ...NIGHTFALL_THEME,
14 appearance: "light",
15 canvas: "#eff1f5",
16} as const satisfies EnvironmentTheme;
17
18async function setupThemeSync(mode: "dark" | "system" = "dark") {
19 const storage = new Map<string, string>([["t3code:theme", NIGHTFALL_THEME.id]]);
20 const styles = new Map<string, string>();
21 const classes = new Set<string>();
22 const root = {
23 dataset: {} as Record<string, string>,
24 style: {
25 setProperty: (name: string, value: string) => styles.set(name, value),
26 removeProperty: (name: string) => styles.delete(name),
27 },
28 classList: {
29 add: (name: string) => classes.add(name),
30 remove: (name: string) => classes.delete(name),
31 toggle: (name: string, enabled: boolean) =>
32 enabled ? classes.add(name) : classes.delete(name),
33 contains: (name: string) => classes.has(name),
34 },
35 };
36 vi.stubGlobal("document", { documentElement: root });
37 vi.stubGlobal("window", {
38 localStorage: {
39 getItem: (key: string) => storage.get(key) ?? null,
40 setItem: (key: string, value: string) => storage.set(key, value),
41 removeItem: (key: string) => storage.delete(key),
42 },
43 addEventListener: vi.fn(),
44 removeEventListener: vi.fn(),
45 matchMedia: () => ({
46 matches: true,
47 addEventListener: vi.fn(),
48 removeEventListener: vi.fn(),
49 }),
50 });
51 vi.stubGlobal("requestAnimationFrame", vi.fn());
52
53 let published: ReadonlyArray<EnvironmentTheme> = [NIGHTFALL_THEME];
54 let readSnapshot: (() => unknown) | undefined;
55 const effects: Array<() => void> = [];
56 const lastPublished: { current: ReadonlyArray<EnvironmentTheme> | null } = { current: null };
57 vi.doMock("react", () => ({
58 useCallback: <A>(callback: A) => callback,
59 useEffect: (effect: () => void) => effects.push(effect),
60 useRef: () => lastPublished,
61 useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => {
62 readSnapshot = getSnapshot;
63 return getSnapshot();
64 },
65 }));
66 vi.doMock("@effect/atom-react", () => ({ useAtomValue: () => published }));
67 vi.doMock("../state/server", () => ({ primaryServerEnvironmentThemesAtom: {} }));
68
69 const palette = await import("../themePalette");
70 storage.set(palette.THEME_APPEARANCE_MODE_STORAGE_KEY, mode);
71 const { useTheme } = await import("./useTheme");
72 const { useEnvironmentThemeSync } = await import("./useEnvironmentTheme");
73 const flushEffects = () => {
74 for (const effect of effects.splice(0)) effect();
75 };
76 const publish = (themes: ReadonlyArray<EnvironmentTheme>) => {
77 published = themes;
78 useEnvironmentThemeSync();
79 flushEffects();
80 // A changed store snapshot also runs the consumer's passive theme effect.
81 const theme = useTheme();
82 flushEffects();
83 return theme;
84 };
85 publish(published);
86
87 return { publish, palette, root, styles, readSnapshot: () => readSnapshot?.() };
88}
89
90afterEach(() => {
91 vi.doUnmock("react");
92 vi.doUnmock("@effect/atom-react");
93 vi.doUnmock("../state/server");
94 vi.resetModules();
95 vi.restoreAllMocks();
96 vi.unstubAllGlobals();
97});
98
99describe("published theme refresh", () => {
100 it.each(["dark", "system"] as const)(
101 "updates the React snapshot when appearance changes in %s mode",
102 async (mode) => {
103 const { publish, root, readSnapshot } = await setupThemeSync(mode);
104 const darkSnapshot = readSnapshot();
105
106 expect(publish([LIGHT_THEME]).resolvedTheme).toBe("light");
107 expect(root.classList.contains("dark")).toBe(false);
108 const lightSnapshot = readSnapshot();
109 expect(lightSnapshot).not.toBe(darkSnapshot);
110
111 expect(publish([NIGHTFALL_THEME]).resolvedTheme).toBe("dark");
112 expect(root.classList.contains("dark")).toBe(true);
113 expect(readSnapshot()).not.toBe(lightSnapshot);
114 },
115 );
116
117 it("keeps the React snapshot stable for color-only changes", async () => {
118 const { publish, palette, styles, readSnapshot } = await setupThemeSync();
119 const before = readSnapshot();
120 const accentVariable = palette.getThemeColorVariable("accent");
121 const previousAccent = styles.get(accentVariable);
122
123 publish([{ ...NIGHTFALL_THEME, accent: "#ff0000" }]);
124
125 expect(styles.get(accentVariable)).not.toBe(previousAccent);
126 expect(readSnapshot()).toBe(before);
127 });
128
129 it("keeps a draft visible while updating the stored selection underneath it", async () => {
130 const { publish, palette, root, styles } = await setupThemeSync();
131 const draft = { ...palette.getDefaultThemeColors("light"), canvas: "#234567" };
132 palette.applyThemeColorPreview(draft, "light");
133 const canvasVariable = palette.getThemeColorVariable("canvas");
134
135 const expectPreview = () => {
136 expect(root.dataset.themeId).toBe(palette.THEME_PREVIEW_ID);
137 expect(styles.get(canvasVariable)).toBe(draft.canvas);
138 expect(root.classList.contains("dark")).toBe(false);
139 };
140 expect(publish([LIGHT_THEME]).resolvedTheme).toBe("light");
141 expectPreview();
142 publish([LIGHT_THEME, { ...NIGHTFALL_THEME, id: "unused-theme" }]);
143 expectPreview();
144 expect(publish([]).theme).toBe("system");
145 expectPreview();
146
147 const current = publish([{ ...NIGHTFALL_THEME, canvas: "#112233" }]);
148 expect(current.theme).toBe(NIGHTFALL_THEME.id);
149 expect(current.resolvedTheme).toBe("dark");
150 expectPreview();
151
152 current.refreshTheme();
153 expect(root.dataset.themeId).toBe(NIGHTFALL_THEME.id);
154 expect(styles.get(canvasVariable)).toBe(
155 palette.getThemeDefinition(NIGHTFALL_THEME.id)?.colors.canvas,
156 );
157 expect(root.classList.contains("dark")).toBe(true);
158 expect(palette.getThemePreviewSidebarArtwork()).toBeNull();
159 });
160
161 it("keeps a draft visible when default adoption changes the theme and appearance", async () => {
162 const { publish, palette, root, readSnapshot } = await setupThemeSync();
163 const current = publish([NIGHTFALL_THEME]);
164 palette.applyThemeColorPreview(palette.getDefaultThemeColors("dark"), "dark");
165
166 expect(current.setTheme("ocean")).toBe(true);
167 expect(current.setAppearanceMode("light")).toBe(true);
168 expect(readSnapshot()).toMatchObject({
169 theme: "ocean",
170 appearanceMode: "light",
171 resolvedTheme: "light",
172 });
173 expect(root.dataset.themeId).toBe(palette.THEME_PREVIEW_ID);
174 expect(root.classList.contains("dark")).toBe(true);
175
176 current.refreshTheme();
177 expect(root.dataset.themeId).toBe("ocean");
178 expect(root.classList.contains("dark")).toBe(false);
179 expect(palette.getThemePreviewSidebarArtwork()).toBeNull();
180 });
181});
apps/web/src/hooks/useTheme.ts+65-24
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 resolveDesktopTheme,13 resolveDesktopTheme,
14 resolveThemeAppearance,14 resolveThemeAppearance,
15 resolveThemeHalf,15 resolveThemeHalf,
16 THEME_PREVIEW_ID,
16 THEME_APPEARANCE_MODE_STORAGE_KEY,17 THEME_APPEARANCE_MODE_STORAGE_KEY,
17 THEME_FOLLOW_SYSTEM_STORAGE_KEY,18 THEME_FOLLOW_SYSTEM_STORAGE_KEY,
18 THEME_HALVES_STORAGE_KEY,19 THEME_HALVES_STORAGE_KEY,
...@@ -25,6 +26,7 @@ import {...@@ -25,6 +26,7 @@ import {
25type Theme = ThemePreference;26type Theme = ThemePreference;
26type ThemeSnapshot = {27type ThemeSnapshot = {
27 theme: Theme;28 theme: Theme;
29 resolvedTheme: ThemeAppearance;
28 systemDark: boolean;30 systemDark: boolean;
29 followSystem: boolean;31 followSystem: boolean;
30 appearanceMode: ThemePreferenceMode;32 appearanceMode: ThemePreferenceMode;
...@@ -37,6 +39,7 @@ const STORAGE_KEY = "t3code:theme";...@@ -37,6 +39,7 @@ const STORAGE_KEY = "t3code:theme";
37const MEDIA_QUERY = "(prefers-color-scheme: dark)";39const MEDIA_QUERY = "(prefers-color-scheme: dark)";
38const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {40const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
39 theme: "system",41 theme: "system",
42 resolvedTheme: "light",
40 systemDark: false,43 systemDark: false,
41 followSystem: true,44 followSystem: true,
42 appearanceMode: "system",45 appearanceMode: "system",
...@@ -49,6 +52,17 @@ export function readThemeHalves(): ThemeHalves | null {...@@ -49,6 +52,17 @@ export function readThemeHalves(): ThemeHalves | null {
49 return readStoredThemeHalves();52 return readStoredThemeHalves();
50}53}
5154
55/**
56 * The stored mix as written, without resolvability pruning. Flows that
57 * rebuild the whole mix must capture this before a `setTheme` clears it: a
58 * published id resolves only once its set has streamed in, and treating "not
59 * resolvable yet" as "absent" silently rewrites that half.
60 */
61export function readThemeHalvesRaw(): { light?: string; dark?: string } {
62 if (typeof window === "undefined") return {};
63 return readStoredThemeHalvesRaw();
64}
65
52function readStoredThemeHalves(): ThemeHalves | null {66function readStoredThemeHalves(): ThemeHalves | null {
53 if (typeof window === "undefined") return null;67 if (typeof window === "undefined") return null;
54 try {68 try {
...@@ -58,6 +72,29 @@ function readStoredThemeHalves(): ThemeHalves | null {...@@ -58,6 +72,29 @@ function readStoredThemeHalves(): ThemeHalves | null {
58 }72 }
59}73}
6074
75/**
76 * The stored mix as written, without resolvability pruning. An environment
77 * published id resolves only once its set has streamed in, so a write that
78 * merged over the pruned parse would silently erase that half whenever the
79 * other one changed before the set arrived.
80 */
81function readStoredThemeHalvesRaw(): { light?: string; dark?: string } {
82 try {
83 const value: unknown = JSON.parse(
84 window.localStorage.getItem(THEME_HALVES_STORAGE_KEY) ?? "null",
85 );
86 if (value === null || typeof value !== "object") return {};
87 const halves: { light?: string; dark?: string } = {};
88 for (const appearance of ["light", "dark"] as const) {
89 const themeId = (value as Record<string, unknown>)[appearance];
90 if (typeof themeId === "string") halves[appearance] = themeId;
91 }
92 return halves;
93 } catch {
94 return {};
95 }
96}
97
61function themeHalvesSignature(halves: ThemeHalves | null): string {98function themeHalvesSignature(halves: ThemeHalves | null): string {
62 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;99 return `${halves?.light ?? ""}|${halves?.dark ?? ""}`;
63}100}
...@@ -98,7 +135,7 @@ let listeners: Array<() => void> = [];...@@ -98,7 +135,7 @@ let listeners: Array<() => void> = [];
98let lastSnapshot: ThemeSnapshot | null = null;135let lastSnapshot: ThemeSnapshot | null = null;
99let snapshotStale = true;136let snapshotStale = true;
100let lastDesktopTheme: "light" | "dark" | "system" | null = null;137let lastDesktopTheme: "light" | "dark" | "system" | null = null;
101let lastAppliedTheme: ThemeSnapshot | null = null;138let lastAppliedTheme: Omit<ThemeSnapshot, "resolvedTheme"> | null = null;
102let themeStorageReadFailure: ThemeStorageError | null = null;139let themeStorageReadFailure: ThemeStorageError | null = null;
103140
104function emitChange() {141function emitChange() {
...@@ -282,8 +319,10 @@ export function syncBrowserChromeTheme() {...@@ -282,8 +319,10 @@ export function syncBrowserChromeTheme() {
282 }319 }
283}320}
284321
285function applyTheme(theme: Theme, suppressTransitions = false) {322function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) {
286 if (typeof document === "undefined" || typeof window === "undefined") return;323 if (typeof document === "undefined" || typeof window === "undefined") return;
324 // Keep the editor's draft visible until an explicit refresh restores the selection.
325 if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return;
287 const appearanceMode = readAppearanceModePreference(theme);326 const appearanceMode = readAppearanceModePreference(theme);
288 const followSystem = appearanceMode === "system";327 const followSystem = appearanceMode === "system";
289 const systemDark = followSystem ? getSystemDark() : false;328 const systemDark = followSystem ? getSystemDark() : false;
...@@ -386,9 +425,17 @@ function getSnapshot(): ThemeSnapshot {...@@ -386,9 +425,17 @@ function getSnapshot(): ThemeSnapshot {
386 const systemDark = followSystem ? getSystemDark() : false;425 const systemDark = followSystem ? getSystemDark() : false;
387 const themeHalves = readStoredThemeHalves();426 const themeHalves = readStoredThemeHalves();
388427
428 const resolvedTheme = resolveThemeAppearance(
429 theme,
430 systemDark,
431 followSystem,
432 appearanceMode,
433 themeHalves,
434 );
389 if (435 if (
390 lastSnapshot &&436 lastSnapshot &&
391 lastSnapshot.theme === theme &&437 lastSnapshot.theme === theme &&
438 lastSnapshot.resolvedTheme === resolvedTheme &&
392 lastSnapshot.systemDark === systemDark &&439 lastSnapshot.systemDark === systemDark &&
393 lastSnapshot.followSystem === followSystem &&440 lastSnapshot.followSystem === followSystem &&
394 lastSnapshot.appearanceMode === appearanceMode &&441 lastSnapshot.appearanceMode === appearanceMode &&
...@@ -397,7 +444,7 @@ function getSnapshot(): ThemeSnapshot {...@@ -397,7 +444,7 @@ function getSnapshot(): ThemeSnapshot {
397 return lastSnapshot;444 return lastSnapshot;
398 }445 }
399446
400 lastSnapshot = { theme, systemDark, followSystem, appearanceMode, themeHalves };447 lastSnapshot = { theme, resolvedTheme, systemDark, followSystem, appearanceMode, themeHalves };
401 return lastSnapshot;448 return lastSnapshot;
402}449}
403450
...@@ -407,26 +454,28 @@ function getServerSnapshot() {...@@ -407,26 +454,28 @@ function getServerSnapshot() {
407454
408function handleSystemAppearanceChange() {455function handleSystemAppearanceChange() {
409 const storedTheme = getStored();456 const storedTheme = getStored();
410 if (readAppearanceModePreference(storedTheme) === "system") applyTheme(storedTheme, true);457 if (readAppearanceModePreference(storedTheme) === "system") {
458 applyTheme(storedTheme, { suppressTransitions: true });
459 }
411 emitChange();460 emitChange();
412}461}
413462
414function handleStorageChange(e: StorageEvent) {463function handleStorageChange(e: StorageEvent) {
415 if (e.key === STORAGE_KEY) {464 if (e.key === STORAGE_KEY) {
416 themeStorageReadFailure = null;465 themeStorageReadFailure = null;
417 applyTheme(getStored(), true);466 applyTheme(getStored(), { suppressTransitions: true });
418 emitChange();467 emitChange();
419 } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) {468 } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) {
420 applyTheme(getStored(), true);469 applyTheme(getStored(), { suppressTransitions: true });
421 emitChange();470 emitChange();
422 } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) {471 } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) {
423 applyTheme(getStored(), true);472 applyTheme(getStored(), { suppressTransitions: true });
424 emitChange();473 emitChange();
425 } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) {474 } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) {
426 if (e.key === null) themeStorageReadFailure = null;475 if (e.key === null) themeStorageReadFailure = null;
427 invalidateCustomThemes();476 invalidateCustomThemes();
428 lastAppliedTheme = null;477 lastAppliedTheme = null;
429 applyTheme(getStored(), true);478 applyTheme(getStored(), { suppressTransitions: true });
430 emitChange();479 emitChange();
431 }480 }
432}481}
...@@ -460,15 +509,7 @@ function subscribe(listener: () => void): () => void {...@@ -460,15 +509,7 @@ function subscribe(listener: () => void): () => void {
460509
461export function useTheme() {510export function useTheme() {
462 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);511 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
463 const theme = snapshot.theme;512 const { theme, resolvedTheme } = snapshot;
464
465 const resolvedTheme: "light" | "dark" = resolveThemeAppearance(
466 theme,
467 snapshot.systemDark,
468 snapshot.followSystem,
469 snapshot.appearanceMode,
470 snapshot.themeHalves,
471 );
472513
473 const setTheme = useCallback((next: Theme): boolean => {514 const setTheme = useCallback((next: Theme): boolean => {
474 if (typeof window === "undefined") return false;515 if (typeof window === "undefined") return false;
...@@ -511,7 +552,7 @@ export function useTheme() {...@@ -511,7 +552,7 @@ export function useTheme() {
511 });552 });
512 return false;553 return false;
513 }554 }
514 applyTheme(next, true);555 applyTheme(next, { suppressTransitions: true });
515 emitChange();556 emitChange();
516 return true;557 return true;
517 }, []);558 }, []);
...@@ -536,7 +577,7 @@ export function useTheme() {...@@ -536,7 +577,7 @@ export function useTheme() {
536 return false;577 return false;
537 }578 }
538 themeStorageReadFailure = null;579 themeStorageReadFailure = null;
539 applyTheme(getStored(), true);580 applyTheme(getStored(), { suppressTransitions: true });
540 emitChange();581 emitChange();
541 return true;582 return true;
542 }, []);583 }, []);
...@@ -558,7 +599,7 @@ export function useTheme() {...@@ -558,7 +599,7 @@ export function useTheme() {
558 (appearance: ThemeAppearance, themeId: string | null): boolean => {599 (appearance: ThemeAppearance, themeId: string | null): boolean => {
559 if (typeof window === "undefined") return false;600 if (typeof window === "undefined") return false;
560 try {601 try {
561 const current = readStoredThemeHalves() ?? {};602 const current = readStoredThemeHalvesRaw();
562 const next: { light?: string; dark?: string } = { ...current };603 const next: { light?: string; dark?: string } = { ...current };
563 if (themeId === null) delete next[appearance];604 if (themeId === null) delete next[appearance];
564 else next[appearance] = themeId;605 else next[appearance] = themeId;
...@@ -580,7 +621,7 @@ export function useTheme() {...@@ -580,7 +621,7 @@ export function useTheme() {
580 });621 });
581 return false;622 return false;
582 }623 }
583 applyTheme(getStored(), true);624 applyTheme(getStored(), { suppressTransitions: true });
584 emitChange();625 emitChange();
585 return true;626 return true;
586 },627 },
...@@ -604,15 +645,15 @@ export function useTheme() {...@@ -604,15 +645,15 @@ export function useTheme() {
604 });645 });
605 return false;646 return false;
606 }647 }
607 applyTheme(getStored(), true);648 applyTheme(getStored(), { suppressTransitions: true });
608 emitChange();649 emitChange();
609 return true;650 return true;
610 }, []);651 }, []);
611652
612 const refreshTheme = useCallback(() => {653 const refreshTheme = useCallback(({ preservePreview = false } = {}) => {
613 if (typeof window === "undefined") return;654 if (typeof window === "undefined") return;
614 lastAppliedTheme = null;655 lastAppliedTheme = null;
615 applyTheme(getStored(), true);656 applyTheme(getStored(), { suppressTransitions: true, preservePreview });
616 emitChange();657 emitChange();
617 }, []);658 }, []);
618659
apps/web/src/routes/__root.tsx+12
...@@ -21,6 +21,8 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt...@@ -21,6 +21,8 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt
21import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";21import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
22import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";22import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
23import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";23import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
24import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme";
25import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme";
24import { Button } from "../components/ui/button";26import { Button } from "../components/ui/button";
25import {27import {
26 AnchoredToastProvider,28 AnchoredToastProvider,
...@@ -133,6 +135,7 @@ function RootRouteView() {...@@ -133,6 +135,7 @@ function RootRouteView() {
133 <AnchoredToastProvider>135 <AnchoredToastProvider>
134 <DocumentTitleSync />136 <DocumentTitleSync />
135 <ContrastAppearanceSync />137 <ContrastAppearanceSync />
138 <EnvironmentThemeSync />
136 <GlassAppearanceSync />139 <GlassAppearanceSync />
137 <FontAppearanceSync />140 <FontAppearanceSync />
138 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}141 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
...@@ -154,6 +157,15 @@ function RootRouteView() {...@@ -154,6 +157,15 @@ function RootRouteView() {
154 );157 );
155}158}
156159
160/** Follows the palette the primary environment's machine publishes, if any. */
161function EnvironmentThemeSync() {
162 useEnvironmentThemeSync();
163 // Ordered after the palette sync so a first-run client adopting the
164 // environment's own theme finds it already in the library.
165 useDefaultThemeAdoption();
166 return null;
167}
168
157function ContrastAppearanceSync() {169function ContrastAppearanceSync() {
158 const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast);170 const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast);
159171
apps/web/src/state/server.ts+20
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 type EditorId,3 type EditorId,
4 type EnvironmentTheme,
4 type ServerConfig,5 type ServerConfig,
5 type ServerConfigStreamEvent,6 type ServerConfigStreamEvent,
6 type ServerLifecycleWelcomePayload,7 type ServerLifecycleWelcomePayload,
...@@ -18,8 +19,15 @@ import { connectionAtomRuntime } from "../connection/runtime";...@@ -18,8 +19,15 @@ import { connectionAtomRuntime } from "../connection/runtime";
18import { primaryEnvironmentIdAtom } from "./primaryEnvironment";19import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
19import { environmentSession } from "./session";20import { environmentSession } from "./session";
2021
22// Opted in for every environment, not just the primary one. Only the primary
23// environment's themes are rendered, but which environment is primary changes
24// at runtime and the subscription payload is fixed when it is established --
25// gating on "primary right now" would leave a newly promoted environment with
26// no themes until it reconnected. The set is capped server-side and stripped
27// before caching, so following all of them costs a few KB per environment.
21export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {28export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
22 initialConfigValueAtom: environmentSession.initialConfigValueAtom,29 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
30 environmentThemes: true,
23});31});
24export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({32export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
25 catalogValueAtom: environmentCatalog.catalogValueAtom,33 catalogValueAtom: environmentCatalog.catalogValueAtom,
...@@ -94,6 +102,18 @@ export const primaryServerKeybindingsConfigPathAtom = Atom.make(...@@ -94,6 +102,18 @@ export const primaryServerKeybindingsConfigPathAtom = Atom.make(
94 (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null,102 (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null,
95).pipe(Atom.withLabel("web-primary-server-keybindings-config-path"));103).pipe(Atom.withLabel("web-primary-server-keybindings-config-path"));
96104
105const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray<EnvironmentTheme> = [];
106
107/**
108 * Palettes published by the primary environment's machine. Only the primary
109 * environment: a client follows the machine it is anchored to, not every
110 * environment it happens to be connected to.
111 */
112export const primaryServerEnvironmentThemesAtom = Atom.make(
113 (get): ReadonlyArray<EnvironmentTheme> =>
114 get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES,
115).pipe(Atom.withLabel("web-primary-server-environment-themes"));
116
97export const primaryServerObservabilityAtom = Atom.make(117export const primaryServerObservabilityAtom = Atom.make(
98 (get): ServerConfig["observability"] | null =>118 (get): ServerConfig["observability"] | null =>
99 get(primaryServerConfigAtom)?.observability ?? null,119 get(primaryServerConfigAtom)?.observability ?? null,
apps/web/src/themePalette.test.ts+9
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 themeColorToHex,39 themeColorToHex,
40 toCanonicalThemeColor,40 toCanonicalThemeColor,
41 THEME_FILE_VERSION,41 THEME_FILE_VERSION,
42 singleAppearanceOf,
42} from "./themePalette";43} from "./themePalette";
4344
44function asHex(value: string): string {45function asHex(value: string): string {
...@@ -1072,3 +1073,11 @@ describe("stored theme preferences", () => {...@@ -1072,3 +1073,11 @@ describe("stored theme preferences", () => {
1072 invalidateCustomThemes();1073 invalidateCustomThemes();
1073 });1074 });
1074});1075});
1076
1077describe("singleAppearanceOf", () => {
1078 it("reports the only half a theme can claim, and null for a pair", () => {
1079 const { variants: _pair, ...base } = T3_CHAT_THEME;
1080 expect(singleAppearanceOf({ ...base, id: "x", appearance: "dark" })).toBe("dark");
1081 expect(singleAppearanceOf(T3_CHAT_THEME)).toBe(null);
1082 });
1083});
apps/web/src/themePalette.ts+64-25
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as Equal from "effect/Equal";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
2import "culori/css";3import "culori/css";
3import { converter, parse } from "culori/fn";4import { converter, parse } from "culori/fn";
...@@ -8,6 +9,7 @@ import {...@@ -8,6 +9,7 @@ import {
8 IRIS_THEME,9 IRIS_THEME,
9 OCEAN_THEME,10 OCEAN_THEME,
10 T3_CHAT_THEME,11 T3_CHAT_THEME,
12 RESERVED_THEME_IDS,
11 THEME_COLOR_ROLES,13 THEME_COLOR_ROLES,
12 type ThemeAppearance,14 type ThemeAppearance,
13 type ThemeColorRole,15 type ThemeColorRole,
...@@ -56,21 +58,16 @@ export type ThemeFile = Readonly<{...@@ -56,21 +58,16 @@ export type ThemeFile = Readonly<{
56 managed?: boolean;58 managed?: boolean;
57}>;59}>;
5860
59const RESERVED_THEME_IDS = new Set([61// Reserved ids come from shared so the CLI, the server watcher, and this
60 "system",62// library cannot drift on what a published theme may be called.
61 "light",63
62 "dark",64/**
63 T3_CHAT_THEME_ID,65 * The environment's palettes are not saved: they are republished by the
64 GROVE_THEME_ID,66 * server on every change and would go stale the moment the machine's theme
65 OCEAN_THEME_ID,67 * moved on. They ride the custom-theme listeners so every theme consumer
66 EMBER_THEME_ID,68 * already re-reads when they change.
67 IRIS_THEME_ID,69 */
68 LEGACY_T3_CHAT_DARK_THEME_ID,70let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> = [];
69 "t3-grove",
70 "t3-ocean",
71 "t3-ember",
72 "t3-iris",
73]);
7471
75const customThemeListeners = new Set<() => void>();72const customThemeListeners = new Set<() => void>();
76type CustomThemeLibrarySnapshot =73type CustomThemeLibrarySnapshot =
...@@ -130,21 +127,28 @@ function parseThemeCollection(value: unknown): ThemeCollection | undefined {...@@ -130,21 +127,28 @@ function parseThemeCollection(value: unknown): ThemeCollection | undefined {
130 : undefined;127 : undefined;
131}128}
132129
133function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null {130/**
134 if (!isRecord(value)) return null;131 * Tolerates unknown roles and malformed values so themes written by other
135132 * builds (for example one that adds a new role) keep their remaining colors.
136 const colors: Partial<Record<ThemeColorRole, string>> = {133 * The one canonicalization path for every externally supplied color record:
137 ...getDefaultThemeColors(appearance),134 * stored themes, imported files, and environment-published themes.
138 };135 */
139 // Tolerate unknown roles and malformed values so themes saved by other136export function lenientThemeColorOverrides(
140 // builds (for example one that adds a new role) keep their remaining colors.137 value: Readonly<Record<string, unknown>>,
138): Partial<Record<ThemeColorRole, string>> {
139 const overrides: Partial<Record<ThemeColorRole, string>> = {};
141 for (const [role, color] of Object.entries(value)) {140 for (const [role, color] of Object.entries(value)) {
142 const normalized = toCanonicalThemeColor(color);141 const normalized = toCanonicalThemeColor(color);
143 if (THEME_COLOR_ROLE_SET.has(role) && normalized) {142 if (THEME_COLOR_ROLE_SET.has(role) && normalized) {
144 colors[role as ThemeColorRole] = normalized;143 overrides[role as ThemeColorRole] = normalized;
145 }144 }
146 }145 }
147 return colors as ThemeColors;146 return overrides;
147}
148
149function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null {
150 if (!isRecord(value)) return null;
151 return { ...getDefaultThemeColors(appearance), ...lenientThemeColorOverrides(value) };
148}152}
149153
150function parseStoredThemeVariants(154function parseStoredThemeVariants(
...@@ -244,6 +248,27 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {...@@ -244,6 +248,27 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
244 return snapshot.status === "ready" ? snapshot.themes : [];248 return snapshot.status === "ready" ? snapshot.themes : [];
245}249}
246250
251export function getEnvironmentThemes(): ReadonlyArray<ThemeDefinition> {
252 return environmentThemeDefinitions;
253}
254
255/**
256 * Returns whether anything changed, structurally: config snapshots arrive as
257 * fresh arrays on every reconnect, and a repaint for identical colors is the
258 * kind of wasted work users of this product notice.
259 */
260export function setEnvironmentThemes(themes: ReadonlyArray<ThemeDefinition>): boolean {
261 if (Equal.equals(environmentThemeDefinitions, themes)) return false;
262 environmentThemeDefinitions = themes;
263 notifyCustomThemeListeners();
264 return true;
265}
266
267/** Ids no published theme may occupy: appearance keywords and built-in ids. */
268export function isReservedThemeId(themeId: string): boolean {
269 return RESERVED_THEME_IDS.has(themeId);
270}
271
247export function getStoredCustomThemeCollection(272export function getStoredCustomThemeCollection(
248 collectionId: string,273 collectionId: string,
249): ReadonlyArray<ThemeDefinition> {274): ReadonlyArray<ThemeDefinition> {
...@@ -1424,6 +1449,9 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu...@@ -1424,6 +1449,9 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu
1424 return (1449 return (
1425 BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ??1450 BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ??
1426 getCustomThemes().find((definition) => definition.id === themeId) ??1451 getCustomThemes().find((definition) => definition.id === themeId) ??
1452 // Resolved last so a theme the user saved always wins over one the
1453 // machine happens to publish under the same id.
1454 environmentThemeDefinitions.find((definition) => definition.id === themeId) ??
1427 null1455 null
1428 );1456 );
1429}1457}
...@@ -1437,6 +1465,17 @@ export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean {...@@ -1437,6 +1465,17 @@ export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean {
1437 );1465 );
1438}1466}
14391467
1468/**
1469 * Which half a theme can claim, or null when it renders both appearances.
1470 * Selecting a single-appearance theme as the base preference would clear the
1471 * light/dark mix and leave the appearance tiles disagreeing with what is on
1472 * screen, so every path that selects a theme has to make the same call.
1473 */
1474export function singleAppearanceOf(theme: ThemeDefinition): ThemeAppearance | null {
1475 const modes = getThemeModes(theme);
1476 return modes.length === 1 ? modes[0]! : null;
1477}
1478
1440export function getThemeColorsForMode(1479export function getThemeColorsForMode(
1441 theme: ThemeDefinition,1480 theme: ThemeDefinition,
1442 mode: ThemeAppearance,1481 mode: ThemeAppearance,
docs/README.md+1
...@@ -9,6 +9,7 @@...@@ -9,6 +9,7 @@
9- [Review usage](./user/usage.md)9- [Review usage](./user/usage.md)
10- [Customize a project icon](./user/project-settings.md)10- [Customize a project icon](./user/project-settings.md)
11- [Mobile appearance](./user/mobile-appearance.md)11- [Mobile appearance](./user/mobile-appearance.md)
12- [Environment themes](./user/environment-theme.md)
12- [Remote access](./user/remote-access.md)13- [Remote access](./user/remote-access.md)
13- [Keeping app and server in sync](./user/updating.md)14- [Keeping app and server in sync](./user/updating.md)
14- [Source control integrations](./user/source-control.md)15- [Source control integrations](./user/source-control.md)
docs/internals/glossary.md+18
...@@ -11,6 +11,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi...@@ -11,6 +11,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi
11- [Orchestration](#orchestration)11- [Orchestration](#orchestration)
12- [Provider runtime](#provider-runtime)12- [Provider runtime](#provider-runtime)
13- [Checkpointing](#checkpointing)13- [Checkpointing](#checkpointing)
14- [Appearance](#appearance)
1415
15## Concepts16## Concepts
1617
...@@ -144,6 +145,21 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi...@@ -144,6 +145,21 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi
144145
145The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4].146The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4].
146147
148### Appearance
149
150#### Environment theme
151
152A theme an environment's machine publishes for clients to follow, one file per theme under `themes/` in that environment's state directory; the filename is the theme id. [environmentTheme.ts][25] watches the directory and streams the set over `subscribeServerConfig`; clients render each as a library card, generating a full palette when the file carries seed colors and using the palette directly when it is a standard exported theme file. A desktop that retints its apps when the system theme changes rewrites its file, so T3 Code follows along without a restart. See [environment-theme.md][26].
153
154#### Default theme
155
156The environment's theme, held in its `settings.json` as `defaultTheme` (with `defaultThemeSetAt`
157as the set-generation) and set with `t3 theme set <id>`. Web and desktop clients apply each set
158once — live when connected, on the next connect otherwise — so setting it switches them, while a
159theme a user picks in Settings afterwards sticks until the next set; mobile keeps its own
160appearance settings. Naming a published [environment theme](#environment-theme) is how a desktop
161ships T3 Code already matching it.
162
147## Practical Shortcuts163## Practical Shortcuts
148164
149- If you see `requested`, think "intent recorded".165- If you see `requested`, think "intent recorded".
...@@ -183,3 +199,5 @@ The file patch and changed-file summary for one turn. It is usually computed in...@@ -183,3 +199,5 @@ The file patch and changed-file summary for one turn. It is usually computed in
183[22]: ../../apps/server/src/checkpointing/Utils.ts199[22]: ../../apps/server/src/checkpointing/Utils.ts
184[23]: ../../apps/server/src/checkpointing/Diffs.ts200[23]: ../../apps/server/src/checkpointing/Diffs.ts
185[24]: ./overview.md201[24]: ./overview.md
202[25]: ../../apps/server/src/environmentTheme.ts
203[26]: ../user/environment-theme.md
docs/user/environment-theme.md created+92
...@@ -0,0 +1,92 @@
1# Environment theme
2
3Some desktops publish the palette they are currently wearing so that apps can match it. When the
4machine running your T3 Code server does that, its themes appear in the theme library alongside
5your own, and T3 Code follows them: change the desktop theme and T3 Code retints with it, without
6a restart.
7
8To use one:
9
101. Open **Settings**.
112. Select **Appearance**.
123. Choose the theme published by your environment.
13
14Like every theme, this is a per-client choice: picking it on your desktop does not change what your
15phone or another browser shows.
16
17The machine can also set the environment's theme:
18
19```bash
20t3 theme set nightfall
21```
22
23Web and desktop clients switch to it — immediately when connected, on their next connect
24otherwise — and a fresh client opens with it. Each client applies a set once, so picking a
25different theme in Settings afterwards sticks until the next `t3 theme set`, and running the same
26set again is how you bring clients back. `t3 theme clear` removes the setting without changing
27what anyone currently has, and `t3 theme show` prints the current theme and everything the
28machine publishes. Only the environment you are anchored to publishes themes, so a remote client
29follows the machine it is connected to, not the device it runs on. T3 Code Mobile keeps its own
30appearance settings and does not follow environment themes.
31
32Select **Duplicate** on a published theme's card to keep a copy you can edit. The published theme
33itself cannot be edited or removed, because the environment rewrites it whenever its own theme
34changes. If the machine stops publishing it, the card disappears and clients using it fall back to
35the standard T3 Code look. A theme you saved always wins over a published one with the same id.
36
37When the theme editor is open, its draft stays visible while published themes change.
38Close the editor to show the latest selected theme.
39
40## Publishing themes
41
42A machine publishes themes by writing files into the `themes` directory of the T3 Code state
43directory (`~/.t3/userdata/themes/` by default). The filename is the theme id — `nightfall.json`
44appears as `nightfall` — and stays stable while the machine rewrites the colors underneath, so
45selections and defaults keep pointing at it.
46
47The filename may not be an appearance keyword (`system`, `light`, `dark`) or a built-in theme's
48id — those names already mean something on every client. Two formats are accepted. A theme
49exported from T3 Code (the **Download** button's output) works as-is, so any theme someone shared
50can be dropped in unchanged. Or, for a desktop that only knows
51its own palette, a short seeded form:
52
53```json
54{
55 "name": "Nightfall",
56 "appearance": "dark",
57 "canvas": "#1a1b26",
58 "accent": "#7aa2f7"
59}
60```
61
62`appearance` is `light` or `dark`, and `canvas` and `accent` are hex colors. T3 Code generates the
63rest of the palette from those two, the same way the guided theme editor does, so the result reads
64as a T3 Code theme wearing your desktop's colors rather than a transplant of another app's.
65
66A machine that knows a role better than a derivation can guess — its terminal palette, its
67semantic colors — can publish that role directly under `colors`, layered over the generated
68palette:
69
70```json
71{
72 "name": "Nightfall",
73 "appearance": "dark",
74 "canvas": "#1a1b26",
75 "accent": "#7aa2f7",
76 "colors": {
77 "terminalSelection": "#292e42",
78 "error": "#f7768e"
79 }
80}
81```
82
83Any of the roles shown in the theme editor's advanced view can be published this way; roles left
84out keep their generated value. A role this version of T3 Code does not know is ignored rather
85than rejected, so publishing a role added by a newer release is safe. An `id` inside the file is
86ignored — the filename decides.
87
88A theme with no usable colors is not listed on that client.
89
90Write each file atomically — write a temporary file beside it and rename — so T3 Code never reads
91a half-written theme. An unreadable or invalid file is simply not published; the machine's other
92themes are unaffected.
packages/client-runtime/src/state/server.test.ts+75
...@@ -51,6 +51,9 @@ const CONFIG = {...@@ -51,6 +51,9 @@ const CONFIG = {
51 observability: null,51 observability: null,
52 providers: [],52 providers: [],
53 settings: {},53 settings: {},
54 // Capabilities drive version-skew behaviour in the projection, so the
55 // fixture carries them rather than leaving the field absent.
56 environment: { capabilities: { environmentThemes: true } },
54} as unknown as ServerConfig;57} as unknown as ServerConfig;
5558
56const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({59const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({
...@@ -306,6 +309,78 @@ describe("server state projection", () => {...@@ -306,6 +309,78 @@ describe("server state projection", () => {
306 expect(result.latestEvent.type).toBe("settingsUpdated");309 expect(result.latestEvent.type).toBe("settingsUpdated");
307 });310 });
308311
312 it("carries published environment themes in and out of the projected snapshot", () => {
313 const snapshot = applyServerConfigProjection(Option.none(), {
314 version: 1,
315 type: "snapshot",
316 config: CONFIG,
317 });
318 const themes = [
319 {
320 id: "nightfall",
321 name: "Nightfall",
322 appearance: "dark",
323 canvas: "#1a1b26",
324 accent: "#7aa2f7",
325 },
326 ] as const;
327
328 const published = applyServerConfigProjection(snapshot, {
329 version: 1,
330 type: "environmentThemesUpdated",
331 payload: { themes },
332 });
333 expect(Option.getOrThrow(published).config.environmentThemes).toEqual(themes);
334
335 // A machine that stops publishing has to clear the palettes, not freeze
336 // clients on the last set it sent.
337 const unpublished = applyServerConfigProjection(published, {
338 version: 1,
339 type: "environmentThemesUpdated",
340 payload: { themes: [] },
341 });
342 expect(Option.getOrThrow(unpublished).config.environmentThemes).toBeUndefined();
343 });
344
345 // A snapshot never carries published themes, so taking it wholesale would
346 // clear them on every reconnect and repaint anyone wearing one.
347 it("keeps published themes across a reconnect snapshot", () => {
348 const themes = [
349 {
350 id: "nightfall",
351 name: "Nightfall",
352 appearance: "dark",
353 canvas: "#1a1b26",
354 accent: "#7aa2f7",
355 },
356 ] as const;
357
358 const withThemes = applyServerConfigProjection(
359 applyServerConfigProjection(Option.none(), { version: 1, type: "snapshot", config: CONFIG }),
360 { version: 1, type: "environmentThemesUpdated", payload: { themes } },
361 );
362 expect(Option.getOrThrow(withThemes).config.environmentThemes).toEqual(themes);
363
364 const afterReconnect = applyServerConfigProjection(withThemes, {
365 version: 1,
366 type: "snapshot",
367 config: CONFIG,
368 });
369 expect(Option.getOrThrow(afterReconnect).config.environmentThemes).toEqual(themes);
370
371 // A server that predates the feature never sends another theme event, so
372 // carrying the set forward would leave a palette nothing can update.
373 const downgraded = applyServerConfigProjection(withThemes, {
374 version: 1,
375 type: "snapshot",
376 config: {
377 ...CONFIG,
378 environment: { capabilities: {} },
379 } as unknown as ServerConfig,
380 });
381 expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined();
382 });
383
309 it("retains welcome when a ready event follows in the same stream chunk", () => {384 it("retains welcome when a ready event follows in the same stream chunk", () => {
310 const welcome = {385 const welcome = {
311 environment: {} as ServerLifecycleWelcomePayload["environment"],386 environment: {} as ServerLifecycleWelcomePayload["environment"],
packages/client-runtime/src/state/server.ts+58-12
...@@ -273,12 +273,24 @@ export function applyServerConfigProjection(...@@ -273,12 +273,24 @@ export function applyServerConfigProjection(
273 event: ServerConfigStreamEvent,273 event: ServerConfigStreamEvent,
274): Option.Option<ServerConfigProjection> {274): Option.Option<ServerConfigProjection> {
275 switch (event.type) {275 switch (event.type) {
276 case "snapshot":276 case "snapshot": {
277 // A snapshot never carries published themes -- the theme stream owns
278 // them -- so taking it wholesale would clear the set on every reconnect
279 // and repaint anyone wearing one until the follow-up event landed.
280 // Only from a server that still streams them. Reconnecting to one that
281 // predates the feature must drop the set rather than leave a palette on
282 // screen that nothing will ever update again.
283 const carried =
284 event.config.environment.capabilities.environmentThemes === true && Option.isSome(current)
285 ? current.value.config.environmentThemes
286 : undefined;
277 return Option.some({287 return Option.some({
278 config: event.config,288 config:
289 carried === undefined ? event.config : { ...event.config, environmentThemes: carried },
279 latestEvent: event,290 latestEvent: event,
280 source: "live",291 source: "live" as const,
281 });292 });
293 }
282 case "keybindingsUpdated":294 case "keybindingsUpdated":
283 return Option.map(current, (projection) => ({295 return Option.map(current, (projection) => ({
284 config: {296 config: {
...@@ -307,6 +319,15 @@ export function applyServerConfigProjection(...@@ -307,6 +319,15 @@ export function applyServerConfigProjection(
307 latestEvent: event,319 latestEvent: event,
308 source: "live",320 source: "live",
309 }));321 }));
322 case "environmentThemesUpdated":
323 return Option.map(current, (projection) => ({
324 config: {
325 ...projection.config,
326 environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined,
327 },
328 latestEvent: event,
329 source: "live",
330 }));
310 }331 }
311}332}
312333
...@@ -329,8 +350,19 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven...@@ -329,8 +350,19 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven
329 * config carries the provider/model catalogue used by task creation, so it is350 * config carries the provider/model catalogue used by task creation, so it is
330 * useful—and safe—to retain after a transport session ends.351 * useful—and safe—to retain after a transport session ends.
331 */352 */
353/**
354 * Published themes live only as long as the machine publishes them, so they
355 * must not survive in the config cache: a restart or an offline load would
356 * otherwise hand clients palettes the environment has already dropped.
357 */
358function withoutEnvironmentThemes(config: ServerConfig): ServerConfig {
359 if (config.environmentThemes === undefined) return config;
360 const { environmentThemes: _ephemeral, ...rest } = config;
361 return rest;
362}
363
332export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(364export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(
333 function* () {365 function* (environmentThemes?: boolean) {
334 const supervisor = yield* EnvironmentSupervisor;366 const supervisor = yield* EnvironmentSupervisor;
335 const cache = yield* EnvironmentCacheStore;367 const cache = yield* EnvironmentCacheStore;
336 const environmentId = supervisor.target.environmentId;368 const environmentId = supervisor.target.environmentId;
...@@ -346,9 +378,11 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf...@@ -346,9 +378,11 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
346 ),378 ),
347 );379 );
348 const state = yield* SubscriptionRef.make<Option.Option<ServerConfigProjection>>(380 const state = yield* SubscriptionRef.make<Option.Option<ServerConfigProjection>>(
349 Option.map(cachedConfig, (config) => ({381 // Stripped on load as well as on save: a cache written by an earlier
350 config,382 // build can still carry published themes.
351 latestEvent: cachedConfigSnapshotEvent(config),383 Option.map(cachedConfig, (cached) => ({
384 config: withoutEnvironmentThemes(cached),
385 latestEvent: cachedConfigSnapshotEvent(withoutEnvironmentThemes(cached)),
352 source: "cache" as const,386 source: "cache" as const,
353 })),387 })),
354 );388 );
...@@ -358,7 +392,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf...@@ -358,7 +392,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
358 const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* (392 const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* (
359 config: ServerConfig,393 config: ServerConfig,
360 ) {394 ) {
361 return yield* cache.saveServerConfig(environmentId, config).pipe(395 return yield* cache.saveServerConfig(environmentId, withoutEnvironmentThemes(config)).pipe(
362 Effect.as(true),396 Effect.as(true),
363 Effect.catch((error) =>397 Effect.catch((error) =>
364 Effect.logWarning("Could not persist cached server configuration.").pipe(398 Effect.logWarning("Could not persist cached server configuration.").pipe(
...@@ -389,7 +423,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf...@@ -389,7 +423,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
389 Effect.forkScoped,423 Effect.forkScoped,
390 );424 );
391425
392 yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe(426 yield* subscribe(
427 WS_METHODS.subscribeServerConfig,
428 environmentThemes === true ? { environmentThemes: true } : {},
429 ).pipe(
393 Stream.runForEach((event) =>430 Stream.runForEach((event) =>
394 Effect.gen(function* () {431 Effect.gen(function* () {
395 const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event);432 const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event);
...@@ -419,11 +456,14 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf...@@ -419,11 +456,14 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
419 },456 },
420);457);
421458
422export function serverConfigStateChanges(environmentId: EnvironmentId) {459export function serverConfigStateChanges(
460 environmentId: EnvironmentId,
461 environmentThemes?: boolean,
462) {
423 return followStreamInEnvironment(463 return followStreamInEnvironment(
424 environmentId,464 environmentId,
425 Stream.unwrap(465 Stream.unwrap(
426 makeEnvironmentServerConfigState().pipe(466 makeEnvironmentServerConfigState(environmentThemes).pipe(
427 Effect.map((state) =>467 Effect.map((state) =>
428 SubscriptionRef.changes(state).pipe(468 SubscriptionRef.changes(state).pipe(
429 Stream.filterMap((projection) =>469 Stream.filterMap((projection) =>
...@@ -476,6 +516,12 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -476,6 +516,12 @@ export function createServerEnvironmentAtoms<R, E>(
476 readonly initialConfigValueAtom: (516 readonly initialConfigValueAtom: (
477 environmentId: EnvironmentId,517 environmentId: EnvironmentId,
478 ) => Atom.Atom<ServerConfig | null>;518 ) => Atom.Atom<ServerConfig | null>;
519 /**
520 * Whether this surface renders themes the environment publishes. Mobile
521 * keeps its own appearance settings, so it neither asks for the stream nor
522 * receives the payload.
523 */
524 readonly environmentThemes?: boolean;
479 },525 },
480) {526) {
481 const configScheduler = createAtomCommandScheduler();527 const configScheduler = createAtomCommandScheduler();
...@@ -487,7 +533,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -487,7 +533,7 @@ export function createServerEnvironmentAtoms<R, E>(
487 };533 };
488 const configProjectionFamily = Atom.family((environmentId: EnvironmentId) =>534 const configProjectionFamily = Atom.family((environmentId: EnvironmentId) =>
489 runtime535 runtime
490 .atom(serverConfigStateChanges(environmentId))536 .atom(serverConfigStateChanges(environmentId, options.environmentThemes))
491 .pipe(537 .pipe(
492 Atom.setIdleTTL(5 * 60_000),538 Atom.setIdleTTL(5 * 60_000),
493 Atom.withLabel(`environment-data:server:config-projection:${environmentId}`),539 Atom.withLabel(`environment-data:server:config-projection:${environmentId}`),
packages/contracts/src/environment.ts+5
...@@ -66,6 +66,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -66,6 +66,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
66 /** Server understands thread.snooze / thread.unsnooze commands. Same66 /** Server understands thread.snooze / thread.unsnooze commands. Same
67 version-skew contract as threadSettlement. */67 version-skew contract as threadSettlement. */
68 threadSnooze: Schema.optionalKey(Schema.Boolean),68 threadSnooze: Schema.optionalKey(Schema.Boolean),
69 /** Server streams themes an environment publishes. Absent on servers from
70 before environment themes shipped, which never emit the events -- so a
71 client reconnecting to one must drop published themes rather than keep
72 showing a set nothing will ever update. */
73 environmentThemes: Schema.optionalKey(Schema.Boolean),
69 /** Server understands thread.pin / thread.unpin commands. Same74 /** Server understands thread.pin / thread.unpin commands. Same
70 version-skew contract as threadSettlement. */75 version-skew contract as threadSettlement. */
71 threadPinning: Schema.optionalKey(Schema.Boolean),76 threadPinning: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/rpc.test.ts created+31
...@@ -0,0 +1,31 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as Exit from "effect/Exit";
3import * as Schema from "effect/Schema";
4
5import { WsSubscribeServerConfigRpc } from "./rpc.ts";
6
7/**
8 * The client always sends `environmentThemes`, including to servers built
9 * before the field existed, whose payload schema was an empty struct. What
10 * makes that safe is that such a schema accepts the request rather than
11 * rejecting it -- an error here would take down the config subscription.
12 */
13describe("subscribeServerConfig payload compatibility", () => {
14 it("is accepted by a server whose schema predates the field", () => {
15 const oldServerPayload = Schema.Struct({});
16 const decoded = Schema.decodeUnknownExit(oldServerPayload)({ environmentThemes: true });
17 expect(Exit.isSuccess(decoded)).toBe(true);
18 });
19
20 it("is carried by a server that declares it", () => {
21 const decoded = Schema.decodeUnknownSync(WsSubscribeServerConfigRpc.payloadSchema)({
22 environmentThemes: true,
23 });
24 expect(decoded).toEqual({ environmentThemes: true });
25 });
26
27 it("stays optional, so a client that never sends it still subscribes", () => {
28 const decoded = Schema.decodeUnknownSync(WsSubscribeServerConfigRpc.payloadSchema)({});
29 expect(decoded).toEqual({});
30 });
31});
packages/contracts/src/rpc.ts+10-1
...@@ -983,7 +983,16 @@ export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTermi...@@ -983,7 +983,16 @@ export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTermi
983});983});
984984
985export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {985export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {
986 payload: Schema.Struct({}),986 payload: Schema.Struct({
987 /**
988 * Whether this client understands `environmentThemesUpdated` events.
989 * Already-shipped clients decode the stream against the old event union
990 * and would die on an unknown member, so the server emits the theme
991 * stream only to subscribers that ask for it. Absent on old clients;
992 * dropped by old servers.
993 */
994 environmentThemes: Schema.optional(Schema.Boolean),
995 }),
987 success: ServerConfigStreamEvent,996 success: ServerConfigStreamEvent,
988 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),997 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
989 stream: true,998 stream: true,
packages/contracts/src/server.ts+111
...@@ -417,6 +417,93 @@ export const ServerSignalProcessResult = Schema.Struct({...@@ -417,6 +417,93 @@ export const ServerSignalProcessResult = Schema.Struct({
417});417});
418export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type;418export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type;
419419
420/**
421 * A palette the environment's machine publishes for T3 Code to follow, read
422 * from a theme file next to the rest of the environment's state. Two seed
423 * colors rather than a full palette: clients derive the remaining roles with
424 * the same generator the guided theme editor uses, so a desktop theme carries
425 * over as a coherent T3 Code palette instead of a foreign one.
426 */
427export const EnvironmentThemeColor = Schema.String.check(
428 Schema.isPattern(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/),
429);
430export type EnvironmentThemeColor = typeof EnvironmentThemeColor.Type;
431
432/**
433 * Matches the client-side theme id rule, so a published id is selectable.
434 * The appearance keywords are excluded outright: a published `dark.json`
435 * would otherwise capture every client whose stored preference is the stock
436 * `"dark"`, retinting people who never chose it.
437 */
438export const EnvironmentThemeId = Schema.String.check(
439 Schema.isPattern(/^(?!(?:system|light|dark)$)[a-z0-9](?:[a-z0-9-]{0,47})$/),
440);
441export type EnvironmentThemeId = typeof EnvironmentThemeId.Type;
442
443/**
444 * Role colors as published. Values are any CSS color the client's theme
445 * parser accepts (exported theme files use oklch), canonicalized client-side;
446 * roles a build does not know are dropped there, so a machine may publish
447 * roles a newer client added without breaking an older one. Keys must still
448 * be role-shaped and values color-sized, so the record stays open to future
449 * vocabulary without being an arbitrary-payload channel.
450 */
451const EnvironmentThemeColors = Schema.Record(
452 Schema.String.check(Schema.isPattern(/^[a-zA-Z][a-zA-Z0-9]{0,63}$/)),
453 TrimmedNonEmptyString.check(Schema.isMaxLength(64)),
454);
455
456const environmentThemeFields = {
457 /**
458 * Standard exported theme files (the Download button's output) carry
459 * `version: 1`; the seeded short form a desktop generates has no version.
460 */
461 version: Schema.optional(Schema.Literal(1)),
462 /** Shown on the theme card, e.g. the desktop theme's own name. */
463 name: TrimmedNonEmptyString.check(Schema.isMaxLength(48)),
464 appearance: Schema.Literals(["light", "dark"]),
465 /**
466 * Seed colors. When present, clients derive the full palette from them with
467 * the guided theme editor's generator and layer `colors` on top; when
468 * absent, `colors` is the palette, as in an exported theme file.
469 */
470 canvas: Schema.optional(EnvironmentThemeColor),
471 accent: Schema.optional(EnvironmentThemeColor),
472 colors: Schema.optional(EnvironmentThemeColors),
473 /** The other appearance's palette, as exported theme files carry it. */
474 variants: Schema.optional(
475 Schema.Struct({
476 light: Schema.optional(EnvironmentThemeColors),
477 dark: Schema.optional(EnvironmentThemeColors),
478 }),
479 ),
480};
481
482/** One published theme file. The id is the filename, not part of the content,
483 * so a file cannot claim another file's identity; an embedded `id` is ignored. */
484export const EnvironmentThemeFile = Schema.Struct(environmentThemeFields);
485export type EnvironmentThemeFile = typeof EnvironmentThemeFile.Type;
486
487export const EnvironmentTheme = Schema.Struct({
488 /** The publishing filename without its extension, stable across recolors. */
489 id: EnvironmentThemeId,
490 ...environmentThemeFields,
491});
492export type EnvironmentTheme = typeof EnvironmentTheme.Type;
493
494/**
495 * Whether a theme file carries anything to render. A file with neither seeds
496 * nor colors would show as the stock palette wearing a name, which reads as a
497 * bug rather than a theme — the CLI and the server watcher both reject it,
498 * through this one predicate so they cannot drift.
499 */
500export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boolean {
501 return (
502 (file.canvas !== undefined && file.accent !== undefined) ||
503 (file.colors !== undefined && Object.keys(file.colors).length > 0)
504 );
505}
506
420export const ServerConfig = Schema.Struct({507export const ServerConfig = Schema.Struct({
421 environment: ExecutionEnvironmentDescriptor,508 environment: ExecutionEnvironmentDescriptor,
422 auth: ServerAuthDescriptor,509 auth: ServerAuthDescriptor,
...@@ -451,6 +538,14 @@ export const ServerConfig = Schema.Struct({...@@ -451,6 +538,14 @@ export const ServerConfig = Schema.Struct({
451 * fields to servers that don't advertise this.538 * fields to servers that don't advertise this.
452 */539 */
453 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),540 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),
541 /**
542 * Palettes published by this environment's machine. Never sent in a config
543 * snapshot: the theme stream emits the current set before any change, so a
544 * snapshot carrying it too would hand every subscriber the same array twice
545 * per connect. Clients populate this by projecting `environmentThemesUpdated`,
546 * and it stays absent for subscribers that did not opt in.
547 */
548 environmentThemes: Schema.optional(Schema.Array(EnvironmentTheme)),
454});549});
455export type ServerConfig = typeof ServerConfig.Type;550export type ServerConfig = typeof ServerConfig.Type;
456551
...@@ -535,11 +630,27 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({...@@ -535,11 +630,27 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({
535export type ServerConfigStreamSettingsUpdatedEvent =630export type ServerConfigStreamSettingsUpdatedEvent =
536 typeof ServerConfigStreamSettingsUpdatedEvent.Type;631 typeof ServerConfigStreamSettingsUpdatedEvent.Type;
537632
633export const ServerConfigEnvironmentThemesUpdatedPayload = Schema.Struct({
634 /** The full published set; empty once the machine publishes none. */
635 themes: Schema.Array(EnvironmentTheme),
636});
637export type ServerConfigEnvironmentThemesUpdatedPayload =
638 typeof ServerConfigEnvironmentThemesUpdatedPayload.Type;
639
640export const ServerConfigStreamEnvironmentThemesUpdatedEvent = Schema.Struct({
641 version: Schema.Literal(1),
642 type: Schema.Literal("environmentThemesUpdated"),
643 payload: ServerConfigEnvironmentThemesUpdatedPayload,
644});
645export type ServerConfigStreamEnvironmentThemesUpdatedEvent =
646 typeof ServerConfigStreamEnvironmentThemesUpdatedEvent.Type;
647
538export const ServerConfigStreamEvent = Schema.Union([648export const ServerConfigStreamEvent = Schema.Union([
539 ServerConfigStreamSnapshotEvent,649 ServerConfigStreamSnapshotEvent,
540 ServerConfigStreamKeybindingsUpdatedEvent,650 ServerConfigStreamKeybindingsUpdatedEvent,
541 ServerConfigStreamProviderStatusesEvent,651 ServerConfigStreamProviderStatusesEvent,
542 ServerConfigStreamSettingsUpdatedEvent,652 ServerConfigStreamSettingsUpdatedEvent,
653 ServerConfigStreamEnvironmentThemesUpdatedEvent,
543]);654]);
544export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;655export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
545656
packages/contracts/src/settings.ts+24
...@@ -131,6 +131,19 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM...@@ -131,6 +131,19 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM
131export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200));131export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200));
132export type FontFamilyPreference = typeof FontFamilyPreference.Type;132export type FontFamilyPreference = typeof FontFamilyPreference.Type;
133133
134/**
135 * The environment's theme, set with `t3 theme set <id>`. Each client applies
136 * it once per value — live when connected, on its next connect otherwise — so
137 * setting it switches every client, while a theme a user picks in Settings
138 * afterwards sticks until the next set. Empty means "no environment theme",
139 * which is also how it is cleared.
140 */
141export const DefaultThemePreference = Schema.String.check(Schema.isMaxLength(64));
142// Deliberately absent from ServerSettingsPatch: `t3 theme set` checks that an
143// id is syntactically valid and actually resolvable, and a generic RPC patch
144// would let a client write a theme no client can resolve, bypassing both.
145export type DefaultThemePreference = typeof DefaultThemePreference.Type;
146
134/**147/**
135 * Defaults for the in-app preview browser, applied whenever a tab is opened148 * Defaults for the in-app preview browser, applied whenever a tab is opened
136 * without an explicit viewport/zoom/appearance — by the user opening a browser149 * without an explicit viewport/zoom/appearance — by the user opening a browser
...@@ -653,6 +666,17 @@ export const ServerSettings = Schema.Struct({...@@ -653,6 +666,17 @@ export const ServerSettings = Schema.Struct({
653 backgroundActivityProfile: BackgroundActivityProfile.pipe(666 backgroundActivityProfile: BackgroundActivityProfile.pipe(
654 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)),667 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)),
655 ),668 ),
669 defaultTheme: DefaultThemePreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
670 /**
671 * When the environment's theme was last set, so clients can tell a re-set
672 * of the same value from one they already applied: `t3 theme set` must act
673 * even when it names the theme it named before. Empty on environments
674 * provisioned by builds that predate it, where clients fall back to
675 * applying once per value.
676 */
677 defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe(
678 Schema.withDecodingDefault(Effect.succeed("")),
679 ),
656 defaultThreadEnvMode: ThreadEnvMode.pipe(680 defaultThreadEnvMode: ThreadEnvMode.pipe(
657 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),681 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
658 ),682 ),
packages/shared/src/themePalettes.ts+29
...@@ -10,6 +10,35 @@ export const MOBILE_DEFAULT_THEME_ID = "t3-code";...@@ -10,6 +10,35 @@ export const MOBILE_DEFAULT_THEME_ID = "t3-code";
10 */10 */
11export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const;11export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const;
1212
13/**
14 * Ids a theme may not take: the appearance keywords a stored preference uses,
15 * every built-in, and the legacy aliases older saves still carry. Taking one
16 * would either be shadowed by the built-in or capture clients that never chose
17 * it, so the client library and the publish path both consult this set.
18 */
19export const RESERVED_THEME_IDS: ReadonlySet<string> = new Set([
20 "system",
21 "light",
22 "dark",
23 ...BUILT_IN_THEME_IDS,
24 "t3-chat-dark",
25 "t3-grove",
26 "t3-ocean",
27 "t3-ember",
28 "t3-iris",
29]);
30
31/**
32 * Additionally closed to a machine publishing a theme: the mobile default is
33 * not a web or desktop built-in, so a saved theme may legitimately carry that
34 * id, but no client that follows published themes can resolve it -- publishing
35 * it would report success and change nothing.
36 */
37export const UNPUBLISHABLE_THEME_IDS: ReadonlySet<string> = new Set([
38 ...RESERVED_THEME_IDS,
39 MOBILE_DEFAULT_THEME_ID,
40]);
41
13export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number];42export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number];
14export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number];43export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number];
15export type ThemeAppearance = "light" | "dark";44export type ThemeAppearance = "light" | "dark";