From 2606da3fea4668e9d6d6c10cfab4875c6a251e15 Mon Sep 17 00:00:00 2001 From: pouyarezvani Date: Sat, 14 Nov 2026 02:26:21 -0800 Subject: [PATCH 1/3] feat(themes): theme CSS can hold 50,000 characters A theme's custom CSS was capped at 20,000 characters. A theme that restyles most of the app already needs about 15,500 characters with comments and whitespace stripped, so the cap left little room. THEME_CSS_MAX_LENGTH goes to 50,000. The server's per-theme-file cap goes from 32 KB to 80 KB and the total from 192 KB to 480 KB, so a theme with 50,000 characters of CSS still fits once JSON-encoded. The agent styling guide states the new limit. The CSS guard is unchanged. Closes #36650 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/environmentTheme.ts | 4 ++-- packages/contracts/src/server.ts | 2 +- packages/shared/src/themeCss.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index b658870f2e960..97f74f47cc198 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -45,8 +45,8 @@ const THEME_FILE_SUFFIX = ".json"; const MAX_THEME_FILES = 32; /** Shared with publishing so it cannot accept files the watcher skips. */ -export const MAX_THEME_FILE_BYTES = 32 * 1024; -const MAX_THEME_TOTAL_BYTES = 192 * 1024; +export const MAX_THEME_FILE_BYTES = 80 * 1024; +const MAX_THEME_TOTAL_BYTES = 480 * 1024; interface PublishedThemes { readonly seq: number; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 88fa0220fe920..e224471c19607 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -622,7 +622,7 @@ const EnvironmentThemeColors = Schema.Record( TrimmedNonEmptyString.check(Schema.isMaxLength(64)), ); -export const THEME_CSS_MAX_LENGTH = 20_000; +export const THEME_CSS_MAX_LENGTH = 50_000; const environmentThemeFields = { version: Schema.optional(Schema.Literal(1)), diff --git a/packages/shared/src/themeCss.ts b/packages/shared/src/themeCss.ts index 48b1259d0377b..ee933f48a171e 100644 --- a/packages/shared/src/themeCss.ts +++ b/packages/shared/src/themeCss.ts @@ -67,7 +67,7 @@ export const THEME_GUIDE = [ "", "CSS: `css` applies after the app's styles. Use `.dark` or `:root:not(.dark)` for one appearance.", "It cannot load files: no @import, no image-set(), and url() only with data: or #id.", - "A background image must be embedded as a data: address. `css` holds 20,000 characters, so keep the image near 10 KB.", + "A background image must be embedded as a data: address. `css` holds 50,000 characters, so keep the image near 30 KB.", "The app's own rules can be more specific. If a rule has no effect, add !important.", "It cannot reach the terminal text, the file tree, or diff internals. Use color roles for those.", "Native scrollbars take `::-webkit-scrollbar` and `::-webkit-scrollbar-thumb`.", From 821d430dc77bc479c6cf9cb7f96219bb68846ce0 Mon Sep 17 00:00:00 2001 From: pouyarezvani Date: Sat, 14 Nov 2026 10:17:45 -0800 Subject: [PATCH 2/3] fix(themes): size theme files for the longest CSS in any script The per-file cap now fits 50,000 CSS characters at 3 UTF-8 bytes each plus the colors (166,384 bytes), so a valid theme written in CJK is no longer skipped by the watcher or refused on publish. The total cap stays at six of the largest files (998,304 bytes), and both still sit under the web import dialog's 256 KiB limit. The size tests now build their fixtures from the caps: the oversized file pads past MAX_THEME_FILE_BYTES, the total-limit test fills the directory with six max-length CJK themes, and the skipped-files test writes junk that would overflow the total if it were charged. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/environmentTheme.test.ts | 15 +++++++++++---- apps/server/src/environmentTheme.ts | 10 +++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index 1e8b538a665ae..b2dc15e1ded17 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentThemeFile, ServerSettingsError, + THEME_CSS_MAX_LENGTH, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -190,7 +191,10 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ); it.effect("does not replace a theme file it could not put back", () => { - const oversized = JSON.stringify({ ...NIGHTFALL_THEME, padding: "x".repeat(40_000) }); + const oversized = JSON.stringify({ + ...NIGHTFALL_THEME, + padding: "x".repeat(EnvironmentTheme.MAX_THEME_FILE_BYTES), + }); return withEnvironmentThemes( { [`${REFUSED_THEME_ID}.json`]: oversized }, Effect.gen(function* () { @@ -298,12 +302,12 @@ it.layer(NodeServices.layer)("environment theme", (it) => { const large = (name: string): EnvironmentThemeFile => ({ ...NIGHTFALL_THEME, name, - css: `/* ${"x".repeat(19_900)} */`, + css: `/* ${"界".repeat(THEME_CSS_MAX_LENGTH - 6)} */`, }); return withEnvironmentThemes( { ...Object.fromEntries( - Array.from({ length: 9 }, (_, index) => [ + Array.from({ length: 6 }, (_, index) => [ `aa-${index}.json`, encodeThemeFile(large(`Large ${index}`)), ]), @@ -341,7 +345,10 @@ it.layer(NodeServices.layer)("environment theme", (it) => { withEnvironmentThemes( { ...Object.fromEntries( - Array.from({ length: 7 }, (_, index) => [`junk-${index}.json`, "{".repeat(30_000)]), + Array.from({ length: 7 }, (_, index) => [ + `junk-${index}.json`, + "{".repeat(EnvironmentTheme.MAX_THEME_FILE_BYTES), + ]), ), "zz-valid.json": encodeThemeFile(NIGHTFALL_THEME), }, diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index 97f74f47cc198..d5a017ae78552 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -9,6 +9,7 @@ import { EnvironmentThemeFile, EnvironmentThemeId, environmentThemeFileHasColors, + THEME_CSS_MAX_LENGTH, } from "@t3tools/contracts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { themeCssLoadsRemoteFiles } from "@t3tools/shared/themeCss"; @@ -44,9 +45,12 @@ const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); const THEME_FILE_SUFFIX = ".json"; const MAX_THEME_FILES = 32; -/** Shared with publishing so it cannot accept files the watcher skips. */ -export const MAX_THEME_FILE_BYTES = 80 * 1024; -const MAX_THEME_TOTAL_BYTES = 480 * 1024; +/** + * Shared with publishing so it cannot accept files the watcher skips. Fits the longest CSS at + * 3 UTF-8 bytes per character, plus the colors. + */ +export const MAX_THEME_FILE_BYTES = 3 * THEME_CSS_MAX_LENGTH + 16 * 1024; +const MAX_THEME_TOTAL_BYTES = 6 * MAX_THEME_FILE_BYTES; interface PublishedThemes { readonly seq: number; From 4b77dac4b054dc94676949005bf1a9049e7437b9 Mon Sep 17 00:00:00 2001 From: pouyarezvani Date: Sat, 14 Nov 2026 10:25:43 -0800 Subject: [PATCH 3/3] fix(themes): fit theme files whose CSS is written with \u escapes Encoders such as Python's json.dumps write every non-ASCII character as a 6-byte \uXXXX escape, so a valid 50,000-character CJK theme from one of them outgrew the 3-bytes-per-character cap and the watcher and CLI skipped it. The per-file cap now allows 6 bytes per character plus the colors (316,384 bytes), which fits a valid theme in any JSON encoding. The total stays at six of the largest files (1,898,304 bytes). The total-limit test now writes its large themes with \u escapes. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/environmentTheme.test.ts | 8 +++++++- apps/server/src/environmentTheme.ts | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index b2dc15e1ded17..241c89546c228 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -21,6 +21,12 @@ import { ServerSettingsService } from "./serverSettings.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); +/** Writes non-ASCII characters as `\uXXXX` escapes, as encoders such as Python's json.dumps do. */ +const encodeThemeFileEscaped = (theme: EnvironmentThemeFile) => + encodeThemeFile(theme).replace( + /[\u0080-\uffff]/g, + (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); const NIGHTFALL_THEME: EnvironmentThemeFile = { name: "Nightfall", @@ -309,7 +315,7 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ...Object.fromEntries( Array.from({ length: 6 }, (_, index) => [ `aa-${index}.json`, - encodeThemeFile(large(`Large ${index}`)), + encodeThemeFileEscaped(large(`Large ${index}`)), ]), ), "zz-look.json": encodeThemeFile(NIGHTFALL_THEME), diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index d5a017ae78552..1e7f4d3052301 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -46,10 +46,10 @@ const THEME_FILE_SUFFIX = ".json"; const MAX_THEME_FILES = 32; /** - * Shared with publishing so it cannot accept files the watcher skips. Fits the longest CSS at - * 3 UTF-8 bytes per character, plus the colors. + * Shared with publishing so it cannot accept files the watcher skips. Fits the longest CSS in any + * JSON encoding, at up to 6 bytes per character for a `\uXXXX` escape, plus the colors. */ -export const MAX_THEME_FILE_BYTES = 3 * THEME_CSS_MAX_LENGTH + 16 * 1024; +export const MAX_THEME_FILE_BYTES = 6 * THEME_CSS_MAX_LENGTH + 16 * 1024; const MAX_THEME_TOTAL_BYTES = 6 * MAX_THEME_FILE_BYTES; interface PublishedThemes {