import * as Equal from "effect/Equal";import * as Schema from "effect/Schema";import "culori/css";import { converter, parse } from "culori/fn";import { BUILT_IN_THEMES, EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, RESERVED_THEME_IDS, THEME_COLOR_ROLES, type ThemeAppearance, type ThemeColorRole, type ThemeColors, type ThemeDefinition, type ThemeVariants,} from "@t3tools/shared/themePalettes"; export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES };export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; export const T3_CHAT_THEME_ID = "t3-chat" as const;const GROVE_THEME_ID = "grove" as const;export const OCEAN_THEME_ID = "ocean" as const;const EMBER_THEME_ID = "ember" as const;const IRIS_THEME_ID = "iris" as const;export const THEME_FILE_VERSION = 1 as const;export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1";export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system";export const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode";export const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark"; export const ThemePreference = Schema.String;export type ThemePreference = typeof ThemePreference.Type; const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES);export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>;export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;export type ThemePreferenceMode = ThemeAppearance | "system";export type ThemeCollection = Readonly<{ id: string; label: string }>;export type ThemeFile = Readonly<{ version: typeof THEME_FILE_VERSION; id: string; name: string; appearance: ThemeAppearance; colors: ThemeColorOverrides; variants?: ThemeVariantOverrides; collection?: ThemeCollection; managed?: boolean;}>; // Reserved ids come from shared so the CLI, the server watcher, and this// library cannot drift on what a published theme may be called. /** * The environment's palettes are not saved: they are republished by the * server on every change and would go stale the moment the machine's theme * moved on. They ride the custom-theme listeners so every theme consumer * already re-reads when they change. */let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> = []; const customThemeListeners = new Set<() => void>();type CustomThemeLibrarySnapshot = | Readonly<{ status: "ready"; storedThemes: ReadonlyArray<unknown>; themes: ReadonlyArray<ThemeDefinition>; }> | Readonly<{ status: "unavailable"; reason: "malformed" }> | Readonly<{ status: "unavailable"; reason: "storage-unavailable"; cause: unknown }>; let customThemeLibrarySnapshot: CustomThemeLibrarySnapshot | null = null;const themePreviewListeners = new Set<() => void>();let themePreviewSidebarArtwork: boolean | null = null; export function getThemePreviewSidebarArtwork(): boolean | null { return themePreviewSidebarArtwork;} export function subscribeToThemePreview(listener: () => void): () => void { themePreviewListeners.add(listener); return () => themePreviewListeners.delete(listener);} function setThemePreviewSidebarArtwork(next: boolean | null): void { if (themePreviewSidebarArtwork === next) return; themePreviewSidebarArtwork = next; for (const listener of themePreviewListeners) listener();} function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value);} function isThemeAppearance(value: unknown): value is ThemeAppearance { return value === "light" || value === "dark";} export function isThemeColor(value: unknown): value is string { return typeof value === "string" && toCanonicalThemeColor(value) !== null;} function isThemeId(value: unknown): value is string { return typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value);} function isThemeLabel(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48;} function parseThemeCollection(value: unknown): ThemeCollection | undefined { return isRecord(value) && typeof value.id === "string" && /^[a-z0-9][a-z0-9.:-]{0,127}$/i.test(value.id) && isThemeLabel(value.label) ? { id: value.id, label: value.label.trim() } : undefined;} /** * Tolerates unknown roles and malformed values so themes written by other * builds (for example one that adds a new role) keep their remaining colors. * The one canonicalization path for every externally supplied color record: * stored themes, imported files, and environment-published themes. */export function lenientThemeColorOverrides( value: Readonly<Record<string, unknown>>,): Partial<Record<ThemeColorRole, string>> { const overrides: Partial<Record<ThemeColorRole, string>> = {}; for (const [role, color] of Object.entries(value)) { const normalized = toCanonicalThemeColor(color); if (THEME_COLOR_ROLE_SET.has(role) && normalized) { overrides[role as ThemeColorRole] = normalized; } } return overrides;} function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { if (!isRecord(value)) return null; return { ...getDefaultThemeColors(appearance), ...lenientThemeColorOverrides(value) };} function parseStoredThemeVariants( value: unknown, baseAppearance: ThemeAppearance,): ThemeVariants | null | undefined { if (value === undefined) return undefined; if (!isRecord(value)) return null; const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; for (const [appearance, colors] of Object.entries(value)) { if (!isThemeAppearance(appearance)) return null; // A variant matching the base appearance would be shadowed by the base // colors; drop it so the theme round-trips through parseThemeFile. if (appearance === baseAppearance) continue; const parsedColors = parseStoredThemeColors(colors, appearance); if (!parsedColors) return null; variants[appearance] = parsedColors; } return Object.keys(variants).length > 0 ? variants : undefined;} function parseStoredTheme(value: unknown): ThemeDefinition | null { if (!isRecord(value)) return null; if (!isThemeId(value.id) || RESERVED_THEME_IDS.has(value.id)) return null; if (!isThemeLabel(value.label) || !isThemeAppearance(value.appearance)) return null; const colors = parseStoredThemeColors(value.colors, value.appearance); if (!colors) return null; const variants = parseStoredThemeVariants(value.variants, value.appearance); if (value.variants !== undefined && variants === null) return null; const collection = parseThemeCollection(value.collection); return { id: value.id, label: value.label.trim(), appearance: value.appearance, colors, ...(variants ? { variants } : {}), ...(collection ? { collection } : {}), ...(value.managed === true ? { managed: true } : {}), };} function parseStoredThemes(storedThemes: ReadonlyArray<unknown>): ReadonlyArray<ThemeDefinition> { const themes: ThemeDefinition[] = []; for (const value of storedThemes) { const theme = parseStoredTheme(value); if (theme && !themes.some((existing) => existing.id === theme.id)) { themes.push(theme); } } return themes;} function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { if (typeof window === "undefined") { return { status: "ready", storedThemes: [], themes: [] }; } let raw: string | null; try { raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY); } catch (cause) { return { status: "unavailable", reason: "storage-unavailable", cause }; } if (!raw) return { status: "ready", storedThemes: [], themes: [] }; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { status: "unavailable", reason: "malformed" }; } if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" }; return { status: "ready", storedThemes: parsed, themes: parseStoredThemes(parsed) };} function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { if (customThemeLibrarySnapshot === null) { customThemeLibrarySnapshot = readCustomThemeLibrarySnapshot(); } return customThemeLibrarySnapshot;} function notifyCustomThemeListeners() { for (const listener of customThemeListeners) listener();} export function invalidateCustomThemes() { customThemeLibrarySnapshot = null; notifyCustomThemeListeners();} export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { const snapshot = getCustomThemeLibrarySnapshot(); return snapshot.status === "ready" ? snapshot.themes : [];} export function getEnvironmentThemes(): ReadonlyArray<ThemeDefinition> { return environmentThemeDefinitions;} /** * Returns whether anything changed, structurally: config snapshots arrive as * fresh arrays on every reconnect, and a repaint for identical colors is the * kind of wasted work users of this product notice. */export function setEnvironmentThemes(themes: ReadonlyArray<ThemeDefinition>): boolean { if (Equal.equals(environmentThemeDefinitions, themes)) return false; environmentThemeDefinitions = themes; notifyCustomThemeListeners(); return true;} /** Ids no published theme may occupy: appearance keywords and built-in ids. */export function isReservedThemeId(themeId: string): boolean { return RESERVED_THEME_IDS.has(themeId);} export function getStoredCustomThemeCollection( collectionId: string,): ReadonlyArray<ThemeDefinition> { return readWritableCustomThemeLibrary().themes.filter( (theme) => theme.collection?.id === collectionId, );} export function subscribeToCustomThemes(listener: () => void): () => void { customThemeListeners.add(listener); if (typeof window === "undefined") { return () => customThemeListeners.delete(listener); } const handleStorage = (event: StorageEvent) => { if (event.key === CUSTOM_THEMES_STORAGE_KEY || event.key === null) { invalidateCustomThemes(); } }; window.addEventListener("storage", handleStorage); return () => { customThemeListeners.delete(listener); window.removeEventListener("storage", handleStorage); };} // Earlier builds shipped every maintainer theme under a t3- prefix; only the// genuinely T3-branded palette keeps it. Stored preferences and mixes with the// old ids stay readable through this alias table.const LEGACY_THEME_ID_ALIASES: Readonly<Record<string, string>> = { [LEGACY_T3_CHAT_DARK_THEME_ID]: T3_CHAT_THEME_ID, "t3-grove": GROVE_THEME_ID, "t3-ocean": OCEAN_THEME_ID, "t3-ember": EMBER_THEME_ID, "t3-iris": IRIS_THEME_ID,}; function normalizeThemeId(themeId: string): string { return LEGACY_THEME_ID_ALIASES[themeId] ?? themeId;} /** * Map a stored preference onto the id the runtime applies, so selection state * matches the theme cards. The legacy dark-variant id stays as-is because it * still carries the appearance hint getThemePreferenceMode reads. */export function canonicalThemePreference(theme: string): string { return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? theme : normalizeThemeId(theme);} function themeIdFromPreference(theme: ThemePreference): string { return normalizeThemeId(theme);} // Older builds stored the dark T3 Chat palette as a separate theme. Keep// those preferences readable while mapping them to the dark variant.function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null { return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null;} /** * The palette T3 Code wears with no theme installed, captured from the app's * stock tokens (index.css) so a draft seeded from the default look paints the * pixels the user is already seeing. Alpha-bearing tokens are flattened over * their real backdrops (canvas, or the sidebar for its rows) because theme * colors are stored as opaque OKLCH tokens. */const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { canvas: "#fcfcfc", chrome: "#fcfcfc", toolbar: "#fcfcfc", toolbarForeground: "#27272a", toolbarBorder: "#e4e4e7", toolbarControl: "#ffffff", toolbarControlForeground: "#27272a", toolbarControlHover: "#f4f4f5", surface: "#ffffff", surfaceRaised: "#fcfcfc", surfaceOverlay: "#ffffff", text: "#27272a", textMuted: "#71717b", border: "#e4e4e7", input: "#d4d4d8", focus: "#1b4ed8", accent: "#1b4ed8", accentForeground: "#ffffff", secondary: "#fafafa", secondaryForeground: "#27272a", muted: "#fafafa", mutedForeground: "#71717b", placeholder: "#71717b", secondaryLabel: "#71717b", iconMuted: "#71717b", error: "#fb2c36", errorForeground: "#c10007", errorSurface: "#fcebec", warning: "#fe9a00", warningForeground: "#bb4d00", warningSurface: "#fcf4e8", update: "#1b4ed8", updateForeground: "#1b4ed8", updateSurface: "#e0e6f7", accentSurface: "#f4f4f5", accentSurfaceForeground: "#18181b", messageSurface: "#f4f4f5", messageForeground: "#27272a", messageAction: "#1b4ed8", messageActionForeground: "#ffffff", messageActionHover: "#3160db", codeBackground: "#ffffff", codeForeground: "#27272a", sidebar: "#fafafa", sidebarForeground: "#27272a", sidebarMutedForeground: "#71717b", sidebarControlSurface: "#f4f4f5", sidebarRowHover: "#fcfcfc", sidebarRowActive: "#ffffff", sidebarRowSelected: "#ffffff", sidebarBorder: "#e4e4e7", terminalBackground: "#fcfcfc", terminalForeground: "#27272a", terminalCursor: "#26384e", terminalSelection: "#d0d6dd", terminalScrollbar: "#d6d6d6", terminalScrollbarHover: "#bdbdbd",}; const T3_CODE_DARK_THEME_COLORS: ThemeColors = { canvas: "#0a0a0a", chrome: "#0a0a0a", toolbar: "#0a0a0a", toolbarForeground: "#f5f5f5", toolbarBorder: "#191919", toolbarControl: "#111111", toolbarControlForeground: "#f5f5f5", toolbarControlHover: "#141414", surface: "#111111", surfaceRaised: "#111111", surfaceOverlay: "#111111", text: "#f5f5f5", textMuted: "#818181", border: "#191919", input: "#1e1e1e", focus: "#346bf1", accent: "#346bf1", accentForeground: "#ffffff", secondary: "#111111", secondaryForeground: "#f5f5f5", muted: "#111111", mutedForeground: "#818181", placeholder: "#818181", secondaryLabel: "#818181", iconMuted: "#818181", error: "#fb414a", errorForeground: "#ff6467", errorSurface: "#301214", warning: "#fe9a00", warningForeground: "#ffb900", warningSurface: "#312108", update: "#346bf1", updateForeground: "#51a2ff", updateSurface: "#121b34", accentSurface: "#141414", accentSurfaceForeground: "#f5f5f5", messageSurface: "#141414", messageForeground: "#f5f5f5", messageAction: "#346bf1", messageActionForeground: "#ffffff", messageActionHover: "#3061d9", codeBackground: "#111111", codeForeground: "#f5f5f5", sidebar: "#000000", sidebarForeground: "#f1f3f7", sidebarMutedForeground: "#a3a3a3", sidebarControlSurface: "#0a0a0a", sidebarRowHover: "#131313", sidebarRowActive: "#1a1b1b", sidebarRowSelected: "#111111", sidebarBorder: "#141414", terminalBackground: "#0a0a0a", terminalForeground: "#f5f5f5", terminalCursor: "#b4cbff", terminalSelection: "#343a47", terminalScrollbar: "#222222", terminalScrollbarHover: "#363636",}; /** * The standard T3 Code look as a theme palette, for seeding a new theme when * no theme is installed. Distinct from {@link getDefaultThemeColors}, which * carries the flagship T3 Chat palette used to fill roles omitted by theme * files. */export function getStandardThemeColors(appearance: ThemeAppearance): ThemeColors { if (appearance === "dark") { return (standardDarkThemeColors ??= decodeThemeColors(T3_CODE_DARK_THEME_COLORS)); } return (standardLightThemeColors ??= decodeThemeColors(T3_CODE_LIGHT_THEME_COLORS));} type ThemeRgbColor = { r: number; g: number; b: number;}; type ThemeOklch = { L: number; C: number; h: number };type ParsedThemeColor = { color: ThemeOklch; alpha: number }; let standardLightThemeColors: ThemeColors | undefined;let standardDarkThemeColors: ThemeColors | undefined; const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 };const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 };const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 };const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 }; const convertToOklch = converter("oklch"); function parseThemeColor(value: unknown): ParsedThemeColor | null { if (typeof value !== "string") return null; const input = value.trim(); const parsed = parse(input); if (!parsed) return null; const color = convertToOklch(parsed); const lightness = color.l ?? 0; const chroma = color.c ?? 0; const hue = color.h ?? 0; // CSS missing components behave as zero outside interpolation. Culori omits // a `none` alpha from its parsed object, so distinguish it from omitted alpha. const alpha = /\/\s*none\s*\)$/i.test(input) ? 0 : (color.alpha ?? 1); if (![lightness, chroma, hue, alpha].every(Number.isFinite)) return null; return { color: {Lines 1–500 of 1761. Load more
import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import "culori/css"; import { converter, parse } from "culori/fn"; import { BUILT_IN_THEMES, EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, RESERVED_THEME_IDS, THEME_COLOR_ROLES, type ThemeAppearance, type ThemeColorRole, type ThemeColors, type ThemeDefinition, type ThemeVariants, } from "@t3tools/shared/themePalettes"; export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; export const T3_CHAT_THEME_ID = "t3-chat" as const; const GROVE_THEME_ID = "grove" as const; export const OCEAN_THEME_ID = "ocean" as const; const EMBER_THEME_ID = "ember" as const; const IRIS_THEME_ID = "iris" as const; export const THEME_FILE_VERSION = 1 as const; export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; export const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; export const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark"; export const ThemePreference = Schema.String; export type ThemePreference = typeof ThemePreference.Type; const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES); export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>; export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>; export type ThemePreferenceMode = ThemeAppearance | "system"; export type ThemeCollection = Readonly<{ id: string; label: string }>; export type ThemeFile = Readonly<{ version: typeof THEME_FILE_VERSION; id: string; name: string; appearance: ThemeAppearance; colors: ThemeColorOverrides; variants?: ThemeVariantOverrides; collection?: ThemeCollection; managed?: boolean; }>; // Reserved ids come from shared so the CLI, the server watcher, and this // library cannot drift on what a published theme may be called. /** * The environment's palettes are not saved: they are republished by the * server on every change and would go stale the moment the machine's theme * moved on. They ride the custom-theme listeners so every theme consumer * already re-reads when they change. */ let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> = []; const customThemeListeners = new Set<() => void>(); type CustomThemeLibrarySnapshot = | Readonly<{ status: "ready"; storedThemes: ReadonlyArray<unknown>; themes: ReadonlyArray<ThemeDefinition>; }> | Readonly<{ status: "unavailable"; reason: "malformed" }> | Readonly<{ status: "unavailable"; reason: "storage-unavailable"; cause: unknown }>; let customThemeLibrarySnapshot: CustomThemeLibrarySnapshot | null = null; const themePreviewListeners = new Set<() => void>(); let themePreviewSidebarArtwork: boolean | null = null; export function getThemePreviewSidebarArtwork(): boolean | null { return themePreviewSidebarArtwork; } export function subscribeToThemePreview(listener: () => void): () => void { themePreviewListeners.add(listener); return () => themePreviewListeners.delete(listener); } function setThemePreviewSidebarArtwork(next: boolean | null): void { if (themePreviewSidebarArtwork === next) return; themePreviewSidebarArtwork = next; for (const listener of themePreviewListeners) listener(); } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } function isThemeAppearance(value: unknown): value is ThemeAppearance { return value === "light" || value === "dark"; } export function isThemeColor(value: unknown): value is string { return typeof value === "string" && toCanonicalThemeColor(value) !== null; } function isThemeId(value: unknown): value is string { return typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); } function isThemeLabel(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; } function parseThemeCollection(value: unknown): ThemeCollection | undefined { return isRecord(value) && typeof value.id === "string" && /^[a-z0-9][a-z0-9.:-]{0,127}$/i.test(value.id) && isThemeLabel(value.label) ? { id: value.id, label: value.label.trim() } : undefined; } /** * Tolerates unknown roles and malformed values so themes written by other * builds (for example one that adds a new role) keep their remaining colors. * The one canonicalization path for every externally supplied color record: * stored themes, imported files, and environment-published themes. */ export function lenientThemeColorOverrides( value: Readonly<Record<string, unknown>>, ): Partial<Record<ThemeColorRole, string>> { const overrides: Partial<Record<ThemeColorRole, string>> = {}; for (const [role, color] of Object.entries(value)) { const normalized = toCanonicalThemeColor(color); if (THEME_COLOR_ROLE_SET.has(role) && normalized) { overrides[role as ThemeColorRole] = normalized; } } return overrides; } function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { if (!isRecord(value)) return null; return { ...getDefaultThemeColors(appearance), ...lenientThemeColorOverrides(value) }; } function parseStoredThemeVariants( value: unknown, baseAppearance: ThemeAppearance, ): ThemeVariants | null | undefined { if (value === undefined) return undefined; if (!isRecord(value)) return null; const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; for (const [appearance, colors] of Object.entries(value)) { if (!isThemeAppearance(appearance)) return null; // A variant matching the base appearance would be shadowed by the base // colors; drop it so the theme round-trips through parseThemeFile. if (appearance === baseAppearance) continue; const parsedColors = parseStoredThemeColors(colors, appearance); if (!parsedColors) return null; variants[appearance] = parsedColors; } return Object.keys(variants).length > 0 ? variants : undefined; } function parseStoredTheme(value: unknown): ThemeDefinition | null { if (!isRecord(value)) return null; if (!isThemeId(value.id) || RESERVED_THEME_IDS.has(value.id)) return null; if (!isThemeLabel(value.label) || !isThemeAppearance(value.appearance)) return null; const colors = parseStoredThemeColors(value.colors, value.appearance); if (!colors) return null; const variants = parseStoredThemeVariants(value.variants, value.appearance); if (value.variants !== undefined && variants === null) return null; const collection = parseThemeCollection(value.collection); return { id: value.id, label: value.label.trim(), appearance: value.appearance, colors, ...(variants ? { variants } : {}), ...(collection ? { collection } : {}), ...(value.managed === true ? { managed: true } : {}), }; } function parseStoredThemes(storedThemes: ReadonlyArray<unknown>): ReadonlyArray<ThemeDefinition> { const themes: ThemeDefinition[] = []; for (const value of storedThemes) { const theme = parseStoredTheme(value); if (theme && !themes.some((existing) => existing.id === theme.id)) { themes.push(theme); } } return themes; } function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { if (typeof window === "undefined") { return { status: "ready", storedThemes: [], themes: [] }; } let raw: string | null; try { raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY); } catch (cause) { return { status: "unavailable", reason: "storage-unavailable", cause }; } if (!raw) return { status: "ready", storedThemes: [], themes: [] }; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { status: "unavailable", reason: "malformed" }; } if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" }; return { status: "ready", storedThemes: parsed, themes: parseStoredThemes(parsed) }; } function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { if (customThemeLibrarySnapshot === null) { customThemeLibrarySnapshot = readCustomThemeLibrarySnapshot(); } return customThemeLibrarySnapshot; } function notifyCustomThemeListeners() { for (const listener of customThemeListeners) listener(); } export function invalidateCustomThemes() { customThemeLibrarySnapshot = null; notifyCustomThemeListeners(); } export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { const snapshot = getCustomThemeLibrarySnapshot(); return snapshot.status === "ready" ? snapshot.themes : []; } export function getEnvironmentThemes(): ReadonlyArray<ThemeDefinition> { return environmentThemeDefinitions; } /** * Returns whether anything changed, structurally: config snapshots arrive as * fresh arrays on every reconnect, and a repaint for identical colors is the * kind of wasted work users of this product notice. */ export function setEnvironmentThemes(themes: ReadonlyArray<ThemeDefinition>): boolean { if (Equal.equals(environmentThemeDefinitions, themes)) return false; environmentThemeDefinitions = themes; notifyCustomThemeListeners(); return true; } /** Ids no published theme may occupy: appearance keywords and built-in ids. */ export function isReservedThemeId(themeId: string): boolean { return RESERVED_THEME_IDS.has(themeId); } export function getStoredCustomThemeCollection( collectionId: string, ): ReadonlyArray<ThemeDefinition> { return readWritableCustomThemeLibrary().themes.filter( (theme) => theme.collection?.id === collectionId, ); } export function subscribeToCustomThemes(listener: () => void): () => void { customThemeListeners.add(listener); if (typeof window === "undefined") { return () => customThemeListeners.delete(listener); } const handleStorage = (event: StorageEvent) => { if (event.key === CUSTOM_THEMES_STORAGE_KEY || event.key === null) { invalidateCustomThemes(); } }; window.addEventListener("storage", handleStorage); return () => { customThemeListeners.delete(listener); window.removeEventListener("storage", handleStorage); }; } // Earlier builds shipped every maintainer theme under a t3- prefix; only the // genuinely T3-branded palette keeps it. Stored preferences and mixes with the // old ids stay readable through this alias table. const LEGACY_THEME_ID_ALIASES: Readonly<Record<string, string>> = { [LEGACY_T3_CHAT_DARK_THEME_ID]: T3_CHAT_THEME_ID, "t3-grove": GROVE_THEME_ID, "t3-ocean": OCEAN_THEME_ID, "t3-ember": EMBER_THEME_ID, "t3-iris": IRIS_THEME_ID, }; function normalizeThemeId(themeId: string): string { return LEGACY_THEME_ID_ALIASES[themeId] ?? themeId; } /** * Map a stored preference onto the id the runtime applies, so selection state * matches the theme cards. The legacy dark-variant id stays as-is because it * still carries the appearance hint getThemePreferenceMode reads. */ export function canonicalThemePreference(theme: string): string { return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? theme : normalizeThemeId(theme); } function themeIdFromPreference(theme: ThemePreference): string { return normalizeThemeId(theme); } // Older builds stored the dark T3 Chat palette as a separate theme. Keep // those preferences readable while mapping them to the dark variant. function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null { return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null; } /** * The palette T3 Code wears with no theme installed, captured from the app's * stock tokens (index.css) so a draft seeded from the default look paints the * pixels the user is already seeing. Alpha-bearing tokens are flattened over * their real backdrops (canvas, or the sidebar for its rows) because theme * colors are stored as opaque OKLCH tokens. */ const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { canvas: "#fcfcfc", chrome: "#fcfcfc", toolbar: "#fcfcfc", toolbarForeground: "#27272a", toolbarBorder: "#e4e4e7", toolbarControl: "#ffffff", toolbarControlForeground: "#27272a", toolbarControlHover: "#f4f4f5", surface: "#ffffff", surfaceRaised: "#fcfcfc", surfaceOverlay: "#ffffff", text: "#27272a", textMuted: "#71717b", border: "#e4e4e7", input: "#d4d4d8", focus: "#1b4ed8", accent: "#1b4ed8", accentForeground: "#ffffff", secondary: "#fafafa", secondaryForeground: "#27272a", muted: "#fafafa", mutedForeground: "#71717b", placeholder: "#71717b", secondaryLabel: "#71717b", iconMuted: "#71717b", error: "#fb2c36", errorForeground: "#c10007", errorSurface: "#fcebec", warning: "#fe9a00", warningForeground: "#bb4d00", warningSurface: "#fcf4e8", update: "#1b4ed8", updateForeground: "#1b4ed8", updateSurface: "#e0e6f7", accentSurface: "#f4f4f5", accentSurfaceForeground: "#18181b", messageSurface: "#f4f4f5", messageForeground: "#27272a", messageAction: "#1b4ed8", messageActionForeground: "#ffffff", messageActionHover: "#3160db", codeBackground: "#ffffff", codeForeground: "#27272a", sidebar: "#fafafa", sidebarForeground: "#27272a", sidebarMutedForeground: "#71717b", sidebarControlSurface: "#f4f4f5", sidebarRowHover: "#fcfcfc", sidebarRowActive: "#ffffff", sidebarRowSelected: "#ffffff", sidebarBorder: "#e4e4e7", terminalBackground: "#fcfcfc", terminalForeground: "#27272a", terminalCursor: "#26384e", terminalSelection: "#d0d6dd", terminalScrollbar: "#d6d6d6", terminalScrollbarHover: "#bdbdbd", }; const T3_CODE_DARK_THEME_COLORS: ThemeColors = { canvas: "#0a0a0a", chrome: "#0a0a0a", toolbar: "#0a0a0a", toolbarForeground: "#f5f5f5", toolbarBorder: "#191919", toolbarControl: "#111111", toolbarControlForeground: "#f5f5f5", toolbarControlHover: "#141414", surface: "#111111", surfaceRaised: "#111111", surfaceOverlay: "#111111", text: "#f5f5f5", textMuted: "#818181", border: "#191919", input: "#1e1e1e", focus: "#346bf1", accent: "#346bf1", accentForeground: "#ffffff", secondary: "#111111", secondaryForeground: "#f5f5f5", muted: "#111111", mutedForeground: "#818181", placeholder: "#818181", secondaryLabel: "#818181", iconMuted: "#818181", error: "#fb414a", errorForeground: "#ff6467", errorSurface: "#301214", warning: "#fe9a00", warningForeground: "#ffb900", warningSurface: "#312108", update: "#346bf1", updateForeground: "#51a2ff", updateSurface: "#121b34", accentSurface: "#141414", accentSurfaceForeground: "#f5f5f5", messageSurface: "#141414", messageForeground: "#f5f5f5", messageAction: "#346bf1", messageActionForeground: "#ffffff", messageActionHover: "#3061d9", codeBackground: "#111111", codeForeground: "#f5f5f5", sidebar: "#000000", sidebarForeground: "#f1f3f7", sidebarMutedForeground: "#a3a3a3", sidebarControlSurface: "#0a0a0a", sidebarRowHover: "#131313", sidebarRowActive: "#1a1b1b", sidebarRowSelected: "#111111", sidebarBorder: "#141414", terminalBackground: "#0a0a0a", terminalForeground: "#f5f5f5", terminalCursor: "#b4cbff", terminalSelection: "#343a47", terminalScrollbar: "#222222", terminalScrollbarHover: "#363636", }; /** * The standard T3 Code look as a theme palette, for seeding a new theme when * no theme is installed. Distinct from {@link getDefaultThemeColors}, which * carries the flagship T3 Chat palette used to fill roles omitted by theme * files. */ export function getStandardThemeColors(appearance: ThemeAppearance): ThemeColors { if (appearance === "dark") { return (standardDarkThemeColors ??= decodeThemeColors(T3_CODE_DARK_THEME_COLORS)); } return (standardLightThemeColors ??= decodeThemeColors(T3_CODE_LIGHT_THEME_COLORS)); } type ThemeRgbColor = { r: number; g: number; b: number; }; type ThemeOklch = { L: number; C: number; h: number }; type ParsedThemeColor = { color: ThemeOklch; alpha: number }; let standardLightThemeColors: ThemeColors | undefined; let standardDarkThemeColors: ThemeColors | undefined; const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 }; const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 }; const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 }; const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 }; const convertToOklch = converter("oklch"); function parseThemeColor(value: unknown): ParsedThemeColor | null { if (typeof value !== "string") return null; const input = value.trim(); const parsed = parse(input); if (!parsed) return null; const color = convertToOklch(parsed); const lightness = color.l ?? 0; const chroma = color.c ?? 0; const hue = color.h ?? 0; // CSS missing components behave as zero outside interpolation. Culori omits // a `none` alpha from its parsed object, so distinguish it from omitted alpha. const alpha = /\/\s*none\s*\)$/i.test(input) ? 0 : (color.alpha ?? 1); if (![lightness, chroma, hue, alpha].every(Number.isFinite)) return null; return { color: {