1761 lines · 64.2 KB · TypeScript
RawDownload
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.