| 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,6 +17,7 @@ import { projectCommand } from "./cli/project.ts"; |
| 17 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; | 17 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; |
| 18 | import { serviceCommand } from "./cli/service.ts"; | 18 | import { serviceCommand } from "./cli/service.ts"; |
| 19 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; | 19 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; |
| 20 | import { themeCommand } from "./cli/theme.ts"; | ||
| 20 | import { triageCommand } from "./cli/triage.ts"; | 21 | import { triageCommand } from "./cli/triage.ts"; |
| 21 | 22 | ||
| 22 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 23 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| ... | @@ -57,6 +58,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => | ... | @@ -57,6 +58,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 57 | projectCommand, | 58 | projectCommand, |
| 58 | serviceCommand, | 59 | serviceCommand, |
| 59 | servicePreflightCommand, | 60 | servicePreflightCommand, |
| 61 | themeCommand, | ||
| 60 | triageCommand, | 62 | triageCommand, |
| 61 | cloudEnabled ? connectCommand : connectUnavailableCommand, | 63 | cloudEnabled ? connectCommand : connectUnavailableCommand, |
| 62 | ]), | 64 | ]), |
apps/server/src/cli/theme.test.ts created+478| ... | @@ -0,0 +1,478 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. | ||
| 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,6 +33,8 @@ export interface ServerDerivedPaths { |
| 33 | readonly dbPath: string; | 33 | readonly dbPath: string; |
| 34 | readonly keybindingsConfigPath: string; | 34 | readonly keybindingsConfigPath: string; |
| 35 | readonly settingsPath: string; | 35 | readonly settingsPath: string; |
| 36 | /** Palettes this machine publishes for clients to follow, one file per theme. */ | ||
| 37 | readonly environmentThemesDir: string; | ||
| 36 | readonly providerStatusCacheDir: string; | 38 | readonly providerStatusCacheDir: string; |
| 37 | readonly worktreesDir: string; | 39 | readonly worktreesDir: string; |
| 38 | readonly attachmentsDir: string; | 40 | readonly attachmentsDir: string; |
| ... | @@ -119,6 +121,7 @@ export const deriveServerPaths = Effect.fn(function* ( | ... | @@ -119,6 +121,7 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 119 | dbPath, | 121 | dbPath, |
| 120 | keybindingsConfigPath: join(stateDir, "keybindings.json"), | 122 | keybindingsConfigPath: join(stateDir, "keybindings.json"), |
| 121 | settingsPath: join(stateDir, "settings.json"), | 123 | settingsPath: join(stateDir, "settings.json"), |
| 124 | environmentThemesDir: join(stateDir, "themes"), | ||
| 122 | providerStatusCacheDir, | 125 | providerStatusCacheDir, |
| 123 | worktreesDir: join(baseDir, "worktrees"), | 126 | worktreesDir: join(baseDir, "worktrees"), |
| 124 | attachmentsDir, | 127 | attachmentsDir, |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -155,6 +155,7 @@ export const make = Effect.gen(function* () { | ... | @@ -155,6 +155,7 @@ export const make = Effect.gen(function* () { |
| 155 | pullRequests: true, | 155 | pullRequests: true, |
| 156 | threadSettlement: true, | 156 | threadSettlement: true, |
| 157 | threadSnooze: true, | 157 | threadSnooze: true, |
| 158 | environmentThemes: true, | ||
| 158 | threadPinning: true, | 159 | threadPinning: true, |
| 159 | threadPinReorder: true, | 160 | threadPinReorder: true, |
| 160 | threadTitleRegeneration: true, | 161 | threadTitleRegeneration: true, |
apps/server/src/environmentTheme.test.ts created+272| ... | @@ -0,0 +1,272 @@ | ||
| 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,6 +111,7 @@ import { |
| 111 | } from "./ws.ts"; | 111 | } from "./ws.ts"; |
| 112 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | 112 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 113 | import * as GitManager from "./git/GitManager.ts"; | 113 | import * as GitManager from "./git/GitManager.ts"; |
| 114 | import * as EnvironmentTheme from "./environmentTheme.ts"; | ||
| 114 | import * as Keybindings from "./keybindings.ts"; | 115 | import * as Keybindings from "./keybindings.ts"; |
| 115 | import * as ExternalLauncher from "./process/externalLauncher.ts"; | 116 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 116 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | 117 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| ... | @@ -395,6 +396,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -395,6 +396,7 @@ const buildAppUnderTest = (options?: { |
| 395 | config?: Partial<ServerConfig.ServerConfig["Service"]>; | 396 | config?: Partial<ServerConfig.ServerConfig["Service"]>; |
| 396 | layers?: { | 397 | layers?: { |
| 397 | keybindings?: Partial<Keybindings.Keybindings["Service"]>; | 398 | keybindings?: Partial<Keybindings.Keybindings["Service"]>; |
| 399 | environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>; | ||
| 398 | providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>; | 400 | providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>; |
| 399 | providerService?: Partial<ProviderService.ProviderService["Service"]>; | 401 | providerService?: Partial<ProviderService.ProviderService["Service"]>; |
| 400 | serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>; | 402 | serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>; |
| ... | @@ -630,14 +632,21 @@ const buildAppUnderTest = (options?: { | ... | @@ -630,14 +632,21 @@ const buildAppUnderTest = (options?: { |
| 630 | }, | 632 | }, |
| 631 | ).pipe( | 633 | ).pipe( |
| 632 | Layer.provide( | 634 | Layer.provide( |
| 633 | Layer.mock(Keybindings.Keybindings)({ | 635 | Layer.mergeAll( |
| 634 | loadConfigState: Effect.succeed({ | 636 | Layer.mock(Keybindings.Keybindings)({ |
| 635 | keybindings: [], | 637 | loadConfigState: Effect.succeed({ |
| 636 | issues: [], | 638 | keybindings: [], |
| 639 | issues: [], | ||
| 640 | }), | ||
| 641 | streamChanges: Stream.empty, | ||
| 642 | ...options?.layers?.keybindings, | ||
| 637 | }), | 643 | }), |
| 638 | streamChanges: Stream.empty, | 644 | Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ |
| 639 | ...options?.layers?.keybindings, | 645 | current: Effect.succeed([]), |
| 640 | }), | 646 | streamChanges: Stream.empty, |
| 647 | ...options?.layers?.environmentTheme, | ||
| 648 | }), | ||
| 649 | ), | ||
| 641 | ), | 650 | ), |
| 642 | Layer.provide( | 651 | Layer.provide( |
| 643 | Layer.mergeAll( | 652 | Layer.mergeAll( |
| ... | @@ -4981,6 +4990,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4981,6 +4990,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4981 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 4990 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4982 | ); | 4991 | ); |
| 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 | it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => | 5071 | it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => |
| 4985 | Effect.gen(function* () { | 5072 | Effect.gen(function* () { |
| 4986 | const nextProviders = [ | 5073 | const nextProviders = [ |
apps/server/src/server.ts+4-1| ... | @@ -53,6 +53,7 @@ import * as PreviewManager from "./preview/Manager.ts"; | ... | @@ -53,6 +53,7 @@ import * as PreviewManager from "./preview/Manager.ts"; |
| 53 | import * as PortScanner from "./preview/PortScanner.ts"; | 53 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 54 | import * as ProcessRunner from "./processRunner.ts"; | 54 | import * as ProcessRunner from "./processRunner.ts"; |
| 55 | import * as GitManager from "./git/GitManager.ts"; | 55 | import * as GitManager from "./git/GitManager.ts"; |
| 56 | import * as EnvironmentTheme from "./environmentTheme.ts"; | ||
| 56 | import * as Keybindings from "./keybindings.ts"; | 57 | import * as Keybindings from "./keybindings.ts"; |
| 57 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; | 58 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 58 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; | 59 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; |
| ... | @@ -383,7 +384,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -383,7 +384,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 383 | Layer.provideMerge(ProviderRuntimeLayerLive), | 384 | Layer.provideMerge(ProviderRuntimeLayerLive), |
| 384 | Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), | 385 | Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), |
| 385 | Layer.provideMerge(PersistenceLayerLive), | 386 | Layer.provideMerge(PersistenceLayerLive), |
| 386 | Layer.provideMerge(Keybindings.layer), | 387 | // Both read a user-owned file out of the state directory and stream changes |
| 388 | // to clients; neither depends on the other. | ||
| 389 | Layer.provideMerge(Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer)), | ||
| 387 | Layer.provideMerge(ProviderRegistryLive), | 390 | Layer.provideMerge(ProviderRegistryLive), |
| 388 | // The instance registry is the new routing keystone — text generation, | 391 | // The instance registry is the new routing keystone — text generation, |
| 389 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` | 392 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` |
apps/server/src/ws.ts+24-2| ... | @@ -73,6 +73,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | ... | @@ -73,6 +73,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 73 | 73 | ||
| 74 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | 74 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 75 | import * as ServerConfig from "./config.ts"; | 75 | import * as ServerConfig from "./config.ts"; |
| 76 | import * as EnvironmentTheme from "./environmentTheme.ts"; | ||
| 76 | import * as Keybindings from "./keybindings.ts"; | 77 | import * as Keybindings from "./keybindings.ts"; |
| 77 | import * as ExternalLauncher from "./process/externalLauncher.ts"; | 78 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 78 | import { | 79 | import { |
| ... | @@ -492,6 +493,7 @@ const makeWsRpcLayer = ( | ... | @@ -492,6 +493,7 @@ const makeWsRpcLayer = ( |
| 492 | }; | 493 | }; |
| 493 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; | 494 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 494 | const keybindings = yield* Keybindings.Keybindings; | 495 | const keybindings = yield* Keybindings.Keybindings; |
| 496 | const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; | ||
| 495 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; | 497 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 496 | const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; | 498 | const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; |
| 497 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; | 499 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; |
| ... | @@ -2378,7 +2380,7 @@ const makeWsRpcLayer = ( | ... | @@ -2378,7 +2380,7 @@ const makeWsRpcLayer = ( |
| 2378 | ), | 2380 | ), |
| 2379 | { "rpc.aggregate": "preview" }, | 2381 | { "rpc.aggregate": "preview" }, |
| 2380 | ), | 2382 | ), |
| 2381 | [WS_METHODS.subscribeServerConfig]: (_input) => | 2383 | [WS_METHODS.subscribeServerConfig]: (input) => |
| 2382 | observeRpcStreamEffect( | 2384 | observeRpcStreamEffect( |
| 2383 | WS_METHODS.subscribeServerConfig, | 2385 | WS_METHODS.subscribeServerConfig, |
| 2384 | Effect.gen(function* () { | 2386 | Effect.gen(function* () { |
| ... | @@ -2400,6 +2402,23 @@ const makeWsRpcLayer = ( | ... | @@ -2400,6 +2402,23 @@ const makeWsRpcLayer = ( |
| 2400 | })), | 2402 | })), |
| 2401 | Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), | 2403 | Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), |
| 2402 | ); | 2404 | ); |
| 2405 | // The only source of published themes: the stream emits the | ||
| 2406 | // current set before any change, so the snapshot carrying it too | ||
| 2407 | // would just send every client the same array twice per connect. | ||
| 2408 | // Gated on the subscriber's capability flag because an | ||
| 2409 | // already-shipped client decodes this stream against the old | ||
| 2410 | // event union and its whole config subscription dies on an | ||
| 2411 | // unknown member. | ||
| 2412 | const environmentThemeUpdates = | ||
| 2413 | input.environmentThemes === true | ||
| 2414 | ? environmentTheme.streamChanges.pipe( | ||
| 2415 | Stream.map((themes) => ({ | ||
| 2416 | version: 1 as const, | ||
| 2417 | type: "environmentThemesUpdated" as const, | ||
| 2418 | payload: { themes }, | ||
| 2419 | })), | ||
| 2420 | ) | ||
| 2421 | : Stream.empty; | ||
| 2403 | const settingsUpdates = serverSettings.streamChanges.pipe( | 2422 | const settingsUpdates = serverSettings.streamChanges.pipe( |
| 2404 | Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), | 2423 | Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), |
| 2405 | Stream.map((settings) => ({ | 2424 | Stream.map((settings) => ({ |
| ... | @@ -2415,7 +2434,10 @@ const makeWsRpcLayer = ( | ... | @@ -2415,7 +2434,10 @@ const makeWsRpcLayer = ( |
| 2415 | 2434 | ||
| 2416 | const liveUpdates = Stream.merge( | 2435 | const liveUpdates = Stream.merge( |
| 2417 | keybindingsUpdates, | 2436 | keybindingsUpdates, |
| 2418 | Stream.merge(providerStatuses, settingsUpdates), | 2437 | Stream.merge( |
| 2438 | providerStatuses, | ||
| 2439 | Stream.merge(settingsUpdates, environmentThemeUpdates), | ||
| 2440 | ), | ||
| 2419 | ); | 2441 | ); |
| 2420 | 2442 | ||
| 2421 | return Stream.concat( | 2443 | return Stream.concat( |
apps/web/src/components/settings/ThemeSettings.tsx+44-4| ... | @@ -10,9 +10,12 @@ import { | ... | @@ -10,9 +10,12 @@ import { |
| 10 | UploadIcon, | 10 | UploadIcon, |
| 11 | } from "lucide-react"; | 11 | } from "lucide-react"; |
| 12 | import { useCallback, useEffect, useState, type ReactElement } from "react"; | 12 | import { useCallback, useEffect, useState, type ReactElement } from "react"; |
| 13 | import { useEnvironmentThemeDefinitions } from "../../hooks/useEnvironmentTheme"; | ||
| 14 | import { readThemeHalvesRaw } from "../../hooks/useTheme"; | ||
| 13 | import { cn } from "../../lib/utils"; | 15 | import { cn } from "../../lib/utils"; |
| 14 | import { | 16 | import { |
| 15 | getThemeDefinition, | 17 | getThemeDefinition, |
| 18 | singleAppearanceOf, | ||
| 16 | getThemeModes, | 19 | getThemeModes, |
| 17 | removeCustomThemes, | 20 | removeCustomThemes, |
| 18 | serializeThemeFile, | 21 | serializeThemeFile, |
| ... | @@ -523,6 +526,7 @@ export function ThemeLibrary({ | ... | @@ -523,6 +526,7 @@ export function ThemeLibrary({ |
| 523 | setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean; | 526 | setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean; |
| 524 | }) { | 527 | }) { |
| 525 | const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); | 528 | const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); |
| 529 | const environmentThemes = useEnvironmentThemeDefinitions(); | ||
| 526 | const [themeRemovalTarget, setThemeRemovalTarget] = useState<{ | 530 | const [themeRemovalTarget, setThemeRemovalTarget] = useState<{ |
| 527 | theme: ThemeDefinition; | 531 | theme: ThemeDefinition; |
| 528 | collectionThemes: ReadonlyArray<ThemeDefinition>; | 532 | collectionThemes: ReadonlyArray<ThemeDefinition>; |
| ... | @@ -581,13 +585,17 @@ export function ThemeLibrary({ | ... | @@ -581,13 +585,17 @@ export function ThemeLibrary({ |
| 581 | const removedIds = new Set(themeIdsToRemove); | 585 | const removedIds = new Set(themeIdsToRemove); |
| 582 | if (removedIds.size === 0) return; | 586 | if (removedIds.size === 0) return; |
| 583 | const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? ""); | 587 | const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? ""); |
| 588 | // Captured raw before persistTheme clears the mix: a half naming a | ||
| 589 | // published theme whose set has not streamed in yet is pruned from the | ||
| 590 | // `themeHalves` prop, and rebuilding from that would drop it. | ||
| 591 | const storedHalves = readThemeHalvesRaw(); | ||
| 584 | // Keep the themes installed if we cannot move the selection off one of | 592 | // Keep the themes installed if we cannot move the selection off one of |
| 585 | // them; the dialog stays open so the user can retry or cancel. | 593 | // them; the dialog stays open so the user can retry or cancel. |
| 586 | if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) { | 594 | if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) { |
| 587 | return; | 595 | return; |
| 588 | } | 596 | } |
| 589 | for (const appearance of ["light", "dark"] as const) { | 597 | for (const appearance of ["light", "dark"] as const) { |
| 590 | const half = themeHalves?.[appearance]; | 598 | const half = storedHalves[appearance]; |
| 591 | if (half === undefined) continue; | 599 | if (half === undefined) continue; |
| 592 | // Writing a base preference clears the whole mix, so halves that name | 600 | // Writing a base preference clears the whole mix, so halves that name |
| 593 | // a surviving theme are written back; removed halves fall back to base. | 601 | // a surviving theme are written back; removed halves fall back to base. |
| ... | @@ -610,7 +618,6 @@ export function ThemeLibrary({ | ... | @@ -610,7 +618,6 @@ export function ThemeLibrary({ |
| 610 | persistTheme, | 618 | persistTheme, |
| 611 | setThemeHalf, | 619 | setThemeHalf, |
| 612 | theme, | 620 | theme, |
| 613 | themeHalves, | ||
| 614 | themeIdsToRemove, | 621 | themeIdsToRemove, |
| 615 | themeRemovalTarget, | 622 | themeRemovalTarget, |
| 616 | ]); | 623 | ]); |
| ... | @@ -629,7 +636,10 @@ export function ThemeLibrary({ | ... | @@ -629,7 +636,10 @@ export function ThemeLibrary({ |
| 629 | // the base would still own that appearance. Convert the base into an | 636 | // the base would still own that appearance. Convert the base into an |
| 630 | // explicit half on the other side so this side falls back to default. | 637 | // explicit half on the other side so this side falls back to default. |
| 631 | if (cardId === null && baseCardId !== null) { | 638 | if (cardId === null && baseCardId !== null) { |
| 632 | const otherOwner = themeHalves?.[otherAppearance] ?? baseCardId; | 639 | // Read raw, before persistTheme clears the mix: the other half may |
| 640 | // name a published theme that has not streamed in yet, and falling | ||
| 641 | // back to the base would silently rewrite it. | ||
| 642 | const otherOwner = readThemeHalvesRaw()[otherAppearance] ?? baseCardId; | ||
| 633 | if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return; | 643 | if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return; |
| 634 | if (!setThemeHalf(otherAppearance, otherOwner)) { | 644 | if (!setThemeHalf(otherAppearance, otherOwner)) { |
| 635 | // Best-effort rollback: restore the whole-theme selection rather | 645 | // Best-effort rollback: restore the whole-theme selection rather |
| ... | @@ -651,7 +661,6 @@ export function ThemeLibrary({ | ... | @@ -651,7 +661,6 @@ export function ThemeLibrary({ |
| 651 | setTheme, | 661 | setTheme, |
| 652 | setThemeHalf, | 662 | setThemeHalf, |
| 653 | theme, | 663 | theme, |
| 654 | themeHalves, | ||
| 655 | ], | 664 | ], |
| 656 | ); | 665 | ); |
| 657 | 666 | ||
| ... | @@ -809,6 +818,37 @@ export function ThemeLibrary({ | ... | @@ -809,6 +818,37 @@ export function ThemeLibrary({ |
| 809 | /> | 818 | /> |
| 810 | ); | 819 | ); |
| 811 | })} | 820 | })} |
| 821 | {environmentThemes | ||
| 822 | .filter( | ||
| 823 | // A saved theme with the same id wins resolution, so its card is | ||
| 824 | // the one that must show; rendering both would also collide keys. | ||
| 825 | (environmentTheme) => !customThemes.some((theme) => theme.id === environmentTheme.id), | ||
| 826 | ) | ||
| 827 | .map((environmentTheme) => ( | ||
| 828 | // No edit or remove: the environment republishes these palettes on | ||
| 829 | // every change, so anything saved here would be overwritten. | ||
| 830 | // Duplicating is the way to keep a copy. | ||
| 831 | <ThemeLibraryCard | ||
| 832 | activeModes={pickedModesFor(environmentTheme.id)} | ||
| 833 | isActive={false} | ||
| 834 | key={environmentTheme.id} | ||
| 835 | onDuplicate={() => | ||
| 836 | openThemeEditor({ | ||
| 837 | editingThemeId: null, | ||
| 838 | seedThemeId: environmentTheme.id, | ||
| 839 | seedName: `${environmentTheme.label} copy`, | ||
| 840 | initialAppearance, | ||
| 841 | }) | ||
| 842 | } | ||
| 843 | onUse={() => { | ||
| 844 | const half = singleAppearanceOf(environmentTheme); | ||
| 845 | if (half === null) persistTheme(environmentTheme.id); | ||
| 846 | else assignHalf(half, environmentTheme.id); | ||
| 847 | }} | ||
| 848 | onUseMode={handlePairPick(environmentTheme.id)} | ||
| 849 | theme={getThemeCardDefinition(environmentTheme)} | ||
| 850 | /> | ||
| 851 | ))} | ||
| 812 | {customThemeCollections.map(([collectionId, themes]) => ( | 852 | {customThemeCollections.map(([collectionId, themes]) => ( |
| 813 | <CustomThemeCollectionCard | 853 | <CustomThemeCollectionCard |
| 814 | activeModesFor={pickedModesFor} | 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,6 +13,7 @@ import { |
| 13 | resolveDesktopTheme, | 13 | resolveDesktopTheme, |
| 14 | resolveThemeAppearance, | 14 | resolveThemeAppearance, |
| 15 | resolveThemeHalf, | 15 | resolveThemeHalf, |
| 16 | THEME_PREVIEW_ID, | ||
| 16 | THEME_APPEARANCE_MODE_STORAGE_KEY, | 17 | THEME_APPEARANCE_MODE_STORAGE_KEY, |
| 17 | THEME_FOLLOW_SYSTEM_STORAGE_KEY, | 18 | THEME_FOLLOW_SYSTEM_STORAGE_KEY, |
| 18 | THEME_HALVES_STORAGE_KEY, | 19 | THEME_HALVES_STORAGE_KEY, |
| ... | @@ -25,6 +26,7 @@ import { | ... | @@ -25,6 +26,7 @@ import { |
| 25 | type Theme = ThemePreference; | 26 | type Theme = ThemePreference; |
| 26 | type ThemeSnapshot = { | 27 | type ThemeSnapshot = { |
| 27 | theme: Theme; | 28 | theme: Theme; |
| 29 | resolvedTheme: ThemeAppearance; | ||
| 28 | systemDark: boolean; | 30 | systemDark: boolean; |
| 29 | followSystem: boolean; | 31 | followSystem: boolean; |
| 30 | appearanceMode: ThemePreferenceMode; | 32 | appearanceMode: ThemePreferenceMode; |
| ... | @@ -37,6 +39,7 @@ const STORAGE_KEY = "t3code:theme"; | ... | @@ -37,6 +39,7 @@ const STORAGE_KEY = "t3code:theme"; |
| 37 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; | 39 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 38 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { | 40 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { |
| 39 | theme: "system", | 41 | theme: "system", |
| 42 | resolvedTheme: "light", | ||
| 40 | systemDark: false, | 43 | systemDark: false, |
| 41 | followSystem: true, | 44 | followSystem: true, |
| 42 | appearanceMode: "system", | 45 | appearanceMode: "system", |
| ... | @@ -49,6 +52,17 @@ export function readThemeHalves(): ThemeHalves | null { | ... | @@ -49,6 +52,17 @@ export function readThemeHalves(): ThemeHalves | null { |
| 49 | return readStoredThemeHalves(); | 52 | return readStoredThemeHalves(); |
| 50 | } | 53 | } |
| 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 | function readStoredThemeHalves(): ThemeHalves | null { | 66 | function readStoredThemeHalves(): ThemeHalves | null { |
| 53 | if (typeof window === "undefined") return null; | 67 | if (typeof window === "undefined") return null; |
| 54 | try { | 68 | try { |
| ... | @@ -58,6 +72,29 @@ function readStoredThemeHalves(): ThemeHalves | null { | ... | @@ -58,6 +72,29 @@ function readStoredThemeHalves(): ThemeHalves | null { |
| 58 | } | 72 | } |
| 59 | } | 73 | } |
| 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 | function themeHalvesSignature(halves: ThemeHalves | null): string { | 98 | function themeHalvesSignature(halves: ThemeHalves | null): string { |
| 62 | return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; | 99 | return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; |
| 63 | } | 100 | } |
| ... | @@ -98,7 +135,7 @@ let listeners: Array<() => void> = []; | ... | @@ -98,7 +135,7 @@ let listeners: Array<() => void> = []; |
| 98 | let lastSnapshot: ThemeSnapshot | null = null; | 135 | let lastSnapshot: ThemeSnapshot | null = null; |
| 99 | let snapshotStale = true; | 136 | let snapshotStale = true; |
| 100 | let lastDesktopTheme: "light" | "dark" | "system" | null = null; | 137 | let lastDesktopTheme: "light" | "dark" | "system" | null = null; |
| 101 | let lastAppliedTheme: ThemeSnapshot | null = null; | 138 | let lastAppliedTheme: Omit<ThemeSnapshot, "resolvedTheme"> | null = null; |
| 102 | let themeStorageReadFailure: ThemeStorageError | null = null; | 139 | let themeStorageReadFailure: ThemeStorageError | null = null; |
| 103 | 140 | ||
| 104 | function emitChange() { | 141 | function emitChange() { |
| ... | @@ -282,8 +319,10 @@ export function syncBrowserChromeTheme() { | ... | @@ -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 | if (typeof document === "undefined" || typeof window === "undefined") return; | 323 | if (typeof document === "undefined" || typeof window === "undefined") return; |
| 324 | // Keep the editor's draft visible until an explicit refresh restores the selection. | ||
| 325 | if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return; | ||
| 287 | const appearanceMode = readAppearanceModePreference(theme); | 326 | const appearanceMode = readAppearanceModePreference(theme); |
| 288 | const followSystem = appearanceMode === "system"; | 327 | const followSystem = appearanceMode === "system"; |
| 289 | const systemDark = followSystem ? getSystemDark() : false; | 328 | const systemDark = followSystem ? getSystemDark() : false; |
| ... | @@ -386,9 +425,17 @@ function getSnapshot(): ThemeSnapshot { | ... | @@ -386,9 +425,17 @@ function getSnapshot(): ThemeSnapshot { |
| 386 | const systemDark = followSystem ? getSystemDark() : false; | 425 | const systemDark = followSystem ? getSystemDark() : false; |
| 387 | const themeHalves = readStoredThemeHalves(); | 426 | const themeHalves = readStoredThemeHalves(); |
| 388 | 427 | ||
| 428 | const resolvedTheme = resolveThemeAppearance( | ||
| 429 | theme, | ||
| 430 | systemDark, | ||
| 431 | followSystem, | ||
| 432 | appearanceMode, | ||
| 433 | themeHalves, | ||
| 434 | ); | ||
| 389 | if ( | 435 | if ( |
| 390 | lastSnapshot && | 436 | lastSnapshot && |
| 391 | lastSnapshot.theme === theme && | 437 | lastSnapshot.theme === theme && |
| 438 | lastSnapshot.resolvedTheme === resolvedTheme && | ||
| 392 | lastSnapshot.systemDark === systemDark && | 439 | lastSnapshot.systemDark === systemDark && |
| 393 | lastSnapshot.followSystem === followSystem && | 440 | lastSnapshot.followSystem === followSystem && |
| 394 | lastSnapshot.appearanceMode === appearanceMode && | 441 | lastSnapshot.appearanceMode === appearanceMode && |
| ... | @@ -397,7 +444,7 @@ function getSnapshot(): ThemeSnapshot { | ... | @@ -397,7 +444,7 @@ function getSnapshot(): ThemeSnapshot { |
| 397 | return lastSnapshot; | 444 | return lastSnapshot; |
| 398 | } | 445 | } |
| 399 | 446 | ||
| 400 | lastSnapshot = { theme, systemDark, followSystem, appearanceMode, themeHalves }; | 447 | lastSnapshot = { theme, resolvedTheme, systemDark, followSystem, appearanceMode, themeHalves }; |
| 401 | return lastSnapshot; | 448 | return lastSnapshot; |
| 402 | } | 449 | } |
| 403 | 450 | ||
| ... | @@ -407,26 +454,28 @@ function getServerSnapshot() { | ... | @@ -407,26 +454,28 @@ function getServerSnapshot() { |
| 407 | 454 | ||
| 408 | function handleSystemAppearanceChange() { | 455 | function handleSystemAppearanceChange() { |
| 409 | const storedTheme = getStored(); | 456 | const storedTheme = getStored(); |
| 410 | if (readAppearanceModePreference(storedTheme) === "system") applyTheme(storedTheme, true); | 457 | if (readAppearanceModePreference(storedTheme) === "system") { |
| 458 | applyTheme(storedTheme, { suppressTransitions: true }); | ||
| 459 | } | ||
| 411 | emitChange(); | 460 | emitChange(); |
| 412 | } | 461 | } |
| 413 | 462 | ||
| 414 | function handleStorageChange(e: StorageEvent) { | 463 | function handleStorageChange(e: StorageEvent) { |
| 415 | if (e.key === STORAGE_KEY) { | 464 | if (e.key === STORAGE_KEY) { |
| 416 | themeStorageReadFailure = null; | 465 | themeStorageReadFailure = null; |
| 417 | applyTheme(getStored(), true); | 466 | applyTheme(getStored(), { suppressTransitions: true }); |
| 418 | emitChange(); | 467 | emitChange(); |
| 419 | } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) { | 468 | } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) { |
| 420 | applyTheme(getStored(), true); | 469 | applyTheme(getStored(), { suppressTransitions: true }); |
| 421 | emitChange(); | 470 | emitChange(); |
| 422 | } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) { | 471 | } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) { |
| 423 | applyTheme(getStored(), true); | 472 | applyTheme(getStored(), { suppressTransitions: true }); |
| 424 | emitChange(); | 473 | emitChange(); |
| 425 | } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) { | 474 | } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) { |
| 426 | if (e.key === null) themeStorageReadFailure = null; | 475 | if (e.key === null) themeStorageReadFailure = null; |
| 427 | invalidateCustomThemes(); | 476 | invalidateCustomThemes(); |
| 428 | lastAppliedTheme = null; | 477 | lastAppliedTheme = null; |
| 429 | applyTheme(getStored(), true); | 478 | applyTheme(getStored(), { suppressTransitions: true }); |
| 430 | emitChange(); | 479 | emitChange(); |
| 431 | } | 480 | } |
| 432 | } | 481 | } |
| ... | @@ -460,15 +509,7 @@ function subscribe(listener: () => void): () => void { | ... | @@ -460,15 +509,7 @@ function subscribe(listener: () => void): () => void { |
| 460 | 509 | ||
| 461 | export function useTheme() { | 510 | export function useTheme() { |
| 462 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | 511 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
| 463 | const theme = snapshot.theme; | 512 | const { theme, resolvedTheme } = snapshot; |
| 464 | |||
| 465 | const resolvedTheme: "light" | "dark" = resolveThemeAppearance( | ||
| 466 | theme, | ||
| 467 | snapshot.systemDark, | ||
| 468 | snapshot.followSystem, | ||
| 469 | snapshot.appearanceMode, | ||
| 470 | snapshot.themeHalves, | ||
| 471 | ); | ||
| 472 | 513 | ||
| 473 | const setTheme = useCallback((next: Theme): boolean => { | 514 | const setTheme = useCallback((next: Theme): boolean => { |
| 474 | if (typeof window === "undefined") return false; | 515 | if (typeof window === "undefined") return false; |
| ... | @@ -511,7 +552,7 @@ export function useTheme() { | ... | @@ -511,7 +552,7 @@ export function useTheme() { |
| 511 | }); | 552 | }); |
| 512 | return false; | 553 | return false; |
| 513 | } | 554 | } |
| 514 | applyTheme(next, true); | 555 | applyTheme(next, { suppressTransitions: true }); |
| 515 | emitChange(); | 556 | emitChange(); |
| 516 | return true; | 557 | return true; |
| 517 | }, []); | 558 | }, []); |
| ... | @@ -536,7 +577,7 @@ export function useTheme() { | ... | @@ -536,7 +577,7 @@ export function useTheme() { |
| 536 | return false; | 577 | return false; |
| 537 | } | 578 | } |
| 538 | themeStorageReadFailure = null; | 579 | themeStorageReadFailure = null; |
| 539 | applyTheme(getStored(), true); | 580 | applyTheme(getStored(), { suppressTransitions: true }); |
| 540 | emitChange(); | 581 | emitChange(); |
| 541 | return true; | 582 | return true; |
| 542 | }, []); | 583 | }, []); |
| ... | @@ -558,7 +599,7 @@ export function useTheme() { | ... | @@ -558,7 +599,7 @@ export function useTheme() { |
| 558 | (appearance: ThemeAppearance, themeId: string | null): boolean => { | 599 | (appearance: ThemeAppearance, themeId: string | null): boolean => { |
| 559 | if (typeof window === "undefined") return false; | 600 | if (typeof window === "undefined") return false; |
| 560 | try { | 601 | try { |
| 561 | const current = readStoredThemeHalves() ?? {}; | 602 | const current = readStoredThemeHalvesRaw(); |
| 562 | const next: { light?: string; dark?: string } = { ...current }; | 603 | const next: { light?: string; dark?: string } = { ...current }; |
| 563 | if (themeId === null) delete next[appearance]; | 604 | if (themeId === null) delete next[appearance]; |
| 564 | else next[appearance] = themeId; | 605 | else next[appearance] = themeId; |
| ... | @@ -580,7 +621,7 @@ export function useTheme() { | ... | @@ -580,7 +621,7 @@ export function useTheme() { |
| 580 | }); | 621 | }); |
| 581 | return false; | 622 | return false; |
| 582 | } | 623 | } |
| 583 | applyTheme(getStored(), true); | 624 | applyTheme(getStored(), { suppressTransitions: true }); |
| 584 | emitChange(); | 625 | emitChange(); |
| 585 | return true; | 626 | return true; |
| 586 | }, | 627 | }, |
| ... | @@ -604,15 +645,15 @@ export function useTheme() { | ... | @@ -604,15 +645,15 @@ export function useTheme() { |
| 604 | }); | 645 | }); |
| 605 | return false; | 646 | return false; |
| 606 | } | 647 | } |
| 607 | applyTheme(getStored(), true); | 648 | applyTheme(getStored(), { suppressTransitions: true }); |
| 608 | emitChange(); | 649 | emitChange(); |
| 609 | return true; | 650 | return true; |
| 610 | }, []); | 651 | }, []); |
| 611 | 652 | ||
| 612 | const refreshTheme = useCallback(() => { | 653 | const refreshTheme = useCallback(({ preservePreview = false } = {}) => { |
| 613 | if (typeof window === "undefined") return; | 654 | if (typeof window === "undefined") return; |
| 614 | lastAppliedTheme = null; | 655 | lastAppliedTheme = null; |
| 615 | applyTheme(getStored(), true); | 656 | applyTheme(getStored(), { suppressTransitions: true, preservePreview }); |
| 616 | emitChange(); | 657 | emitChange(); |
| 617 | }, []); | 658 | }, []); |
| 618 | 659 |
apps/web/src/routes/__root.tsx+12| ... | @@ -21,6 +21,8 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt | ... | @@ -21,6 +21,8 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt |
| 21 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | 21 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 22 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; | 22 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 23 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; | 23 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; |
| 24 | import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; | ||
| 25 | import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme"; | ||
| 24 | import { Button } from "../components/ui/button"; | 26 | import { Button } from "../components/ui/button"; |
| 25 | import { | 27 | import { |
| 26 | AnchoredToastProvider, | 28 | AnchoredToastProvider, |
| ... | @@ -133,6 +135,7 @@ function RootRouteView() { | ... | @@ -133,6 +135,7 @@ function RootRouteView() { |
| 133 | <AnchoredToastProvider> | 135 | <AnchoredToastProvider> |
| 134 | <DocumentTitleSync /> | 136 | <DocumentTitleSync /> |
| 135 | <ContrastAppearanceSync /> | 137 | <ContrastAppearanceSync /> |
| 138 | <EnvironmentThemeSync /> | ||
| 136 | <GlassAppearanceSync /> | 139 | <GlassAppearanceSync /> |
| 137 | <FontAppearanceSync /> | 140 | <FontAppearanceSync /> |
| 138 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} | 141 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| ... | @@ -154,6 +157,15 @@ function RootRouteView() { | ... | @@ -154,6 +157,15 @@ function RootRouteView() { |
| 154 | ); | 157 | ); |
| 155 | } | 158 | } |
| 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 | function ContrastAppearanceSync() { | 169 | function ContrastAppearanceSync() { |
| 158 | const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast); | 170 | const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast); |
| 159 | 171 |
apps/web/src/state/server.ts+20| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | type EditorId, | 3 | type EditorId, |
| 4 | type EnvironmentTheme, | ||
| 4 | type ServerConfig, | 5 | type ServerConfig, |
| 5 | type ServerConfigStreamEvent, | 6 | type ServerConfigStreamEvent, |
| 6 | type ServerLifecycleWelcomePayload, | 7 | type ServerLifecycleWelcomePayload, |
| ... | @@ -18,8 +19,15 @@ import { connectionAtomRuntime } from "../connection/runtime"; | ... | @@ -18,8 +19,15 @@ import { connectionAtomRuntime } from "../connection/runtime"; |
| 18 | import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; | 19 | import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; |
| 19 | import { environmentSession } from "./session"; | 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 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { | 28 | export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { |
| 22 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, | 29 | initialConfigValueAtom: environmentSession.initialConfigValueAtom, |
| 30 | environmentThemes: true, | ||
| 23 | }); | 31 | }); |
| 24 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ | 32 | export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ |
| 25 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 33 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| ... | @@ -94,6 +102,18 @@ export const primaryServerKeybindingsConfigPathAtom = Atom.make( | ... | @@ -94,6 +102,18 @@ export const primaryServerKeybindingsConfigPathAtom = Atom.make( |
| 94 | (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, | 102 | (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, |
| 95 | ).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); | 103 | ).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); |
| 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 | export const primaryServerObservabilityAtom = Atom.make( | 117 | export const primaryServerObservabilityAtom = Atom.make( |
| 98 | (get): ServerConfig["observability"] | null => | 118 | (get): ServerConfig["observability"] | null => |
| 99 | get(primaryServerConfigAtom)?.observability ?? null, | 119 | get(primaryServerConfigAtom)?.observability ?? null, |
apps/web/src/themePalette.test.ts+9| ... | @@ -39,6 +39,7 @@ import { | ... | @@ -39,6 +39,7 @@ import { |
| 39 | themeColorToHex, | 39 | themeColorToHex, |
| 40 | toCanonicalThemeColor, | 40 | toCanonicalThemeColor, |
| 41 | THEME_FILE_VERSION, | 41 | THEME_FILE_VERSION, |
| 42 | singleAppearanceOf, | ||
| 42 | } from "./themePalette"; | 43 | } from "./themePalette"; |
| 43 | 44 | ||
| 44 | function asHex(value: string): string { | 45 | function asHex(value: string): string { |
| ... | @@ -1072,3 +1073,11 @@ describe("stored theme preferences", () => { | ... | @@ -1072,3 +1073,11 @@ describe("stored theme preferences", () => { |
| 1072 | invalidateCustomThemes(); | 1073 | invalidateCustomThemes(); |
| 1073 | }); | 1074 | }); |
| 1074 | }); | 1075 | }); |
| 1076 | |||
| 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,3 +1,4 @@ |
| 1 | import * as Equal from "effect/Equal"; | ||
| 1 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 2 | import "culori/css"; | 3 | import "culori/css"; |
| 3 | import { converter, parse } from "culori/fn"; | 4 | import { converter, parse } from "culori/fn"; |
| ... | @@ -8,6 +9,7 @@ import { | ... | @@ -8,6 +9,7 @@ import { |
| 8 | IRIS_THEME, | 9 | IRIS_THEME, |
| 9 | OCEAN_THEME, | 10 | OCEAN_THEME, |
| 10 | T3_CHAT_THEME, | 11 | T3_CHAT_THEME, |
| 12 | RESERVED_THEME_IDS, | ||
| 11 | THEME_COLOR_ROLES, | 13 | THEME_COLOR_ROLES, |
| 12 | type ThemeAppearance, | 14 | type ThemeAppearance, |
| 13 | type ThemeColorRole, | 15 | type ThemeColorRole, |
| ... | @@ -56,21 +58,16 @@ export type ThemeFile = Readonly<{ | ... | @@ -56,21 +58,16 @@ export type ThemeFile = Readonly<{ |
| 56 | managed?: boolean; | 58 | managed?: boolean; |
| 57 | }>; | 59 | }>; |
| 58 | 60 | ||
| 59 | const RESERVED_THEME_IDS = new Set([ | 61 | // Reserved ids come from shared so the CLI, the server watcher, and this |
| 60 | "system", | 62 | // library cannot drift on what a published theme may be called. |
| 61 | "light", | 63 | |
| 62 | "dark", | 64 | /** |
| 63 | T3_CHAT_THEME_ID, | 65 | * The environment's palettes are not saved: they are republished by the |
| 64 | GROVE_THEME_ID, | 66 | * server on every change and would go stale the moment the machine's theme |
| 65 | OCEAN_THEME_ID, | 67 | * moved on. They ride the custom-theme listeners so every theme consumer |
| 66 | EMBER_THEME_ID, | 68 | * already re-reads when they change. |
| 67 | IRIS_THEME_ID, | 69 | */ |
| 68 | LEGACY_T3_CHAT_DARK_THEME_ID, | 70 | let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> = []; |
| 69 | "t3-grove", | ||
| 70 | "t3-ocean", | ||
| 71 | "t3-ember", | ||
| 72 | "t3-iris", | ||
| 73 | ]); | ||
| 74 | 71 | ||
| 75 | const customThemeListeners = new Set<() => void>(); | 72 | const customThemeListeners = new Set<() => void>(); |
| 76 | type CustomThemeLibrarySnapshot = | 73 | type CustomThemeLibrarySnapshot = |
| ... | @@ -130,21 +127,28 @@ function parseThemeCollection(value: unknown): ThemeCollection | undefined { | ... | @@ -130,21 +127,28 @@ function parseThemeCollection(value: unknown): ThemeCollection | undefined { |
| 130 | : undefined; | 127 | : undefined; |
| 131 | } | 128 | } |
| 132 | 129 | ||
| 133 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { | 130 | /** |
| 134 | if (!isRecord(value)) return null; | 131 | * Tolerates unknown roles and malformed values so themes written by other |
| 135 | 132 | * builds (for example one that adds a new role) keep their remaining colors. | |
| 136 | const colors: Partial<Record<ThemeColorRole, string>> = { | 133 | * The one canonicalization path for every externally supplied color record: |
| 137 | ...getDefaultThemeColors(appearance), | 134 | * stored themes, imported files, and environment-published themes. |
| 138 | }; | 135 | */ |
| 139 | // Tolerate unknown roles and malformed values so themes saved by other | 136 | export function lenientThemeColorOverrides( |
| 140 | // builds (for example one that adds a new role) keep their remaining colors. | 137 | value: Readonly<Record<string, unknown>>, |
| 138 | ): Partial<Record<ThemeColorRole, string>> { | ||
| 139 | const overrides: Partial<Record<ThemeColorRole, string>> = {}; | ||
| 141 | for (const [role, color] of Object.entries(value)) { | 140 | for (const [role, color] of Object.entries(value)) { |
| 142 | const normalized = toCanonicalThemeColor(color); | 141 | const normalized = toCanonicalThemeColor(color); |
| 143 | if (THEME_COLOR_ROLE_SET.has(role) && normalized) { | 142 | if (THEME_COLOR_ROLE_SET.has(role) && normalized) { |
| 144 | colors[role as ThemeColorRole] = normalized; | 143 | overrides[role as ThemeColorRole] = normalized; |
| 145 | } | 144 | } |
| 146 | } | 145 | } |
| 147 | return colors as ThemeColors; | 146 | return overrides; |
| 147 | } | ||
| 148 | |||
| 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 | function parseStoredThemeVariants( | 154 | function parseStoredThemeVariants( |
| ... | @@ -244,6 +248,27 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { | ... | @@ -244,6 +248,27 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { |
| 244 | return snapshot.status === "ready" ? snapshot.themes : []; | 248 | return snapshot.status === "ready" ? snapshot.themes : []; |
| 245 | } | 249 | } |
| 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 | export function getStoredCustomThemeCollection( | 272 | export function getStoredCustomThemeCollection( |
| 248 | collectionId: string, | 273 | collectionId: string, |
| 249 | ): ReadonlyArray<ThemeDefinition> { | 274 | ): ReadonlyArray<ThemeDefinition> { |
| ... | @@ -1424,6 +1449,9 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu | ... | @@ -1424,6 +1449,9 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu |
| 1424 | return ( | 1449 | return ( |
| 1425 | BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ?? | 1450 | BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ?? |
| 1426 | getCustomThemes().find((definition) => definition.id === themeId) ?? | 1451 | getCustomThemes().find((definition) => definition.id === themeId) ?? |
| 1452 | // Resolved last so a theme the user saved always wins over one the | ||
| 1453 | // machine happens to publish under the same id. | ||
| 1454 | environmentThemeDefinitions.find((definition) => definition.id === themeId) ?? | ||
| 1427 | null | 1455 | null |
| 1428 | ); | 1456 | ); |
| 1429 | } | 1457 | } |
| ... | @@ -1437,6 +1465,17 @@ export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { | ... | @@ -1437,6 +1465,17 @@ export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { |
| 1437 | ); | 1465 | ); |
| 1438 | } | 1466 | } |
| 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 | export function getThemeColorsForMode( | 1479 | export function getThemeColorsForMode( |
| 1441 | theme: ThemeDefinition, | 1480 | theme: ThemeDefinition, |
| 1442 | mode: ThemeAppearance, | 1481 | mode: ThemeAppearance, |
docs/README.md+1| ... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
| 9 | - [Review usage](./user/usage.md) | 9 | - [Review usage](./user/usage.md) |
| 10 | - [Customize a project icon](./user/project-settings.md) | 10 | - [Customize a project icon](./user/project-settings.md) |
| 11 | - [Mobile appearance](./user/mobile-appearance.md) | 11 | - [Mobile appearance](./user/mobile-appearance.md) |
| 12 | - [Environment themes](./user/environment-theme.md) | ||
| 12 | - [Remote access](./user/remote-access.md) | 13 | - [Remote access](./user/remote-access.md) |
| 13 | - [Keeping app and server in sync](./user/updating.md) | 14 | - [Keeping app and server in sync](./user/updating.md) |
| 14 | - [Source control integrations](./user/source-control.md) | 15 | - [Source control integrations](./user/source-control.md) |
docs/internals/glossary.md+18| ... | @@ -11,6 +11,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi | ... | @@ -11,6 +11,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi |
| 11 | - [Orchestration](#orchestration) | 11 | - [Orchestration](#orchestration) |
| 12 | - [Provider runtime](#provider-runtime) | 12 | - [Provider runtime](#provider-runtime) |
| 13 | - [Checkpointing](#checkpointing) | 13 | - [Checkpointing](#checkpointing) |
| 14 | - [Appearance](#appearance) | ||
| 14 | 15 | ||
| 15 | ## Concepts | 16 | ## Concepts |
| 16 | 17 | ||
| ... | @@ -144,6 +145,21 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi | ... | @@ -144,6 +145,21 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi |
| 144 | 145 | ||
| 145 | 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 | 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 | ## Practical Shortcuts | 163 | ## Practical Shortcuts |
| 148 | 164 | ||
| 149 | - If you see `requested`, think "intent recorded". | 165 | - If you see `requested`, think "intent recorded". |
| ... | @@ -183,3 +199,5 @@ The file patch and changed-file summary for one turn. It is usually computed in | ... | @@ -183,3 +199,5 @@ The file patch and changed-file summary for one turn. It is usually computed in |
| 183 | [22]: ../../apps/server/src/checkpointing/Utils.ts | 199 | [22]: ../../apps/server/src/checkpointing/Utils.ts |
| 184 | [23]: ../../apps/server/src/checkpointing/Diffs.ts | 200 | [23]: ../../apps/server/src/checkpointing/Diffs.ts |
| 185 | [24]: ./overview.md | 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,6 +51,9 @@ const CONFIG = { |
| 51 | observability: null, | 51 | observability: null, |
| 52 | providers: [], | 52 | providers: [], |
| 53 | settings: {}, | 53 | settings: {}, |
| 54 | // Capabilities drive version-skew behaviour in the projection, so the | ||
| 55 | // fixture carries them rather than leaving the field absent. | ||
| 56 | environment: { capabilities: { environmentThemes: true } }, | ||
| 54 | } as unknown as ServerConfig; | 57 | } as unknown as ServerConfig; |
| 55 | 58 | ||
| 56 | const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ | 59 | const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ |
| ... | @@ -306,6 +309,78 @@ describe("server state projection", () => { | ... | @@ -306,6 +309,78 @@ describe("server state projection", () => { |
| 306 | expect(result.latestEvent.type).toBe("settingsUpdated"); | 309 | expect(result.latestEvent.type).toBe("settingsUpdated"); |
| 307 | }); | 310 | }); |
| 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 | it("retains welcome when a ready event follows in the same stream chunk", () => { | 384 | it("retains welcome when a ready event follows in the same stream chunk", () => { |
| 310 | const welcome = { | 385 | const welcome = { |
| 311 | environment: {} as ServerLifecycleWelcomePayload["environment"], | 386 | environment: {} as ServerLifecycleWelcomePayload["environment"], |
packages/client-runtime/src/state/server.ts+58-12| ... | @@ -273,12 +273,24 @@ export function applyServerConfigProjection( | ... | @@ -273,12 +273,24 @@ export function applyServerConfigProjection( |
| 273 | event: ServerConfigStreamEvent, | 273 | event: ServerConfigStreamEvent, |
| 274 | ): Option.Option<ServerConfigProjection> { | 274 | ): Option.Option<ServerConfigProjection> { |
| 275 | switch (event.type) { | 275 | switch (event.type) { |
| 276 | case "snapshot": | 276 | case "snapshot": { |
| 277 | // A snapshot never carries published themes -- the theme stream owns | ||
| 278 | // them -- so taking it wholesale would clear the set on every reconnect | ||
| 279 | // and repaint anyone wearing one until the follow-up event landed. | ||
| 280 | // Only from a server that still streams them. Reconnecting to one that | ||
| 281 | // predates the feature must drop the set rather than leave a palette on | ||
| 282 | // screen that nothing will ever update again. | ||
| 283 | const carried = | ||
| 284 | event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) | ||
| 285 | ? current.value.config.environmentThemes | ||
| 286 | : undefined; | ||
| 277 | return Option.some({ | 287 | return Option.some({ |
| 278 | config: event.config, | 288 | config: |
| 289 | carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, | ||
| 279 | latestEvent: event, | 290 | latestEvent: event, |
| 280 | source: "live", | 291 | source: "live" as const, |
| 281 | }); | 292 | }); |
| 293 | } | ||
| 282 | case "keybindingsUpdated": | 294 | case "keybindingsUpdated": |
| 283 | return Option.map(current, (projection) => ({ | 295 | return Option.map(current, (projection) => ({ |
| 284 | config: { | 296 | config: { |
| ... | @@ -307,6 +319,15 @@ export function applyServerConfigProjection( | ... | @@ -307,6 +319,15 @@ export function applyServerConfigProjection( |
| 307 | latestEvent: event, | 319 | latestEvent: event, |
| 308 | source: "live", | 320 | source: "live", |
| 309 | })); | 321 | })); |
| 322 | case "environmentThemesUpdated": | ||
| 323 | return Option.map(current, (projection) => ({ | ||
| 324 | config: { | ||
| 325 | ...projection.config, | ||
| 326 | environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, | ||
| 327 | }, | ||
| 328 | latestEvent: event, | ||
| 329 | source: "live", | ||
| 330 | })); | ||
| 310 | } | 331 | } |
| 311 | } | 332 | } |
| 312 | 333 | ||
| ... | @@ -329,8 +350,19 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven | ... | @@ -329,8 +350,19 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven |
| 329 | * config carries the provider/model catalogue used by task creation, so it is | 350 | * config carries the provider/model catalogue used by task creation, so it is |
| 330 | * useful—and safe—to retain after a transport session ends. | 351 | * useful—and safe—to retain after a transport session ends. |
| 331 | */ | 352 | */ |
| 353 | /** | ||
| 354 | * Published themes live only as long as the machine publishes them, so they | ||
| 355 | * must not survive in the config cache: a restart or an offline load would | ||
| 356 | * otherwise hand clients palettes the environment has already dropped. | ||
| 357 | */ | ||
| 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 | export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( | 364 | export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( |
| 333 | function* () { | 365 | function* (environmentThemes?: boolean) { |
| 334 | const supervisor = yield* EnvironmentSupervisor; | 366 | const supervisor = yield* EnvironmentSupervisor; |
| 335 | const cache = yield* EnvironmentCacheStore; | 367 | const cache = yield* EnvironmentCacheStore; |
| 336 | const environmentId = supervisor.target.environmentId; | 368 | const environmentId = supervisor.target.environmentId; |
| ... | @@ -346,9 +378,11 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf | ... | @@ -346,9 +378,11 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 346 | ), | 378 | ), |
| 347 | ); | 379 | ); |
| 348 | const state = yield* SubscriptionRef.make<Option.Option<ServerConfigProjection>>( | 380 | const state = yield* SubscriptionRef.make<Option.Option<ServerConfigProjection>>( |
| 349 | Option.map(cachedConfig, (config) => ({ | 381 | // Stripped on load as well as on save: a cache written by an earlier |
| 350 | config, | 382 | // build can still carry published themes. |
| 351 | latestEvent: cachedConfigSnapshotEvent(config), | 383 | Option.map(cachedConfig, (cached) => ({ |
| 384 | config: withoutEnvironmentThemes(cached), | ||
| 385 | latestEvent: cachedConfigSnapshotEvent(withoutEnvironmentThemes(cached)), | ||
| 352 | source: "cache" as const, | 386 | source: "cache" as const, |
| 353 | })), | 387 | })), |
| 354 | ); | 388 | ); |
| ... | @@ -358,7 +392,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf | ... | @@ -358,7 +392,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 358 | const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( | 392 | const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( |
| 359 | config: ServerConfig, | 393 | config: ServerConfig, |
| 360 | ) { | 394 | ) { |
| 361 | return yield* cache.saveServerConfig(environmentId, config).pipe( | 395 | return yield* cache.saveServerConfig(environmentId, withoutEnvironmentThemes(config)).pipe( |
| 362 | Effect.as(true), | 396 | Effect.as(true), |
| 363 | Effect.catch((error) => | 397 | Effect.catch((error) => |
| 364 | Effect.logWarning("Could not persist cached server configuration.").pipe( | 398 | Effect.logWarning("Could not persist cached server configuration.").pipe( |
| ... | @@ -389,7 +423,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf | ... | @@ -389,7 +423,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 389 | Effect.forkScoped, | 423 | Effect.forkScoped, |
| 390 | ); | 424 | ); |
| 391 | 425 | ||
| 392 | yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( | 426 | yield* subscribe( |
| 427 | WS_METHODS.subscribeServerConfig, | ||
| 428 | environmentThemes === true ? { environmentThemes: true } : {}, | ||
| 429 | ).pipe( | ||
| 393 | Stream.runForEach((event) => | 430 | Stream.runForEach((event) => |
| 394 | Effect.gen(function* () { | 431 | Effect.gen(function* () { |
| 395 | const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event); | 432 | const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event); |
| ... | @@ -419,11 +456,14 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf | ... | @@ -419,11 +456,14 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf |
| 419 | }, | 456 | }, |
| 420 | ); | 457 | ); |
| 421 | 458 | ||
| 422 | export function serverConfigStateChanges(environmentId: EnvironmentId) { | 459 | export function serverConfigStateChanges( |
| 460 | environmentId: EnvironmentId, | ||
| 461 | environmentThemes?: boolean, | ||
| 462 | ) { | ||
| 423 | return followStreamInEnvironment( | 463 | return followStreamInEnvironment( |
| 424 | environmentId, | 464 | environmentId, |
| 425 | Stream.unwrap( | 465 | Stream.unwrap( |
| 426 | makeEnvironmentServerConfigState().pipe( | 466 | makeEnvironmentServerConfigState(environmentThemes).pipe( |
| 427 | Effect.map((state) => | 467 | Effect.map((state) => |
| 428 | SubscriptionRef.changes(state).pipe( | 468 | SubscriptionRef.changes(state).pipe( |
| 429 | Stream.filterMap((projection) => | 469 | Stream.filterMap((projection) => |
| ... | @@ -476,6 +516,12 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -476,6 +516,12 @@ export function createServerEnvironmentAtoms<R, E>( |
| 476 | readonly initialConfigValueAtom: ( | 516 | readonly initialConfigValueAtom: ( |
| 477 | environmentId: EnvironmentId, | 517 | environmentId: EnvironmentId, |
| 478 | ) => Atom.Atom<ServerConfig | null>; | 518 | ) => Atom.Atom<ServerConfig | null>; |
| 519 | /** | ||
| 520 | * Whether this surface renders themes the environment publishes. Mobile | ||
| 521 | * keeps its own appearance settings, so it neither asks for the stream nor | ||
| 522 | * receives the payload. | ||
| 523 | */ | ||
| 524 | readonly environmentThemes?: boolean; | ||
| 479 | }, | 525 | }, |
| 480 | ) { | 526 | ) { |
| 481 | const configScheduler = createAtomCommandScheduler(); | 527 | const configScheduler = createAtomCommandScheduler(); |
| ... | @@ -487,7 +533,7 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -487,7 +533,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 487 | }; | 533 | }; |
| 488 | const configProjectionFamily = Atom.family((environmentId: EnvironmentId) => | 534 | const configProjectionFamily = Atom.family((environmentId: EnvironmentId) => |
| 489 | runtime | 535 | runtime |
| 490 | .atom(serverConfigStateChanges(environmentId)) | 536 | .atom(serverConfigStateChanges(environmentId, options.environmentThemes)) |
| 491 | .pipe( | 537 | .pipe( |
| 492 | Atom.setIdleTTL(5 * 60_000), | 538 | Atom.setIdleTTL(5 * 60_000), |
| 493 | Atom.withLabel(`environment-data:server:config-projection:${environmentId}`), | 539 | Atom.withLabel(`environment-data:server:config-projection:${environmentId}`), |
packages/contracts/src/environment.ts+5| ... | @@ -66,6 +66,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -66,6 +66,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 66 | /** Server understands thread.snooze / thread.unsnooze commands. Same | 66 | /** Server understands thread.snooze / thread.unsnooze commands. Same |
| 67 | version-skew contract as threadSettlement. */ | 67 | version-skew contract as threadSettlement. */ |
| 68 | threadSnooze: Schema.optionalKey(Schema.Boolean), | 68 | threadSnooze: Schema.optionalKey(Schema.Boolean), |
| 69 | /** Server streams themes an environment publishes. Absent on servers from | ||
| 70 | before environment themes shipped, which never emit the events -- so a | ||
| 71 | client reconnecting to one must drop published themes rather than keep | ||
| 72 | showing a set nothing will ever update. */ | ||
| 73 | environmentThemes: Schema.optionalKey(Schema.Boolean), | ||
| 69 | /** Server understands thread.pin / thread.unpin commands. Same | 74 | /** Server understands thread.pin / thread.unpin commands. Same |
| 70 | version-skew contract as threadSettlement. */ | 75 | version-skew contract as threadSettlement. */ |
| 71 | threadPinning: Schema.optionalKey(Schema.Boolean), | 76 | threadPinning: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/rpc.test.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 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,7 +983,16 @@ export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTermi |
| 983 | }); | 983 | }); |
| 984 | 984 | ||
| 985 | export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { | 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 | success: ServerConfigStreamEvent, | 996 | success: ServerConfigStreamEvent, |
| 988 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), | 997 | error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), |
| 989 | stream: true, | 998 | stream: true, |
packages/contracts/src/server.ts+111| ... | @@ -417,6 +417,93 @@ export const ServerSignalProcessResult = Schema.Struct({ | ... | @@ -417,6 +417,93 @@ export const ServerSignalProcessResult = Schema.Struct({ |
| 417 | }); | 417 | }); |
| 418 | export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type; | 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 | export const ServerConfig = Schema.Struct({ | 507 | export const ServerConfig = Schema.Struct({ |
| 421 | environment: ExecutionEnvironmentDescriptor, | 508 | environment: ExecutionEnvironmentDescriptor, |
| 422 | auth: ServerAuthDescriptor, | 509 | auth: ServerAuthDescriptor, |
| ... | @@ -451,6 +538,14 @@ export const ServerConfig = Schema.Struct({ | ... | @@ -451,6 +538,14 @@ export const ServerConfig = Schema.Struct({ |
| 451 | * fields to servers that don't advertise this. | 538 | * fields to servers that don't advertise this. |
| 452 | */ | 539 | */ |
| 453 | threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), | 540 | threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), |
| 541 | /** | ||
| 542 | * Palettes published by this environment's machine. Never sent in a config | ||
| 543 | * snapshot: the theme stream emits the current set before any change, so a | ||
| 544 | * snapshot carrying it too would hand every subscriber the same array twice | ||
| 545 | * per connect. Clients populate this by projecting `environmentThemesUpdated`, | ||
| 546 | * and it stays absent for subscribers that did not opt in. | ||
| 547 | */ | ||
| 548 | environmentThemes: Schema.optional(Schema.Array(EnvironmentTheme)), | ||
| 454 | }); | 549 | }); |
| 455 | export type ServerConfig = typeof ServerConfig.Type; | 550 | export type ServerConfig = typeof ServerConfig.Type; |
| 456 | 551 | ||
| ... | @@ -535,11 +630,27 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ | ... | @@ -535,11 +630,27 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ |
| 535 | export type ServerConfigStreamSettingsUpdatedEvent = | 630 | export type ServerConfigStreamSettingsUpdatedEvent = |
| 536 | typeof ServerConfigStreamSettingsUpdatedEvent.Type; | 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 | export const ServerConfigStreamEvent = Schema.Union([ | 648 | export const ServerConfigStreamEvent = Schema.Union([ |
| 539 | ServerConfigStreamSnapshotEvent, | 649 | ServerConfigStreamSnapshotEvent, |
| 540 | ServerConfigStreamKeybindingsUpdatedEvent, | 650 | ServerConfigStreamKeybindingsUpdatedEvent, |
| 541 | ServerConfigStreamProviderStatusesEvent, | 651 | ServerConfigStreamProviderStatusesEvent, |
| 542 | ServerConfigStreamSettingsUpdatedEvent, | 652 | ServerConfigStreamSettingsUpdatedEvent, |
| 653 | ServerConfigStreamEnvironmentThemesUpdatedEvent, | ||
| 543 | ]); | 654 | ]); |
| 544 | export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; | 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,6 +131,19 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM |
| 131 | export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); | 131 | export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); |
| 132 | export type FontFamilyPreference = typeof FontFamilyPreference.Type; | 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 | * Defaults for the in-app preview browser, applied whenever a tab is opened | 148 | * Defaults for the in-app preview browser, applied whenever a tab is opened |
| 136 | * without an explicit viewport/zoom/appearance — by the user opening a browser | 149 | * without an explicit viewport/zoom/appearance — by the user opening a browser |
| ... | @@ -653,6 +666,17 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -653,6 +666,17 @@ export const ServerSettings = Schema.Struct({ |
| 653 | backgroundActivityProfile: BackgroundActivityProfile.pipe( | 666 | backgroundActivityProfile: BackgroundActivityProfile.pipe( |
| 654 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), | 667 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), |
| 655 | ), | 668 | ), |
| 669 | defaultTheme: DefaultThemePreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | ||
| 670 | /** | ||
| 671 | * When the environment's theme was last set, so clients can tell a re-set | ||
| 672 | * of the same value from one they already applied: `t3 theme set` must act | ||
| 673 | * even when it names the theme it named before. Empty on environments | ||
| 674 | * provisioned by builds that predate it, where clients fall back to | ||
| 675 | * applying once per value. | ||
| 676 | */ | ||
| 677 | defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe( | ||
| 678 | Schema.withDecodingDefault(Effect.succeed("")), | ||
| 679 | ), | ||
| 656 | defaultThreadEnvMode: ThreadEnvMode.pipe( | 680 | defaultThreadEnvMode: ThreadEnvMode.pipe( |
| 657 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), | 681 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), |
| 658 | ), | 682 | ), |
packages/shared/src/themePalettes.ts+29| ... | @@ -10,6 +10,35 @@ export const MOBILE_DEFAULT_THEME_ID = "t3-code"; | ... | @@ -10,6 +10,35 @@ export const MOBILE_DEFAULT_THEME_ID = "t3-code"; |
| 10 | */ | 10 | */ |
| 11 | export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const; | 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 | export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number]; | 42 | export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number]; |
| 14 | export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number]; | 43 | export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number]; |
| 15 | export type ThemeAppearance = "light" | "dark"; | 44 | export type ThemeAppearance = "light" | "dark"; |