| author | |
| committer | |
| log | c1c2d5401de5a352cf6722959de076c3da63d233 |
| tree | 43aa46d19c39dd08de530dea0751d9a5ab53e63a |
| parent | be218ac768d809087bfcea4d8f15c431f0d70e16 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 17 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; |
| 18 | 18 | import { serviceCommand } from "./cli/service.ts"; |
| 19 | 19 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; |
| 20 | import { themeCommand } from "./cli/theme.ts"; | |
| 20 | 21 | import { triageCommand } from "./cli/triage.ts"; |
| 21 | 22 | |
| 22 | 23 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| ... | ... | @@ -57,6 +58,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 57 | 58 | projectCommand, |
| 58 | 59 | serviceCommand, |
| 59 | 60 | servicePreflightCommand, |
| 61 | themeCommand, | |
| 60 | 62 | triageCommand, |
| 61 | 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. | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 8 | import * as NetService from "@t3tools/shared/Net"; | |
| 9 | import { assert, describe, it } from "@effect/vitest"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as TestConsole from "effect/testing/TestConsole"; | |
| 13 | import { Command } from "effect/unstable/cli"; | |
| 14 | ||
| 15 | import { cli } from "../bin.ts"; | |
| 16 | ||
| 17 | const 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 | ||
| 22 | const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-theme-cli-")); | |
| 23 | ||
| 24 | const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json"); | |
| 25 | ||
| 26 | const NIGHTFALL_THEME_JSON = `${JSON.stringify({ | |
| 27 | name: "Nightfall", | |
| 28 | appearance: "dark", | |
| 29 | canvas: "#1a1b26", | |
| 30 | accent: "#7aa2f7", | |
| 31 | })}\n`; | |
| 32 | const JUNK_THEME_JSON = `${JSON.stringify({ name: "Junk" })}\n`; | |
| 33 | ||
| 34 | const 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 | ||
| 39 | const 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 | ||
| 44 | describe("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 | */ | |
| 19 | import * as NodeFS from "node:fs"; | |
| 20 | ||
| 21 | import { | |
| 22 | EnvironmentThemeFile, | |
| 23 | EnvironmentThemeId, | |
| 24 | environmentThemeFileHasColors, | |
| 25 | } from "@t3tools/contracts"; | |
| 26 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 27 | import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; | |
| 28 | import * as Config from "effect/Config"; | |
| 29 | import * as Console from "effect/Console"; | |
| 30 | import * as DateTime from "effect/DateTime"; | |
| 31 | import * as Effect from "effect/Effect"; | |
| 32 | import * as FileSystem from "effect/FileSystem"; | |
| 33 | import * as Option from "effect/Option"; | |
| 34 | import * as Path from "effect/Path"; | |
| 35 | import * as Schema from "effect/Schema"; | |
| 36 | import { Argument, Command, Flag } from "effect/unstable/cli"; | |
| 37 | ||
| 38 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 39 | import * as ServerConfig from "../config.ts"; | |
| 40 | import { | |
| 41 | MAX_THEME_FILE_BYTES, | |
| 42 | readPublishedThemes, | |
| 43 | readThemeFileGuarded, | |
| 44 | } from "../environmentTheme.ts"; | |
| 45 | import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; | |
| 46 | import { 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. */ | |
| 50 | const SparseSettings = Schema.Record(Schema.String, Schema.Unknown); | |
| 51 | const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings)); | |
| 52 | const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings)); | |
| 53 | const decodeThemeFileJsonExit = Schema.decodeUnknownExit( | |
| 54 | Schema.fromJsonString(EnvironmentThemeFile), | |
| 55 | ); | |
| 56 | const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); | |
| 57 | ||
| 58 | export 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 | ||
| 67 | export 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 | ||
| 76 | export 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 | ||
| 85 | export 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 | ||
| 94 | export 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 | ||
| 105 | export 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 | ||
| 114 | export 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 | ||
| 123 | export 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 | ||
| 132 | export 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 | ||
| 141 | const INVALID_THEME_ID_REASON = | |
| 142 | "is not a valid theme id (lowercase letters, digits, and hyphens; not an appearance keyword)"; | |
| 143 | ||
| 144 | export 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 | ||
| 153 | export 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. */ | |
| 163 | export 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 | ||
| 172 | export 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 | ||
| 181 | const envT3Home = Config.string("T3CODE_HOME").pipe(Config.option); | |
| 182 | ||
| 183 | const 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 | */ | |
| 204 | const 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 | */ | |
| 231 | const CONCURRENT_WRITE_ATTEMPTS = 5; | |
| 232 | ||
| 233 | const 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. */ | |
| 287 | const 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 | */ | |
| 459 | const 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 | ||
| 464 | const 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 | ||
| 544 | const 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 | ||
| 555 | const 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 | ||
| 584 | export 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 | 33 | readonly dbPath: string; |
| 34 | 34 | readonly keybindingsConfigPath: string; |
| 35 | 35 | readonly settingsPath: string; |
| 36 | /** Palettes this machine publishes for clients to follow, one file per theme. */ | |
| 37 | readonly environmentThemesDir: string; | |
| 36 | 38 | readonly providerStatusCacheDir: string; |
| 37 | 39 | readonly worktreesDir: string; |
| 38 | 40 | readonly attachmentsDir: string; |
| ... | ... | @@ -119,6 +121,7 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 119 | 121 | dbPath, |
| 120 | 122 | keybindingsConfigPath: join(stateDir, "keybindings.json"), |
| 121 | 123 | settingsPath: join(stateDir, "settings.json"), |
| 124 | environmentThemesDir: join(stateDir, "themes"), | |
| 122 | 125 | providerStatusCacheDir, |
| 123 | 126 | worktreesDir: join(baseDir, "worktrees"), |
| 124 | 127 | attachmentsDir, |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -155,6 +155,7 @@ export const make = Effect.gen(function* () { |
| 155 | 155 | pullRequests: true, |
| 156 | 156 | threadSettlement: true, |
| 157 | 157 | threadSnooze: true, |
| 158 | environmentThemes: true, | |
| 158 | 159 | threadPinning: true, |
| 159 | 160 | threadPinReorder: true, |
| 160 | 161 | threadTitleRegeneration: true, |
apps/server/src/environmentTheme.test.ts created+272| ... | ... | @@ -0,0 +1,272 @@ |
| 1 | import { EnvironmentThemeFile } from "@t3tools/contracts"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { assert, describe, it } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Queue from "effect/Queue"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import * as Scope from "effect/Scope"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | ||
| 14 | import * as ServerConfig from "./config.ts"; | |
| 15 | import * as EnvironmentTheme from "./environmentTheme.ts"; | |
| 16 | ||
| 17 | const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); | |
| 18 | ||
| 19 | const 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. */ | |
| 27 | const 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. */ | |
| 35 | const 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 | ||
| 66 | const currentThemes = Effect.gen(function* () { | |
| 67 | const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; | |
| 68 | return yield* environmentTheme.current; | |
| 69 | }); | |
| 70 | ||
| 71 | it.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`. | |
| 231 | describe("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 | */ | |
| 17 | import * as NodeFS from "node:fs"; | |
| 18 | ||
| 19 | import { | |
| 20 | EnvironmentTheme, | |
| 21 | EnvironmentThemeFile, | |
| 22 | EnvironmentThemeId, | |
| 23 | environmentThemeFileHasColors, | |
| 24 | } from "@t3tools/contracts"; | |
| 25 | import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; | |
| 26 | import * as Cause from "effect/Cause"; | |
| 27 | import * as Context from "effect/Context"; | |
| 28 | import * as Duration from "effect/Duration"; | |
| 29 | import * as Effect from "effect/Effect"; | |
| 30 | import * as Equal from "effect/Equal"; | |
| 31 | import * as Exit from "effect/Exit"; | |
| 32 | import * as FileSystem from "effect/FileSystem"; | |
| 33 | import * as Layer from "effect/Layer"; | |
| 34 | import * as PubSub from "effect/PubSub"; | |
| 35 | import * as Ref from "effect/Ref"; | |
| 36 | import * as Schema from "effect/Schema"; | |
| 37 | import * as Semaphore from "effect/Semaphore"; | |
| 38 | import * as Scope from "effect/Scope"; | |
| 39 | import * as Stream from "effect/Stream"; | |
| 40 | ||
| 41 | import * as ServerConfig from "./config.ts"; | |
| 42 | ||
| 43 | const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit( | |
| 44 | Schema.fromJsonString(EnvironmentThemeFile), | |
| 45 | ); | |
| 46 | const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); | |
| 47 | ||
| 48 | const 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 | */ | |
| 56 | const MAX_THEME_FILES = 32; | |
| 57 | /** Exported so the publish path cannot accept a file the watcher will skip. */ | |
| 58 | export 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 | */ | |
| 64 | const MAX_THEME_TOTAL_BYTES = 192 * 1024; | |
| 65 | ||
| 66 | /** The published set with the sequence number it was observed at. */ | |
| 67 | interface PublishedThemes { | |
| 68 | readonly seq: number; | |
| 69 | readonly themes: ReadonlyArray<EnvironmentTheme>; | |
| 70 | } | |
| 71 | ||
| 72 | export 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 | */ | |
| 99 | export 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 | */ | |
| 133 | export 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 | ||
| 210 | const 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 | ||
| 297 | export const layer = Layer.effect(EnvironmentThemeService, make); |
apps/server/src/server.test.ts+94-7| ... | ... | @@ -111,6 +111,7 @@ import { |
| 111 | 111 | } from "./ws.ts"; |
| 112 | 112 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 113 | 113 | import * as GitManager from "./git/GitManager.ts"; |
| 114 | import * as EnvironmentTheme from "./environmentTheme.ts"; | |
| 114 | 115 | import * as Keybindings from "./keybindings.ts"; |
| 115 | 116 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 116 | 117 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| ... | ... | @@ -395,6 +396,7 @@ const buildAppUnderTest = (options?: { |
| 395 | 396 | config?: Partial<ServerConfig.ServerConfig["Service"]>; |
| 396 | 397 | layers?: { |
| 397 | 398 | keybindings?: Partial<Keybindings.Keybindings["Service"]>; |
| 399 | environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>; | |
| 398 | 400 | providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>; |
| 399 | 401 | providerService?: Partial<ProviderService.ProviderService["Service"]>; |
| 400 | 402 | serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>; |
| ... | ... | @@ -630,14 +632,21 @@ const buildAppUnderTest = (options?: { |
| 630 | 632 | }, |
| 631 | 633 | ).pipe( |
| 632 | 634 | Layer.provide( |
| 633 | Layer.mock(Keybindings.Keybindings)({ | |
| 634 | loadConfigState: Effect.succeed({ | |
| 635 | keybindings: [], | |
| 636 | issues: [], | |
| 635 | Layer.mergeAll( | |
| 636 | Layer.mock(Keybindings.Keybindings)({ | |
| 637 | loadConfigState: Effect.succeed({ | |
| 638 | keybindings: [], | |
| 639 | issues: [], | |
| 640 | }), | |
| 641 | streamChanges: Stream.empty, | |
| 642 | ...options?.layers?.keybindings, | |
| 637 | 643 | }), |
| 638 | streamChanges: Stream.empty, | |
| 639 | ...options?.layers?.keybindings, | |
| 640 | }), | |
| 644 | Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ | |
| 645 | current: Effect.succeed([]), | |
| 646 | streamChanges: Stream.empty, | |
| 647 | ...options?.layers?.environmentTheme, | |
| 648 | }), | |
| 649 | ), | |
| 641 | 650 | ), |
| 642 | 651 | Layer.provide( |
| 643 | 652 | Layer.mergeAll( |
| ... | ... | @@ -4981,6 +4990,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4981 | 4990 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4982 | 4991 | ); |
| 4983 | 4992 | |
| 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 | 5071 | it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => |
| 4985 | 5072 | Effect.gen(function* () { |
| 4986 | 5073 | const nextProviders = [ |
apps/server/src/server.ts+4-1| ... | ... | @@ -53,6 +53,7 @@ import * as PreviewManager from "./preview/Manager.ts"; |
| 53 | 53 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 54 | 54 | import * as ProcessRunner from "./processRunner.ts"; |
| 55 | 55 | import * as GitManager from "./git/GitManager.ts"; |
| 56 | import * as EnvironmentTheme from "./environmentTheme.ts"; | |
| 56 | 57 | import * as Keybindings from "./keybindings.ts"; |
| 57 | 58 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 58 | 59 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; |
| ... | ... | @@ -383,7 +384,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 383 | 384 | Layer.provideMerge(ProviderRuntimeLayerLive), |
| 384 | 385 | Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), |
| 385 | 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 | 390 | Layer.provideMerge(ProviderRegistryLive), |
| 388 | 391 | // The instance registry is the new routing keystone — text generation, |
| 389 | 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 | 73 | |
| 74 | 74 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 75 | 75 | import * as ServerConfig from "./config.ts"; |
| 76 | import * as EnvironmentTheme from "./environmentTheme.ts"; | |
| 76 | 77 | import * as Keybindings from "./keybindings.ts"; |
| 77 | 78 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 78 | 79 | import { |
| ... | ... | @@ -492,6 +493,7 @@ const makeWsRpcLayer = ( |
| 492 | 493 | }; |
| 493 | 494 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 494 | 495 | const keybindings = yield* Keybindings.Keybindings; |
| 496 | const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; | |
| 495 | 497 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 496 | 498 | const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; |
| 497 | 499 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; |
| ... | ... | @@ -2378,7 +2380,7 @@ const makeWsRpcLayer = ( |
| 2378 | 2380 | ), |
| 2379 | 2381 | { "rpc.aggregate": "preview" }, |
| 2380 | 2382 | ), |
| 2381 | [WS_METHODS.subscribeServerConfig]: (_input) => | |
| 2383 | [WS_METHODS.subscribeServerConfig]: (input) => | |
| 2382 | 2384 | observeRpcStreamEffect( |
| 2383 | 2385 | WS_METHODS.subscribeServerConfig, |
| 2384 | 2386 | Effect.gen(function* () { |
| ... | ... | @@ -2400,6 +2402,23 @@ const makeWsRpcLayer = ( |
| 2400 | 2402 | })), |
| 2401 | 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 | 2422 | const settingsUpdates = serverSettings.streamChanges.pipe( |
| 2404 | 2423 | Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), |
| 2405 | 2424 | Stream.map((settings) => ({ |
| ... | ... | @@ -2415,7 +2434,10 @@ const makeWsRpcLayer = ( |
| 2415 | 2434 | |
| 2416 | 2435 | const liveUpdates = Stream.merge( |
| 2417 | 2436 | keybindingsUpdates, |
| 2418 | Stream.merge(providerStatuses, settingsUpdates), | |
| 2437 | Stream.merge( | |
| 2438 | providerStatuses, | |
| 2439 | Stream.merge(settingsUpdates, environmentThemeUpdates), | |
| 2440 | ), | |
| 2419 | 2441 | ); |
| 2420 | 2442 | |
| 2421 | 2443 | return Stream.concat( |
apps/web/src/components/settings/ThemeSettings.tsx+44-4| ... | ... | @@ -10,9 +10,12 @@ import { |
| 10 | 10 | UploadIcon, |
| 11 | 11 | } from "lucide-react"; |
| 12 | 12 | import { useCallback, useEffect, useState, type ReactElement } from "react"; |
| 13 | import { useEnvironmentThemeDefinitions } from "../../hooks/useEnvironmentTheme"; | |
| 14 | import { readThemeHalvesRaw } from "../../hooks/useTheme"; | |
| 13 | 15 | import { cn } from "../../lib/utils"; |
| 14 | 16 | import { |
| 15 | 17 | getThemeDefinition, |
| 18 | singleAppearanceOf, | |
| 16 | 19 | getThemeModes, |
| 17 | 20 | removeCustomThemes, |
| 18 | 21 | serializeThemeFile, |
| ... | ... | @@ -523,6 +526,7 @@ export function ThemeLibrary({ |
| 523 | 526 | setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean; |
| 524 | 527 | }) { |
| 525 | 528 | const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); |
| 529 | const environmentThemes = useEnvironmentThemeDefinitions(); | |
| 526 | 530 | const [themeRemovalTarget, setThemeRemovalTarget] = useState<{ |
| 527 | 531 | theme: ThemeDefinition; |
| 528 | 532 | collectionThemes: ReadonlyArray<ThemeDefinition>; |
| ... | ... | @@ -581,13 +585,17 @@ export function ThemeLibrary({ |
| 581 | 585 | const removedIds = new Set(themeIdsToRemove); |
| 582 | 586 | if (removedIds.size === 0) return; |
| 583 | 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 | 592 | // Keep the themes installed if we cannot move the selection off one of |
| 585 | 593 | // them; the dialog stays open so the user can retry or cancel. |
| 586 | 594 | if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) { |
| 587 | 595 | return; |
| 588 | 596 | } |
| 589 | 597 | for (const appearance of ["light", "dark"] as const) { |
| 590 | const half = themeHalves?.[appearance]; | |
| 598 | const half = storedHalves[appearance]; | |
| 591 | 599 | if (half === undefined) continue; |
| 592 | 600 | // Writing a base preference clears the whole mix, so halves that name |
| 593 | 601 | // a surviving theme are written back; removed halves fall back to base. |
| ... | ... | @@ -610,7 +618,6 @@ export function ThemeLibrary({ |
| 610 | 618 | persistTheme, |
| 611 | 619 | setThemeHalf, |
| 612 | 620 | theme, |
| 613 | themeHalves, | |
| 614 | 621 | themeIdsToRemove, |
| 615 | 622 | themeRemovalTarget, |
| 616 | 623 | ]); |
| ... | ... | @@ -629,7 +636,10 @@ export function ThemeLibrary({ |
| 629 | 636 | // the base would still own that appearance. Convert the base into an |
| 630 | 637 | // explicit half on the other side so this side falls back to default. |
| 631 | 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 | 643 | if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return; |
| 634 | 644 | if (!setThemeHalf(otherAppearance, otherOwner)) { |
| 635 | 645 | // Best-effort rollback: restore the whole-theme selection rather |
| ... | ... | @@ -651,7 +661,6 @@ export function ThemeLibrary({ |
| 651 | 661 | setTheme, |
| 652 | 662 | setThemeHalf, |
| 653 | 663 | theme, |
| 654 | themeHalves, | |
| 655 | 664 | ], |
| 656 | 665 | ); |
| 657 | 666 | |
| ... | ... | @@ -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 | 852 | {customThemeCollections.map(([collectionId, themes]) => ( |
| 813 | 853 | <CustomThemeCollectionCard |
| 814 | 854 | activeModesFor={pickedModesFor} |
apps/web/src/hooks/useDefaultTheme.test.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { defaultThemeGeneration, defaultThemeToApply } from "./useDefaultTheme"; | |
| 4 | ||
| 5 | const 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 | ||
| 13 | describe("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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { useEffect } from "react"; | |
| 3 | ||
| 4 | import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; | |
| 5 | import { primaryServerSettingsAtom } from "../state/server"; | |
| 6 | import { getThemeDefinition, singleAppearanceOf } from "../themePalette"; | |
| 7 | import { useEnvironmentThemeDefinitions } from "./useEnvironmentTheme"; | |
| 8 | import { 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 | */ | |
| 15 | const 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 | */ | |
| 22 | export 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 | */ | |
| 31 | export 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 | ||
| 48 | function 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 | ||
| 57 | function 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 | */ | |
| 73 | export 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 @@ |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { environmentThemeDefinition, publishedThemeDefinitions } from "./useEnvironmentTheme"; | |
| 4 | import { | |
| 5 | getDefaultThemeColors, | |
| 6 | getThemeDefinition, | |
| 7 | installCustomTheme, | |
| 8 | invalidateCustomThemes, | |
| 9 | setEnvironmentThemes, | |
| 10 | THEME_COLOR_ROLES, | |
| 11 | } from "../themePalette"; | |
| 12 | ||
| 13 | const NIGHTFALL_THEME = { | |
| 14 | id: "nightfall", | |
| 15 | name: "Nightfall", | |
| 16 | appearance: "dark", | |
| 17 | canvas: "#1a1b26", | |
| 18 | accent: "#7aa2f7", | |
| 19 | } as const; | |
| 20 | ||
| 21 | afterEach(() => { | |
| 22 | setEnvironmentThemes([]); | |
| 23 | }); | |
| 24 | ||
| 25 | describe("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 @@ |
| 1 | import type { EnvironmentTheme } from "@t3tools/contracts"; | |
| 2 | import { useAtomValue } from "@effect/atom-react"; | |
| 3 | import * as Equal from "effect/Equal"; | |
| 4 | import { useEffect, useRef, useSyncExternalStore } from "react"; | |
| 5 | ||
| 6 | import { primaryServerEnvironmentThemesAtom } from "../state/server"; | |
| 7 | import { | |
| 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"; | |
| 19 | import { useTheme } from "./useTheme"; | |
| 20 | ||
| 21 | function 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 | */ | |
| 44 | export 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 | */ | |
| 80 | export 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. */ | |
| 97 | export 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 | */ | |
| 107 | export 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 @@ |
| 1 | import type { EnvironmentTheme } from "@t3tools/contracts"; | |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | const NIGHTFALL_THEME = { | |
| 5 | id: "nightfall", | |
| 6 | name: "Nightfall", | |
| 7 | appearance: "dark", | |
| 8 | canvas: "#1a1b26", | |
| 9 | accent: "#7aa2f7", | |
| 10 | } as const satisfies EnvironmentTheme; | |
| 11 | ||
| 12 | const LIGHT_THEME = { | |
| 13 | ...NIGHTFALL_THEME, | |
| 14 | appearance: "light", | |
| 15 | canvas: "#eff1f5", | |
| 16 | } as const satisfies EnvironmentTheme; | |
| 17 | ||
| 18 | async 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 | ||
| 90 | afterEach(() => { | |
| 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 | ||
| 99 | describe("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 | 13 | resolveDesktopTheme, |
| 14 | 14 | resolveThemeAppearance, |
| 15 | 15 | resolveThemeHalf, |
| 16 | THEME_PREVIEW_ID, | |
| 16 | 17 | THEME_APPEARANCE_MODE_STORAGE_KEY, |
| 17 | 18 | THEME_FOLLOW_SYSTEM_STORAGE_KEY, |
| 18 | 19 | THEME_HALVES_STORAGE_KEY, |
| ... | ... | @@ -25,6 +26,7 @@ import { |
| 25 | 26 | type Theme = ThemePreference; |
| 26 | 27 | type ThemeSnapshot = { |
| 27 | 28 | theme: Theme; |
| 29 | resolvedTheme: ThemeAppearance; | |
| 28 | 30 | systemDark: boolean; |
| 29 | 31 | followSystem: boolean; |
| 30 | 32 | appearanceMode: ThemePreferenceMode; |
| ... | ... | @@ -37,6 +39,7 @@ const STORAGE_KEY = "t3code:theme"; |
| 37 | 39 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 38 | 40 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| 39 | 41 | theme: "system", |
| 42 | resolvedTheme: "light", | |
| 40 | 43 | systemDark: false, |
| 41 | 44 | followSystem: true, |
| 42 | 45 | appearanceMode: "system", |
| ... | ... | @@ -49,6 +52,17 @@ export function readThemeHalves(): ThemeHalves | null { |
| 49 | 52 | return readStoredThemeHalves(); |
| 50 | 53 | } |
| 51 | 54 | |
| 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 | */ | |
| 61 | export function readThemeHalvesRaw(): { light?: string; dark?: string } { | |
| 62 | if (typeof window === "undefined") return {}; | |
| 63 | return readStoredThemeHalvesRaw(); | |
| 64 | } | |
| 65 | ||
| 52 | 66 | function readStoredThemeHalves(): ThemeHalves | null { |
| 53 | 67 | if (typeof window === "undefined") return null; |
| 54 | 68 | try { |
| ... | ... | @@ -58,6 +72,29 @@ function readStoredThemeHalves(): ThemeHalves | null { |
| 58 | 72 | } |
| 59 | 73 | } |
| 60 | 74 | |
| 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 | */ | |
| 81 | function 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 | ||
| 61 | 98 | function themeHalvesSignature(halves: ThemeHalves | null): string { |
| 62 | 99 | return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; |
| 63 | 100 | } |
| ... | ... | @@ -98,7 +135,7 @@ let listeners: Array<() => void> = []; |
| 98 | 135 | let lastSnapshot: ThemeSnapshot | null = null; |
| 99 | 136 | let snapshotStale = true; |
| 100 | 137 | let lastDesktopTheme: "light" | "dark" | "system" | null = null; |
| 101 | let lastAppliedTheme: ThemeSnapshot | null = null; | |
| 138 | let lastAppliedTheme: Omit<ThemeSnapshot, "resolvedTheme"> | null = null; | |
| 102 | 139 | let themeStorageReadFailure: ThemeStorageError | null = null; |
| 103 | 140 | |
| 104 | 141 | function emitChange() { |
| ... | ... | @@ -282,8 +319,10 @@ export function syncBrowserChromeTheme() { |
| 282 | 319 | } |
| 283 | 320 | } |
| 284 | 321 | |
| 285 | function applyTheme(theme: Theme, suppressTransitions = false) { | |
| 322 | function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { | |
| 286 | 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 | 326 | const appearanceMode = readAppearanceModePreference(theme); |
| 288 | 327 | const followSystem = appearanceMode === "system"; |
| 289 | 328 | const systemDark = followSystem ? getSystemDark() : false; |
| ... | ... | @@ -386,9 +425,17 @@ function getSnapshot(): ThemeSnapshot { |
| 386 | 425 | const systemDark = followSystem ? getSystemDark() : false; |
| 387 | 426 | const themeHalves = readStoredThemeHalves(); |
| 388 | 427 | |
| 428 | const resolvedTheme = resolveThemeAppearance( | |
| 429 | theme, | |
| 430 | systemDark, | |
| 431 | followSystem, | |
| 432 | appearanceMode, | |
| 433 | themeHalves, | |
| 434 | ); | |
| 389 | 435 | if ( |
| 390 | 436 | lastSnapshot && |
| 391 | 437 | lastSnapshot.theme === theme && |
| 438 | lastSnapshot.resolvedTheme === resolvedTheme && | |
| 392 | 439 | lastSnapshot.systemDark === systemDark && |
| 393 | 440 | lastSnapshot.followSystem === followSystem && |
| 394 | 441 | lastSnapshot.appearanceMode === appearanceMode && |
| ... | ... | @@ -397,7 +444,7 @@ function getSnapshot(): ThemeSnapshot { |
| 397 | 444 | return lastSnapshot; |
| 398 | 445 | } |
| 399 | 446 | |
| 400 | lastSnapshot = { theme, systemDark, followSystem, appearanceMode, themeHalves }; | |
| 447 | lastSnapshot = { theme, resolvedTheme, systemDark, followSystem, appearanceMode, themeHalves }; | |
| 401 | 448 | return lastSnapshot; |
| 402 | 449 | } |
| 403 | 450 | |
| ... | ... | @@ -407,26 +454,28 @@ function getServerSnapshot() { |
| 407 | 454 | |
| 408 | 455 | function handleSystemAppearanceChange() { |
| 409 | 456 | const storedTheme = getStored(); |
| 410 | if (readAppearanceModePreference(storedTheme) === "system") applyTheme(storedTheme, true); | |
| 457 | if (readAppearanceModePreference(storedTheme) === "system") { | |
| 458 | applyTheme(storedTheme, { suppressTransitions: true }); | |
| 459 | } | |
| 411 | 460 | emitChange(); |
| 412 | 461 | } |
| 413 | 462 | |
| 414 | 463 | function handleStorageChange(e: StorageEvent) { |
| 415 | 464 | if (e.key === STORAGE_KEY) { |
| 416 | 465 | themeStorageReadFailure = null; |
| 417 | applyTheme(getStored(), true); | |
| 466 | applyTheme(getStored(), { suppressTransitions: true }); | |
| 418 | 467 | emitChange(); |
| 419 | 468 | } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) { |
| 420 | applyTheme(getStored(), true); | |
| 469 | applyTheme(getStored(), { suppressTransitions: true }); | |
| 421 | 470 | emitChange(); |
| 422 | 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 | 473 | emitChange(); |
| 425 | 474 | } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) { |
| 426 | 475 | if (e.key === null) themeStorageReadFailure = null; |
| 427 | 476 | invalidateCustomThemes(); |
| 428 | 477 | lastAppliedTheme = null; |
| 429 | applyTheme(getStored(), true); | |
| 478 | applyTheme(getStored(), { suppressTransitions: true }); | |
| 430 | 479 | emitChange(); |
| 431 | 480 | } |
| 432 | 481 | } |
| ... | ... | @@ -460,15 +509,7 @@ function subscribe(listener: () => void): () => void { |
| 460 | 509 | |
| 461 | 510 | export function useTheme() { |
| 462 | 511 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
| 463 | const theme = snapshot.theme; | |
| 464 | ||
| 465 | const resolvedTheme: "light" | "dark" = resolveThemeAppearance( | |
| 466 | theme, | |
| 467 | snapshot.systemDark, | |
| 468 | snapshot.followSystem, | |
| 469 | snapshot.appearanceMode, | |
| 470 | snapshot.themeHalves, | |
| 471 | ); | |
| 512 | const { theme, resolvedTheme } = snapshot; | |
| 472 | 513 | |
| 473 | 514 | const setTheme = useCallback((next: Theme): boolean => { |
| 474 | 515 | if (typeof window === "undefined") return false; |
| ... | ... | @@ -511,7 +552,7 @@ export function useTheme() { |
| 511 | 552 | }); |
| 512 | 553 | return false; |
| 513 | 554 | } |
| 514 | applyTheme(next, true); | |
| 555 | applyTheme(next, { suppressTransitions: true }); | |
| 515 | 556 | emitChange(); |
| 516 | 557 | return true; |
| 517 | 558 | }, []); |
| ... | ... | @@ -536,7 +577,7 @@ export function useTheme() { |
| 536 | 577 | return false; |
| 537 | 578 | } |
| 538 | 579 | themeStorageReadFailure = null; |
| 539 | applyTheme(getStored(), true); | |
| 580 | applyTheme(getStored(), { suppressTransitions: true }); | |
| 540 | 581 | emitChange(); |
| 541 | 582 | return true; |
| 542 | 583 | }, []); |
| ... | ... | @@ -558,7 +599,7 @@ export function useTheme() { |
| 558 | 599 | (appearance: ThemeAppearance, themeId: string | null): boolean => { |
| 559 | 600 | if (typeof window === "undefined") return false; |
| 560 | 601 | try { |
| 561 | const current = readStoredThemeHalves() ?? {}; | |
| 602 | const current = readStoredThemeHalvesRaw(); | |
| 562 | 603 | const next: { light?: string; dark?: string } = { ...current }; |
| 563 | 604 | if (themeId === null) delete next[appearance]; |
| 564 | 605 | else next[appearance] = themeId; |
| ... | ... | @@ -580,7 +621,7 @@ export function useTheme() { |
| 580 | 621 | }); |
| 581 | 622 | return false; |
| 582 | 623 | } |
| 583 | applyTheme(getStored(), true); | |
| 624 | applyTheme(getStored(), { suppressTransitions: true }); | |
| 584 | 625 | emitChange(); |
| 585 | 626 | return true; |
| 586 | 627 | }, |
| ... | ... | @@ -604,15 +645,15 @@ export function useTheme() { |
| 604 | 645 | }); |
| 605 | 646 | return false; |
| 606 | 647 | } |
| 607 | applyTheme(getStored(), true); | |
| 648 | applyTheme(getStored(), { suppressTransitions: true }); | |
| 608 | 649 | emitChange(); |
| 609 | 650 | return true; |
| 610 | 651 | }, []); |
| 611 | 652 | |
| 612 | const refreshTheme = useCallback(() => { | |
| 653 | const refreshTheme = useCallback(({ preservePreview = false } = {}) => { | |
| 613 | 654 | if (typeof window === "undefined") return; |
| 614 | 655 | lastAppliedTheme = null; |
| 615 | applyTheme(getStored(), true); | |
| 656 | applyTheme(getStored(), { suppressTransitions: true, preservePreview }); | |
| 616 | 657 | emitChange(); |
| 617 | 658 | }, []); |
| 618 | 659 |
apps/web/src/routes/__root.tsx+12| ... | ... | @@ -21,6 +21,8 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt |
| 21 | 21 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 22 | 22 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 23 | 23 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; |
| 24 | import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; | |
| 25 | import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme"; | |
| 24 | 26 | import { Button } from "../components/ui/button"; |
| 25 | 27 | import { |
| 26 | 28 | AnchoredToastProvider, |
| ... | ... | @@ -133,6 +135,7 @@ function RootRouteView() { |
| 133 | 135 | <AnchoredToastProvider> |
| 134 | 136 | <DocumentTitleSync /> |
| 135 | 137 | <ContrastAppearanceSync /> |
| 138 | <EnvironmentThemeSync /> | |
| 136 | 139 | <GlassAppearanceSync /> |
| 137 | 140 | <FontAppearanceSync /> |
| 138 | 141 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| ... | ... | @@ -154,6 +157,15 @@ function RootRouteView() { |
| 154 | 157 | ); |
| 155 | 158 | } |
| 156 | 159 | |
| 160 | /** Follows the palette the primary environment's machine publishes, if any. */ | |
| 161 | function 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 | ||
| 157 | 169 | function ContrastAppearanceSync() { |
| 158 | 170 | const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast); |
| 159 | 171 |
apps/web/src/state/server.ts+20| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | 3 | type EditorId, |
| 4 | type EnvironmentTheme, | |
| 4 | 5 | type ServerConfig, |
| 5 | 6 | type ServerConfigStreamEvent, |
| 6 | 7 | type ServerLifecycleWelcomePayload, |
| ... | ... | @@ -18,8 +19,15 @@ import { connectionAtomRuntime } from "../connection/runtime"; |
| 18 | 19 | import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; |
| 19 | 20 | import { environmentSession } from "./session"; |
| 20 | 21 | |
| 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. | |
| 21 | 28 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { |
| 22 | 29 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, |
| 30 | environmentThemes: true, | |
| 23 | 31 | }); |
| 24 | 32 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 25 | 33 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| ... | ... | @@ -94,6 +102,18 @@ export const primaryServerKeybindingsConfigPathAtom = Atom.make( |
| 94 | 102 | (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, |
| 95 | 103 | ).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); |
| 96 | 104 | |
| 105 | const 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 | */ | |
| 112 | export 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 | ||
| 97 | 117 | export const primaryServerObservabilityAtom = Atom.make( |
| 98 | 118 | (get): ServerConfig["observability"] | null => |
| 99 | 119 | get(primaryServerConfigAtom)?.observability ?? null, |
apps/web/src/themePalette.test.ts+9| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | themeColorToHex, |
| 40 | 40 | toCanonicalThemeColor, |
| 41 | 41 | THEME_FILE_VERSION, |
| 42 | singleAppearanceOf, | |
| 42 | 43 | } from "./themePalette"; |
| 43 | 44 | |
| 44 | 45 | function asHex(value: string): string { |
| ... | ... | @@ -1072,3 +1073,11 @@ describe("stored theme preferences", () => { |
| 1072 | 1073 | invalidateCustomThemes(); |
| 1073 | 1074 | }); |
| 1074 | 1075 | }); |
| 1076 | ||
| 1077 | describe("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 | import * as Equal from "effect/Equal"; | |
| 1 | 2 | import * as Schema from "effect/Schema"; |
| 2 | 3 | import "culori/css"; |
| 3 | 4 | import { converter, parse } from "culori/fn"; |
| ... | ... | @@ -8,6 +9,7 @@ import { |
| 8 | 9 | IRIS_THEME, |
| 9 | 10 | OCEAN_THEME, |
| 10 | 11 | T3_CHAT_THEME, |
| 12 | RESERVED_THEME_IDS, | |
| 11 | 13 | THEME_COLOR_ROLES, |
| 12 | 14 | type ThemeAppearance, |
| 13 | 15 | type ThemeColorRole, |
| ... | ... | @@ -56,21 +58,16 @@ export type ThemeFile = Readonly<{ |
| 56 | 58 | managed?: boolean; |
| 57 | 59 | }>; |
| 58 | 60 | |
| 59 | const RESERVED_THEME_IDS = new Set([ | |
| 60 | "system", | |
| 61 | "light", | |
| 62 | "dark", | |
| 63 | T3_CHAT_THEME_ID, | |
| 64 | GROVE_THEME_ID, | |
| 65 | OCEAN_THEME_ID, | |
| 66 | EMBER_THEME_ID, | |
| 67 | IRIS_THEME_ID, | |
| 68 | LEGACY_T3_CHAT_DARK_THEME_ID, | |
| 69 | "t3-grove", | |
| 70 | "t3-ocean", | |
| 71 | "t3-ember", | |
| 72 | "t3-iris", | |
| 73 | ]); | |
| 61 | // Reserved ids come from shared so the CLI, the server watcher, and this | |
| 62 | // library cannot drift on what a published theme may be called. | |
| 63 | ||
| 64 | /** | |
| 65 | * The environment's palettes are not saved: they are republished by the | |
| 66 | * server on every change and would go stale the moment the machine's theme | |
| 67 | * moved on. They ride the custom-theme listeners so every theme consumer | |
| 68 | * already re-reads when they change. | |
| 69 | */ | |
| 70 | let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> = []; | |
| 74 | 71 | |
| 75 | 72 | const customThemeListeners = new Set<() => void>(); |
| 76 | 73 | type CustomThemeLibrarySnapshot = |
| ... | ... | @@ -130,21 +127,28 @@ function parseThemeCollection(value: unknown): ThemeCollection | undefined { |
| 130 | 127 | : undefined; |
| 131 | 128 | } |
| 132 | 129 | |
| 133 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { | |
| 134 | if (!isRecord(value)) return null; | |
| 135 | ||
| 136 | const colors: Partial<Record<ThemeColorRole, string>> = { | |
| 137 | ...getDefaultThemeColors(appearance), | |
| 138 | }; | |
| 139 | // Tolerate unknown roles and malformed values so themes saved by other | |
| 140 | // builds (for example one that adds a new role) keep their remaining colors. | |
| 130 | /** | |
| 131 | * Tolerates unknown roles and malformed values so themes written by other | |
| 132 | * builds (for example one that adds a new role) keep their remaining colors. | |
| 133 | * The one canonicalization path for every externally supplied color record: | |
| 134 | * stored themes, imported files, and environment-published themes. | |
| 135 | */ | |
| 136 | export function lenientThemeColorOverrides( | |
| 137 | value: Readonly<Record<string, unknown>>, | |
| 138 | ): Partial<Record<ThemeColorRole, string>> { | |
| 139 | const overrides: Partial<Record<ThemeColorRole, string>> = {}; | |
| 141 | 140 | for (const [role, color] of Object.entries(value)) { |
| 142 | 141 | const normalized = toCanonicalThemeColor(color); |
| 143 | 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 | ||
| 149 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { | |
| 150 | if (!isRecord(value)) return null; | |
| 151 | return { ...getDefaultThemeColors(appearance), ...lenientThemeColorOverrides(value) }; | |
| 148 | 152 | } |
| 149 | 153 | |
| 150 | 154 | function parseStoredThemeVariants( |
| ... | ... | @@ -244,6 +248,27 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { |
| 244 | 248 | return snapshot.status === "ready" ? snapshot.themes : []; |
| 245 | 249 | } |
| 246 | 250 | |
| 251 | export 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 | */ | |
| 260 | export 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. */ | |
| 268 | export function isReservedThemeId(themeId: string): boolean { | |
| 269 | return RESERVED_THEME_IDS.has(themeId); | |
| 270 | } | |
| 271 | ||
| 247 | 272 | export function getStoredCustomThemeCollection( |
| 248 | 273 | collectionId: string, |
| 249 | 274 | ): ReadonlyArray<ThemeDefinition> { |
| ... | ... | @@ -1424,6 +1449,9 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu |
| 1424 | 1449 | return ( |
| 1425 | 1450 | BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ?? |
| 1426 | 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 | 1455 | null |
| 1428 | 1456 | ); |
| 1429 | 1457 | } |
| ... | ... | @@ -1437,6 +1465,17 @@ export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { |
| 1437 | 1465 | ); |
| 1438 | 1466 | } |
| 1439 | 1467 | |
| 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 | */ | |
| 1474 | export function singleAppearanceOf(theme: ThemeDefinition): ThemeAppearance | null { | |
| 1475 | const modes = getThemeModes(theme); | |
| 1476 | return modes.length === 1 ? modes[0]! : null; | |
| 1477 | } | |
| 1478 | ||
| 1440 | 1479 | export function getThemeColorsForMode( |
| 1441 | 1480 | theme: ThemeDefinition, |
| 1442 | 1481 | mode: ThemeAppearance, |
docs/README.md+1| ... | ... | @@ -9,6 +9,7 @@ |
| 9 | 9 | - [Review usage](./user/usage.md) |
| 10 | 10 | - [Customize a project icon](./user/project-settings.md) |
| 11 | 11 | - [Mobile appearance](./user/mobile-appearance.md) |
| 12 | - [Environment themes](./user/environment-theme.md) | |
| 12 | 13 | - [Remote access](./user/remote-access.md) |
| 13 | 14 | - [Keeping app and server in sync](./user/updating.md) |
| 14 | 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 | 11 | - [Orchestration](#orchestration) |
| 12 | 12 | - [Provider runtime](#provider-runtime) |
| 13 | 13 | - [Checkpointing](#checkpointing) |
| 14 | - [Appearance](#appearance) | |
| 14 | 15 | |
| 15 | 16 | ## Concepts |
| 16 | 17 | |
| ... | ... | @@ -144,6 +145,21 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi |
| 144 | 145 | |
| 145 | 146 | The 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]. |
| 146 | 147 | |
| 148 | ### Appearance | |
| 149 | ||
| 150 | #### Environment theme | |
| 151 | ||
| 152 | A 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 | ||
| 156 | The environment's theme, held in its `settings.json` as `defaultTheme` (with `defaultThemeSetAt` | |
| 157 | as the set-generation) and set with `t3 theme set <id>`. Web and desktop clients apply each set | |
| 158 | once — live when connected, on the next connect otherwise — so setting it switches them, while a | |
| 159 | theme a user picks in Settings afterwards sticks until the next set; mobile keeps its own | |
| 160 | appearance settings. Naming a published [environment theme](#environment-theme) is how a desktop | |
| 161 | ships T3 Code already matching it. | |
| 162 | ||
| 147 | 163 | ## Practical Shortcuts |
| 148 | 164 | |
| 149 | 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 | 199 | [22]: ../../apps/server/src/checkpointing/Utils.ts |
| 184 | 200 | [23]: ../../apps/server/src/checkpointing/Diffs.ts |
| 185 | 201 | [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 | ||
| 3 | Some desktops publish the palette they are currently wearing so that apps can match it. When the | |
| 4 | machine running your T3 Code server does that, its themes appear in the theme library alongside | |
| 5 | your own, and T3 Code follows them: change the desktop theme and T3 Code retints with it, without | |
| 6 | a restart. | |
| 7 | ||
| 8 | To use one: | |
| 9 | ||
| 10 | 1. Open **Settings**. | |
| 11 | 2. Select **Appearance**. | |
| 12 | 3. Choose the theme published by your environment. | |
| 13 | ||
| 14 | Like every theme, this is a per-client choice: picking it on your desktop does not change what your | |
| 15 | phone or another browser shows. | |
| 16 | ||
| 17 | The machine can also set the environment's theme: | |
| 18 | ||
| 19 | ```bash | |
| 20 | t3 theme set nightfall | |
| 21 | ``` | |
| 22 | ||
| 23 | Web and desktop clients switch to it — immediately when connected, on their next connect | |
| 24 | otherwise — and a fresh client opens with it. Each client applies a set once, so picking a | |
| 25 | different theme in Settings afterwards sticks until the next `t3 theme set`, and running the same | |
| 26 | set again is how you bring clients back. `t3 theme clear` removes the setting without changing | |
| 27 | what anyone currently has, and `t3 theme show` prints the current theme and everything the | |
| 28 | machine publishes. Only the environment you are anchored to publishes themes, so a remote client | |
| 29 | follows the machine it is connected to, not the device it runs on. T3 Code Mobile keeps its own | |
| 30 | appearance settings and does not follow environment themes. | |
| 31 | ||
| 32 | Select **Duplicate** on a published theme's card to keep a copy you can edit. The published theme | |
| 33 | itself cannot be edited or removed, because the environment rewrites it whenever its own theme | |
| 34 | changes. If the machine stops publishing it, the card disappears and clients using it fall back to | |
| 35 | the standard T3 Code look. A theme you saved always wins over a published one with the same id. | |
| 36 | ||
| 37 | When the theme editor is open, its draft stays visible while published themes change. | |
| 38 | Close the editor to show the latest selected theme. | |
| 39 | ||
| 40 | ## Publishing themes | |
| 41 | ||
| 42 | A machine publishes themes by writing files into the `themes` directory of the T3 Code state | |
| 43 | directory (`~/.t3/userdata/themes/` by default). The filename is the theme id — `nightfall.json` | |
| 44 | appears as `nightfall` — and stays stable while the machine rewrites the colors underneath, so | |
| 45 | selections and defaults keep pointing at it. | |
| 46 | ||
| 47 | The filename may not be an appearance keyword (`system`, `light`, `dark`) or a built-in theme's | |
| 48 | id — those names already mean something on every client. Two formats are accepted. A theme | |
| 49 | exported from T3 Code (the **Download** button's output) works as-is, so any theme someone shared | |
| 50 | can be dropped in unchanged. Or, for a desktop that only knows | |
| 51 | its 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 | |
| 63 | rest of the palette from those two, the same way the guided theme editor does, so the result reads | |
| 64 | as a T3 Code theme wearing your desktop's colors rather than a transplant of another app's. | |
| 65 | ||
| 66 | A machine that knows a role better than a derivation can guess — its terminal palette, its | |
| 67 | semantic colors — can publish that role directly under `colors`, layered over the generated | |
| 68 | palette: | |
| 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 | ||
| 83 | Any of the roles shown in the theme editor's advanced view can be published this way; roles left | |
| 84 | out keep their generated value. A role this version of T3 Code does not know is ignored rather | |
| 85 | than rejected, so publishing a role added by a newer release is safe. An `id` inside the file is | |
| 86 | ignored — the filename decides. | |
| 87 | ||
| 88 | A theme with no usable colors is not listed on that client. | |
| 89 | ||
| 90 | Write each file atomically — write a temporary file beside it and rename — so T3 Code never reads | |
| 91 | a half-written theme. An unreadable or invalid file is simply not published; the machine's other | |
| 92 | themes are unaffected. |
packages/client-runtime/src/state/server.test.ts+75| ... | ... | @@ -51,6 +51,9 @@ const CONFIG = { |
| 51 | 51 | observability: null, |
| 52 | 52 | providers: [], |
| 53 | 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 | 57 | } as unknown as ServerConfig; |
| 55 | 58 | |
| 56 | 59 | const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ |
| ... | ... | @@ -306,6 +309,78 @@ describe("server state projection", () => { |
| 306 | 309 | expect(result.latestEvent.type).toBe("settingsUpdated"); |
| 307 | 310 | }); |
| 308 | 311 | |
| 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 | 384 | it("retains welcome when a ready event follows in the same stream chunk", () => { |
| 310 | 385 | const welcome = { |
| 311 | 386 | environment: {} as ServerLifecycleWelcomePayload["environment"], |
packages/client-runtime/src/state/server.ts+58-12| ... | ... | @@ -273,12 +273,24 @@ export function applyServerConfigProjection( |
| 273 | 273 | event: ServerConfigStreamEvent, |
| 274 | 274 | ): Option.Option<ServerConfigProjection> { |
| 275 | 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 | 287 | return Option.some({ |
| 278 | config: event.config, | |
| 288 | config: | |
| 289 | carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, | |
| 279 | 290 | latestEvent: event, |
| 280 | source: "live", | |
| 291 | source: "live" as const, | |
| 281 | 292 | }); |
| 293 | } | |
| 282 | 294 | case "keybindingsUpdated": |
| 283 | 295 | return Option.map(current, (projection) => ({ |
| 284 | 296 | config: { |
| ... | ... | @@ -307,6 +319,15 @@ export function applyServerConfigProjection( |
| 307 | 319 | latestEvent: event, |
| 308 | 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 | } |
| 312 | 333 | |
| ... | ... | @@ -329,8 +350,19 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven |
| 329 | 350 | * config carries the provider/model catalogue used by task creation, so it is |
| 330 | 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 | */ | |
| 358 | function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { | |
| 359 | if (config.environmentThemes === undefined) return config; | |
| 360 | const { environmentThemes: _ephemeral, ...rest } = config; | |
| 361 | return rest; | |
| 362 | } | |
| 363 | ||
| 332 | 364 | export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( |
| 333 | function* () { | |
| 365 | function* (environmentThemes?: boolean) { | |
| 334 | 366 | const supervisor = yield* EnvironmentSupervisor; |
| 335 | 367 | const cache = yield* EnvironmentCacheStore; |
| 336 | 368 | const environmentId = supervisor.target.environmentId; |
| ... | ... | @@ -346,9 +378,11 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 346 | 378 | ), |
| 347 | 379 | ); |
| 348 | 380 | const state = yield* SubscriptionRef.make<Option.Option<ServerConfigProjection>>( |
| 349 | Option.map(cachedConfig, (config) => ({ | |
| 350 | config, | |
| 351 | latestEvent: cachedConfigSnapshotEvent(config), | |
| 381 | // Stripped on load as well as on save: a cache written by an earlier | |
| 382 | // build can still carry published themes. | |
| 383 | Option.map(cachedConfig, (cached) => ({ | |
| 384 | config: withoutEnvironmentThemes(cached), | |
| 385 | latestEvent: cachedConfigSnapshotEvent(withoutEnvironmentThemes(cached)), | |
| 352 | 386 | source: "cache" as const, |
| 353 | 387 | })), |
| 354 | 388 | ); |
| ... | ... | @@ -358,7 +392,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 358 | 392 | const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( |
| 359 | 393 | config: ServerConfig, |
| 360 | 394 | ) { |
| 361 | return yield* cache.saveServerConfig(environmentId, config).pipe( | |
| 395 | return yield* cache.saveServerConfig(environmentId, withoutEnvironmentThemes(config)).pipe( | |
| 362 | 396 | Effect.as(true), |
| 363 | 397 | Effect.catch((error) => |
| 364 | 398 | Effect.logWarning("Could not persist cached server configuration.").pipe( |
| ... | ... | @@ -389,7 +423,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 389 | 423 | Effect.forkScoped, |
| 390 | 424 | ); |
| 391 | 425 | |
| 392 | yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( | |
| 426 | yield* subscribe( | |
| 427 | WS_METHODS.subscribeServerConfig, | |
| 428 | environmentThemes === true ? { environmentThemes: true } : {}, | |
| 429 | ).pipe( | |
| 393 | 430 | Stream.runForEach((event) => |
| 394 | 431 | Effect.gen(function* () { |
| 395 | 432 | const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event); |
| ... | ... | @@ -419,11 +456,14 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 419 | 456 | }, |
| 420 | 457 | ); |
| 421 | 458 | |
| 422 | export function serverConfigStateChanges(environmentId: EnvironmentId) { | |
| 459 | export function serverConfigStateChanges( | |
| 460 | environmentId: EnvironmentId, | |
| 461 | environmentThemes?: boolean, | |
| 462 | ) { | |
| 423 | 463 | return followStreamInEnvironment( |
| 424 | 464 | environmentId, |
| 425 | 465 | Stream.unwrap( |
| 426 | makeEnvironmentServerConfigState().pipe( | |
| 466 | makeEnvironmentServerConfigState(environmentThemes).pipe( | |
| 427 | 467 | Effect.map((state) => |
| 428 | 468 | SubscriptionRef.changes(state).pipe( |
| 429 | 469 | Stream.filterMap((projection) => |
| ... | ... | @@ -476,6 +516,12 @@ export function createServerEnvironmentAtoms<R, E>( |
| 476 | 516 | readonly initialConfigValueAtom: ( |
| 477 | 517 | environmentId: EnvironmentId, |
| 478 | 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 | 527 | const configScheduler = createAtomCommandScheduler(); |
| ... | ... | @@ -487,7 +533,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 487 | 533 | }; |
| 488 | 534 | const configProjectionFamily = Atom.family((environmentId: EnvironmentId) => |
| 489 | 535 | runtime |
| 490 | .atom(serverConfigStateChanges(environmentId)) | |
| 536 | .atom(serverConfigStateChanges(environmentId, options.environmentThemes)) | |
| 491 | 537 | .pipe( |
| 492 | 538 | Atom.setIdleTTL(5 * 60_000), |
| 493 | 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 | 66 | /** Server understands thread.snooze / thread.unsnooze commands. Same |
| 67 | 67 | version-skew contract as threadSettlement. */ |
| 68 | 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 | 74 | /** Server understands thread.pin / thread.unpin commands. Same |
| 70 | 75 | version-skew contract as threadSettlement. */ |
| 71 | 76 | threadPinning: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/rpc.test.ts created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import * as Exit from "effect/Exit"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | ||
| 5 | import { 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 | */ | |
| 13 | describe("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 | 983 | }); |
| 984 | 984 | |
| 985 | 985 | export 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 | 996 | success: ServerConfigStreamEvent, |
| 988 | 997 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), |
| 989 | 998 | stream: true, |
packages/contracts/src/server.ts+111| ... | ... | @@ -417,6 +417,93 @@ export const ServerSignalProcessResult = Schema.Struct({ |
| 417 | 417 | }); |
| 418 | 418 | export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type; |
| 419 | 419 | |
| 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 | */ | |
| 427 | export const EnvironmentThemeColor = Schema.String.check( | |
| 428 | Schema.isPattern(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/), | |
| 429 | ); | |
| 430 | export 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 | */ | |
| 438 | export const EnvironmentThemeId = Schema.String.check( | |
| 439 | Schema.isPattern(/^(?!(?:system|light|dark)$)[a-z0-9](?:[a-z0-9-]{0,47})$/), | |
| 440 | ); | |
| 441 | export 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 | */ | |
| 451 | const 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 | ||
| 456 | const 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. */ | |
| 484 | export const EnvironmentThemeFile = Schema.Struct(environmentThemeFields); | |
| 485 | export type EnvironmentThemeFile = typeof EnvironmentThemeFile.Type; | |
| 486 | ||
| 487 | export const EnvironmentTheme = Schema.Struct({ | |
| 488 | /** The publishing filename without its extension, stable across recolors. */ | |
| 489 | id: EnvironmentThemeId, | |
| 490 | ...environmentThemeFields, | |
| 491 | }); | |
| 492 | export 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 | */ | |
| 500 | export 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 | ||
| 420 | 507 | export const ServerConfig = Schema.Struct({ |
| 421 | 508 | environment: ExecutionEnvironmentDescriptor, |
| 422 | 509 | auth: ServerAuthDescriptor, |
| ... | ... | @@ -451,6 +538,14 @@ export const ServerConfig = Schema.Struct({ |
| 451 | 538 | * fields to servers that don't advertise this. |
| 452 | 539 | */ |
| 453 | 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 | }); |
| 455 | 550 | export type ServerConfig = typeof ServerConfig.Type; |
| 456 | 551 | |
| ... | ... | @@ -535,11 +630,27 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ |
| 535 | 630 | export type ServerConfigStreamSettingsUpdatedEvent = |
| 536 | 631 | typeof ServerConfigStreamSettingsUpdatedEvent.Type; |
| 537 | 632 | |
| 633 | export const ServerConfigEnvironmentThemesUpdatedPayload = Schema.Struct({ | |
| 634 | /** The full published set; empty once the machine publishes none. */ | |
| 635 | themes: Schema.Array(EnvironmentTheme), | |
| 636 | }); | |
| 637 | export type ServerConfigEnvironmentThemesUpdatedPayload = | |
| 638 | typeof ServerConfigEnvironmentThemesUpdatedPayload.Type; | |
| 639 | ||
| 640 | export const ServerConfigStreamEnvironmentThemesUpdatedEvent = Schema.Struct({ | |
| 641 | version: Schema.Literal(1), | |
| 642 | type: Schema.Literal("environmentThemesUpdated"), | |
| 643 | payload: ServerConfigEnvironmentThemesUpdatedPayload, | |
| 644 | }); | |
| 645 | export type ServerConfigStreamEnvironmentThemesUpdatedEvent = | |
| 646 | typeof ServerConfigStreamEnvironmentThemesUpdatedEvent.Type; | |
| 647 | ||
| 538 | 648 | export const ServerConfigStreamEvent = Schema.Union([ |
| 539 | 649 | ServerConfigStreamSnapshotEvent, |
| 540 | 650 | ServerConfigStreamKeybindingsUpdatedEvent, |
| 541 | 651 | ServerConfigStreamProviderStatusesEvent, |
| 542 | 652 | ServerConfigStreamSettingsUpdatedEvent, |
| 653 | ServerConfigStreamEnvironmentThemesUpdatedEvent, | |
| 543 | 654 | ]); |
| 544 | 655 | export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; |
| 545 | 656 |
packages/contracts/src/settings.ts+24| ... | ... | @@ -131,6 +131,19 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM |
| 131 | 131 | export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); |
| 132 | 132 | export type FontFamilyPreference = typeof FontFamilyPreference.Type; |
| 133 | 133 | |
| 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 | */ | |
| 141 | export 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. | |
| 145 | export type DefaultThemePreference = typeof DefaultThemePreference.Type; | |
| 146 | ||
| 134 | 147 | /** |
| 135 | 148 | * Defaults for the in-app preview browser, applied whenever a tab is opened |
| 136 | 149 | * without an explicit viewport/zoom/appearance — by the user opening a browser |
| ... | ... | @@ -653,6 +666,17 @@ export const ServerSettings = Schema.Struct({ |
| 653 | 666 | backgroundActivityProfile: BackgroundActivityProfile.pipe( |
| 654 | 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 | 680 | defaultThreadEnvMode: ThreadEnvMode.pipe( |
| 657 | 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 | 10 | */ |
| 11 | 11 | export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const; |
| 12 | 12 | |
| 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 | */ | |
| 19 | export 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 | */ | |
| 37 | export const UNPUBLISHABLE_THEME_IDS: ReadonlySet<string> = new Set([ | |
| 38 | ...RESERVED_THEME_IDS, | |
| 39 | MOBILE_DEFAULT_THEME_ID, | |
| 40 | ]); | |
| 41 | ||
| 13 | 42 | export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number]; |
| 14 | 43 | export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number]; |
| 15 | 44 | export type ThemeAppearance = "light" | "dark"; |