diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 55f73552e339e..1364e57409336 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -63,6 +63,17 @@ function contrastRatio(first: string, second: string): number { } describe("theme files", () => { + it("gives non-Latin theme names distinct stable ids", () => { + const file = { + version: THEME_FILE_VERSION, + appearance: "dark", + colors: { canvas: "#101820" }, + }; + const night = parseThemeFile({ ...file, name: "夜" }); + expect(parseThemeFile({ ...file, name: "朝" }).id).not.toBe(night.id); + expect(parseThemeFile({ ...file, name: " 夜 " }).id).toBe(night.id); + }); + it("keeps built-in and standard search pairs readable and distinct from inline code", () => { const toOklab = converter("oklab"); const distance = (first: string, second: string) => { diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 1e9cc1a14d12e..861b415dfa331 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1057,13 +1057,20 @@ export function getThemePreferenceMode(theme: ThemePreference): ThemeAppearance } export function themeIdFromName(name: string): string { - const normalized = name - .trim() - .toLowerCase() + const label = name.trim().toLowerCase(); + const normalized = label .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); - return normalized || "custom-theme"; + if (normalized || !label) return normalized || "custom-theme"; + + // Names without ASCII letters or digits still need independent, stable ids. + let hash = 2_166_136_261; + for (let index = 0; index < label.length; index += 1) { + hash ^= label.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return `theme-${(hash >>> 0).toString(36)}`; } export class ThemeLibraryStorageError extends Schema.TaggedError()(