import { Platform } from "react-native"; import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight"; export const baseColors = { // Base colors white: "#ffffff", black: "#000000", // Zinc scale (primary gray palette) zinc: { 50: "#fafafa", 100: "#f4f4f5", 200: "#e4e4e7", 300: "#d4d4d8", 400: "#a1a1aa", 500: "#71717a", 600: "#52525b", 700: "#3f3f46", 800: "#27272a", 850: "#1a1a1d", 900: "#18181b", 950: "#121214", }, // Gray scale gray: { 50: "#f9fafb", 100: "#f3f4f6", 200: "#e5e7eb", 300: "#d1d5db", 400: "#9ca3af", 500: "#6b7280", 600: "#4b5563", 700: "#374151", 800: "#1f2937", 900: "#111827", }, // Slate scale slate: { 200: "#e2e8f0", }, // Blue scale blue: { 50: "#eff6ff", 100: "#dbeafe", 200: "#bfdbfe", 300: "#93c5fd", 400: "#60a5fa", 500: "#3b82f6", 600: "#2563eb", 700: "#1d4ed8", 800: "#1e40af", 900: "#1e3a8a", 950: "#172554", }, // Green scale green: { 100: "#dcfce7", 200: "#bbf7d0", 400: "#4ade80", 500: "#22c55e", 600: "#16a34a", 800: "#166534", 900: "#14532d", }, // Red scale red: { 100: "#fee2e2", 200: "#fecaca", 300: "#fca5a5", 500: "#ef4444", 600: "#dc2626", 800: "#991b1b", 900: "#7f1d1d", }, // Teal scale teal: { 200: "#99f6e4", }, // Amber scale amber: { 500: "#f59e0b", 700: "#b45309", }, // Yellow scale yellow: { 400: "#fbbf24", }, // Purple scale purple: { 500: "#a855f7", 600: "#9333ea", }, // Orange scale orange: { 500: "#f97316", 600: "#ea580c", }, } as const; // Diff colors — the +/- inside a diff view, where the color *is* the signal and has to // survive being scanned line by line, so it stays saturated. Light uses muted tones, dark // uses the brighter palette values. // // A diff *stat* — the "+12 −3" footnote next to a title — is not this. It is a status // signal, so it uses statusSuccess/statusDanger below rather than a tier of its own. const lightDiffColors = { diffAddition: "#15803d", // green-700 — readable on white without screaming diffDeletion: "#b91c1c", // red-700 }; const darkDiffColors = { diffAddition: "#4ade80", // green-400 diffDeletion: "#ef4444", // red-500 }; // Status colors — semantic signals for success/danger/warning/merged. There is exactly one // token per signal, and every status surface uses it: PR state icons, CI check icons and // pies, diff stats, file-change icons, status badges, usage bars. Status *dots* are the // exception and have their own band below. A surface does not otherwise get a quieter or // louder variant of a status color because of where it sits — if a dense list feels loud, // that is a density or weight problem, not a color problem. // // The level is set by the densest consumer, the sidebar workspace list: quiet enough that a // column of green checks reads as one line of subtitle and the single red row still stands // out, saturated enough to name the state on its own. Every other surface follows it. // // Normalized, not hand-picked. Every color below shares one lightness and one chroma; only // the hue changes, and each hue is the one that family already had. Chroma is a fixed // fraction of what sRGB allows at that lightness and hue, because the gamut is lopsided — // amber runs out of room long before red does, so a literal equal-chroma set leaves amber // flat and red screaming. Equal fractions is what makes four hues read as one family. // Regenerate with the same rule rather than nudging one value. // // Hues are fixed per family across both themes: success 150, danger 27, warning 70.5, // merged 300. const lightStatusColors = { // L=0.50, chroma 60% of gamut max statusSuccess: "#3e704a", statusDanger: "#9d433b", statusWarning: "#7b5d39", statusMerged: "#7347af", }; const darkStatusColors = { // L=0.70, chroma 55% of gamut max statusSuccess: "#6cb17b", statusDanger: "#d8847b", statusWarning: "#c09664", statusMerged: "#a890d5", }; // Status *dot* colors — the small filled discs on a sidebar row, and the glyphs that stand in // for them. Same four hues and the same generation rule as the status colors above, but its // own band, because a dot is doing a different job than a check icon or a host badge. // // A dot is 6pt of solid color with no shape to read and no label attached. At the status // band's lightness the dots read dimmer than the static text and icons beside them on the same // row, which is backwards — the dot is the row's state. The loudness comes from chroma: 90% of // gamut max against the status family's 55-60%. // // Lightness is set by hue separation, not by distance from the surface. A dark dot on a light // surface has plenty of contrast but the four hues collapse into each other at 6pt — dark green, // dark red, dark amber and dark blue all read as "dark blob", and the point of the dot is telling // them apart at a glance. So the light band runs as bright as the contrast floor allows: L=0.62 // is the last step where all four clear 3:1 against the sidebar's surface2 (success is the // binding one at 3.10, and drops under 3 by L=0.64), which is WCAG's non-text minimum for a // control that carries state. // // All four move together. A dot matching its siblings in lightness and chroma says only which // state the row is in; one that does not says "this row matters more", which is a claim the // color has no business making. Regenerate the set, never one hue. // // 90% and not 100%: at the gamut edge the lopsidedness is worst — green reaches C=0.215 while // blue manages 0.116 — so the set stops reading as one family and green wins. Red running out // of chroma as lightness climbs is what caps the dark band at L=0.72; higher turns the failed // dot pink, and the light band pastels out the same way just above its own cap. Running is blue // at hue 250, clear of // identity-colors' blue at 256.6 so a blue host badge and a working dot on the same row do not // read as related. const lightStatusDotColors = { // L=0.62, chroma 90% of gamut max statusDotSuccess: "#299f51", statusDotDanger: "#f12e2f", statusDotWarning: "#b37824", statusDotRunning: "#268ae0", }; const darkStatusDotColors = { // L=0.72, chroma 90% of gamut max statusDotSuccess: "#35c264", statusDotDanger: "#f7796d", statusDotWarning: "#db932e", statusDotRunning: "#5caaf6", }; export interface LightThemeConfig { surface0: string; surface1: string; surface2: string; surface3: string; surface4: string; surfaceDiffEmpty: string; surfaceSidebar: string; foreground: string; foregroundMuted: string; foregroundExtraMuted: string; border: string; borderAccent: string; accent: string; accentBright: string; accentForeground?: string; primary: string; primaryForeground: string; destructive: string; terminalBlack: string; terminalBrightBlack: string; ring: string; } const lightTerminalAnsi = { red: "#dc2626", green: "#16a34a", yellow: "#ca8a04", blue: "#2563eb", magenta: "#9333ea", cyan: "#0891b2", white: "#ffffff", brightRed: "#ef4444", brightGreen: "#22c55e", brightYellow: "#f59e0b", brightBlue: "#3b82f6", brightMagenta: "#a855f7", brightCyan: "#06b6d4", brightWhite: "#fafafa", } as const; export function buildLightSemanticColors(tint: LightThemeConfig) { return { surface0: tint.surface0, surface1: tint.surface1, surface2: tint.surface2, surface3: tint.surface3, surface4: tint.surface4, surfaceDiffEmpty: tint.surfaceDiffEmpty, surfaceSidebar: tint.surfaceSidebar, surfaceSidebarHover: tint.surface1, surfaceSidebarSelected: tint.surface2, surfaceWorkspace: tint.surface0, foreground: tint.foreground, foregroundMuted: tint.foregroundMuted, foregroundExtraMuted: tint.foregroundExtraMuted, border: tint.border, borderAccent: tint.borderAccent, accent: tint.accent, accentBright: tint.accentBright, accentForeground: tint.accentForeground ?? tint.surface0, destructive: tint.destructive, destructiveForeground: tint.surface0, success: tint.accent, successForeground: tint.surface0, background: tint.surface0, popover: tint.surface0, popoverForeground: tint.foreground, primary: tint.primary, primaryForeground: tint.primaryForeground, secondary: tint.surface2, secondaryForeground: tint.foreground, muted: tint.surface2, mutedForeground: tint.foregroundMuted, accentBorder: tint.borderAccent, input: tint.surface2, ring: tint.ring, ...lightDiffColors, ...lightStatusColors, ...lightStatusDotColors, terminal: { background: tint.surface0, foreground: tint.foreground, cursor: tint.foreground, cursorAccent: tint.surface0, selectionBackground: "rgba(0, 0, 0, 0.15)", selectionForeground: tint.foreground, black: tint.terminalBlack, ...lightTerminalAnsi, brightBlack: tint.terminalBrightBlack, }, }; } const lightSemanticColors = buildLightSemanticColors({ surface0: "#ffffff", surface1: "#fafafa", surface2: "#f4f4f5", surface3: "#e4e4e7", surface4: "#d4d4d8", surfaceDiffEmpty: "#f6f6f6", surfaceSidebar: "#f4f4f5", foreground: "#1a1a1e", foregroundMuted: "#71717a", foregroundExtraMuted: "#a1a1aa", border: "#e4e4e7", borderAccent: "#ececf1", accent: "#20744A", accentBright: "#239956", accentForeground: "#ffffff", primary: "#18181b", primaryForeground: "#fafafa", destructive: "#b04138", terminalBlack: "#1a1a1e", terminalBrightBlack: "#3f3f46", ring: "#18181b", }); // --------------------------------------------------------------------------- // Dark theme variant builder // --------------------------------------------------------------------------- export interface DarkThemeConfig { surface0: string; surface1: string; surface2: string; surface3: string; surface4: string; surfaceDiffEmpty: string; surfaceSidebar: string; foregroundMuted: string; foregroundExtraMuted: string; border: string; borderAccent: string; accent: string; accentBright: string; accentForeground?: string; destructive: string; terminalBlack: string; terminalBrightBlack: string; foreground?: string; ring?: string; } const darkTerminalAnsi = { red: "#e07070", green: "#5dba80", yellow: "#d4a44a", blue: "#6a9de0", magenta: "#b07ad0", cyan: "#4aabb8", white: "#d4d4d8", brightRed: "#e89090", brightGreen: "#7ecf9a", brightYellow: "#e0be6e", brightBlue: "#8ab4e8", brightMagenta: "#c49ae0", brightCyan: "#6ec2cc", brightWhite: "#f0f0f2", } as const; export function buildDarkSemanticColors(tint: DarkThemeConfig) { const foreground = tint.foreground ?? "#fafafa"; const ring = tint.ring ?? "#d4d4d8"; return { surface0: tint.surface0, surface1: tint.surface1, surface2: tint.surface2, surface3: tint.surface3, surface4: tint.surface4, surfaceDiffEmpty: tint.surfaceDiffEmpty, surfaceSidebar: tint.surfaceSidebar, surfaceSidebarHover: tint.surface1, surfaceSidebarSelected: tint.surface2, surfaceWorkspace: tint.surface1, foreground, foregroundMuted: tint.foregroundMuted, foregroundExtraMuted: tint.foregroundExtraMuted, border: tint.border, borderAccent: tint.borderAccent, accent: tint.accent, accentBright: tint.accentBright, accentForeground: tint.accentForeground ?? "#ffffff", destructive: tint.destructive, destructiveForeground: "#ffffff", success: tint.accent, successForeground: "#ffffff", // Legacy aliases (for gradual migration) background: tint.surface0, popover: tint.surface2, popoverForeground: foreground, primary: foreground, primaryForeground: tint.surface0, secondary: tint.surface2, secondaryForeground: foreground, muted: tint.surface2, mutedForeground: tint.foregroundMuted, accentBorder: tint.borderAccent, input: tint.surface2, ring, ...darkDiffColors, ...darkStatusColors, ...darkStatusDotColors, terminal: { background: tint.surface0, foreground, cursor: foreground, cursorAccent: tint.surface0, selectionBackground: "rgba(255, 255, 255, 0.2)", selectionForeground: foreground, black: tint.terminalBlack, ...darkTerminalAnsi, brightBlack: tint.terminalBrightBlack, }, }; } // --------------------------------------------------------------------------- // Dark tint definitions // --------------------------------------------------------------------------- // Paseo — subtle teal-green tint (default) const paseoDarkColors = buildDarkSemanticColors({ surface0: "#181B1A", surface1: "#1E2120", surface2: "#272A29", surface3: "#434645", surface4: "#595B5B", surfaceDiffEmpty: "#252827", surfaceSidebar: "#141716", foregroundMuted: "#A1A5A4", foregroundExtraMuted: "#717574", border: "#252B2A", borderAccent: "#2F3534", accent: "#20744A", accentBright: "#7ccba0", destructive: "#c64f43", // warm red, hue ~7 — reads as red (not pink) against the green tint terminalBlack: "#141716", terminalBrightBlack: "#434645", }); // Zinc — neutral gray, no tint const zincDarkColors = buildDarkSemanticColors({ surface0: "#18181b", surface1: "#1f1f22", surface2: "#27272a", surface3: "#3f3f46", surface4: "#52525b", surfaceDiffEmpty: "#242427", surfaceSidebar: "#131316", foregroundMuted: "#a1a1aa", foregroundExtraMuted: "#71717a", border: "#27272a", borderAccent: "#303036", accent: "#e4e4e7", accentBright: "#fafafa", accentForeground: "#18181b", // monochrome zinc accent is near-white — needs dark text destructive: "#c44a4a", // neutral red, hue 0 — clearly red without screaming terminalBlack: "#131316", terminalBrightBlack: "#3f3f46", }); // Midnight — subtle blue tint const midnightDarkColors = buildDarkSemanticColors({ surface0: "#161820", surface1: "#1c1e27", surface2: "#252731", surface3: "#3c3e4c", surface4: "#535564", surfaceDiffEmpty: "#222430", surfaceSidebar: "#121420", foregroundMuted: "#9a9db0", foregroundExtraMuted: "#6b6e82", border: "#242636", borderAccent: "#2e3040", accent: "#3b6fcf", accentBright: "#7eaaeb", destructive: "#c44a52", // red with a hint of cool lean against the blue tint terminalBlack: "#121420", terminalBrightBlack: "#3c3e4c", }); // Claude — warm neutral with subtle orange undertone const claudeDarkColors = buildDarkSemanticColors({ surface0: "#1f1f1e", surface1: "#262523", surface2: "#2f2d2b", surface3: "#4a4745", surface4: "#605d5b", surfaceDiffEmpty: "#2a2826", surfaceSidebar: "#1a1918", foregroundMuted: "#ada9a5", foregroundExtraMuted: "#78746f", border: "#2c2a27", borderAccent: "#36332f", accent: "#d97757", accentBright: "#e89a7f", destructive: "#cf513e", // warm orange-red, hue ~10 — sits with the Claude orange accent terminalBlack: "#1a1918", terminalBrightBlack: "#4a4745", }); // Ghostty — blue-tinted dark based on Ghostty default background const ghosttyDarkColors = buildDarkSemanticColors({ surface0: "#282c34", surface1: "#2f333d", surface2: "#383c48", surface3: "#4a4f5e", surface4: "#5b6175", surfaceDiffEmpty: "#323643", surfaceSidebar: "#21252d", foregroundMuted: "#c8ccd8", foregroundExtraMuted: "#a0a4b2", border: "#353a47", borderAccent: "#3f4454", accent: "#89b4fa", accentBright: "#b4d0fc", destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces terminalBlack: "#21252d", terminalBrightBlack: "#4a4f5e", }); export const SPACING = { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32, 12: 48, 16: 64, 20: 80, 24: 96, 32: 128, } as const; export const FONT_SIZE = { code: 12, sm: 12, base: 14, lg: 16, xl: 18, "2xl": 20, "3xl": 22, "4xl": 26, } as const; export const LINE_HEIGHT = { diff: 22, } as const; export const ICON_SIZE = { xs: 12, sm: 14, md: 16, lg: 20, } as const; export const FONT_WEIGHT = { normal: "normal" as const, medium: "500" as const, semibold: "600" as const, bold: "bold" as const, } as const; export const BORDER_RADIUS = { none: 0, sm: 2, base: 4, md: 6, lg: 8, xl: 12, "2xl": 16, full: 9999, } as const; export const BORDER_WIDTH = { 0: 0, 1: 1, 2: 2, } as const; export const OPACITY = { 0: 0, 50: 0.5, 100: 1, } as const; // Platform default font stacks — copied verbatim from constants/theme.ts `Fonts` // (sans -> ui, mono -> mono). These seed the dynamic `fontFamily` theme token and // are the fallback an empty user-supplied family resolves to at apply time. export const DEFAULT_UI_FONT_STACK: string = Platform.select({ ios: "system-ui", default: "normal", web: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", }); export const DEFAULT_MONO_FONT_STACK: string = Platform.select({ ios: "ui-monospace", default: "monospace", web: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", }); // `fontSize`, `fontFamily`, and `lineHeight` are deliberately widened to plain // `number`/`string` (not narrowed by `as const`) so the appearance updater can patch // them at runtime via `UnistylesRuntime.updateTheme`. The remaining tokens keep their // literal types. interface CommonTheme { spacing: typeof SPACING; fontSize: Record; fontFamily: { ui: string; mono: string }; lineHeight: Record; iconSize: typeof ICON_SIZE; fontWeight: typeof FONT_WEIGHT; borderRadius: typeof BORDER_RADIUS; borderWidth: typeof BORDER_WIDTH; opacity: typeof OPACITY; } const commonTheme: CommonTheme = { spacing: SPACING, fontSize: FONT_SIZE, fontFamily: { ui: DEFAULT_UI_FONT_STACK, mono: DEFAULT_MONO_FONT_STACK }, lineHeight: LINE_HEIGHT, iconSize: ICON_SIZE, fontWeight: FONT_WEIGHT, borderRadius: BORDER_RADIUS, borderWidth: BORDER_WIDTH, opacity: OPACITY, }; const darkShadow = { sm: { shadowColor: "rgba(0, 0, 0, 0.25)", shadowOffset: { width: 0, height: 2 }, shadowRadius: 4, elevation: 2, }, md: { shadowColor: "rgba(0, 0, 0, 0.20)", shadowOffset: { width: 0, height: 4 }, shadowRadius: 8, elevation: 8, }, lg: { shadowColor: "rgba(0, 0, 0, 0.40)", shadowOffset: { width: 0, height: 12 }, shadowRadius: 24, elevation: 8, }, } as const; export function buildDarkTheme(semanticColors: ReturnType) { return { colorScheme: "dark" as const, colors: { ...semanticColors, palette: baseColors, syntax: darkHighlightColors, }, shadow: darkShadow, ...commonTheme, } as const; } export const darkTheme = buildDarkTheme(paseoDarkColors); export const darkZincTheme = buildDarkTheme(zincDarkColors); export const darkMidnightTheme = buildDarkTheme(midnightDarkColors); export const darkClaudeTheme = buildDarkTheme(claudeDarkColors); export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors); // Pure black — zero-luminance background with high-contrast surfaces. const pureBlackDarkColors = buildDarkSemanticColors({ surface0: "#000000", surface1: "#0a0a0a", surface2: "#111111", surface3: "#202020", surface4: "#2d2d2d", surfaceDiffEmpty: "#0c0c0c", surfaceSidebar: "#000000", foregroundMuted: "#a1a1aa", foregroundExtraMuted: "#71717a", border: "#1c1c1c", borderAccent: "#242424", accent: "#20744A", accentBright: "#7ccba0", destructive: "#c44a4a", terminalBlack: "#595959", terminalBrightBlack: "#8a8a8a", }); export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors); const lightShadow = { sm: { shadowColor: "rgba(0, 0, 0, 0.02)", shadowOffset: { width: 0, height: 2 }, shadowRadius: 8, elevation: 2, }, md: { shadowColor: "rgba(0, 0, 0, 0.04)", shadowOffset: { width: 0, height: 4 }, shadowRadius: 16, elevation: 4, }, lg: { shadowColor: "rgba(0, 0, 0, 0.08)", shadowOffset: { width: 0, height: 8 }, shadowRadius: 24, elevation: 8, }, } as const; export function buildLightTheme(semanticColors: ReturnType) { return { colorScheme: "light" as const, colors: { ...semanticColors, palette: baseColors, syntax: lightHighlightColors, }, shadow: lightShadow, ...commonTheme, } as const; } export const lightTheme = buildLightTheme(lightSemanticColors); // Keep compatibility with existing code export const theme = darkTheme; export const THEME_OPTIONS = [ { name: "light", group: "primary", unistylesName: "light", theme: lightTheme, swatch: "#ffffff", }, { name: "dark", group: "primary", unistylesName: "dark", theme: darkTheme, swatch: "#2D8B62", }, { name: "auto", group: "primary" }, { name: "zinc", group: "variant", unistylesName: "darkZinc", theme: darkZincTheme, swatch: "#808080", }, { name: "midnight", group: "variant", unistylesName: "darkMidnight", theme: darkMidnightTheme, swatch: "#4A6BA8", }, { name: "claude", group: "variant", unistylesName: "darkClaude", theme: darkClaudeTheme, swatch: "#D97757", }, { name: "ghostty", group: "variant", unistylesName: "darkGhostty", theme: darkGhosttyTheme, swatch: "#8caaee", }, { name: "pureBlack", group: "variant", unistylesName: "darkPureBlack", theme: darkPureBlackTheme, swatch: "#000000", }, ] as const; export const PLUGIN_THEME_PREFERENCE = "plugin"; export const PLUGIN_THEME_NAMES = { light: "pluginLight", dark: "pluginDark", } as const; export type ThemePreference = | (typeof THEME_OPTIONS)[number]["name"] | typeof PLUGIN_THEME_PREFERENCE; export type ThemeName = Exclude; type ConcreteThemeOption = Exclude<(typeof THEME_OPTIONS)[number], { name: "auto" }>; export type Theme = ConcreteThemeOption["theme"]; const CONCRETE_THEME_OPTIONS = THEME_OPTIONS.filter( (option): option is ConcreteThemeOption => option.name !== "auto", ); type ThemeToUnistyles = { [Name in ThemeName]: Extract["unistylesName"]; }; type ThemeSwatches = { [Name in ThemeName]: Extract["swatch"]; }; type RegisteredThemes = { [Option in ConcreteThemeOption as Option["unistylesName"]]: Option["theme"]; } & { pluginLight: typeof lightTheme; pluginDark: typeof darkTheme; }; export const THEME_TO_UNISTYLES = Object.fromEntries( CONCRETE_THEME_OPTIONS.map((option) => [option.name, option.unistylesName]), ) as ThemeToUnistyles; export const THEME_SWATCHES = Object.fromEntries( CONCRETE_THEME_OPTIONS.map((option) => [option.name, option.swatch]), ) as ThemeSwatches; export const REGISTERED_THEMES = { ...Object.fromEntries( CONCRETE_THEME_OPTIONS.map((option) => [option.unistylesName, option.theme]), ), [PLUGIN_THEME_NAMES.light]: lightTheme, [PLUGIN_THEME_NAMES.dark]: darkTheme, } as RegisteredThemes; export function getNextThemePreference(current: ThemePreference): ThemePreference { const currentIndex = THEME_OPTIONS.findIndex((option) => option.name === current); const nextIndex = (currentIndex + 1) % THEME_OPTIONS.length; return THEME_OPTIONS[nextIndex]?.name ?? THEME_OPTIONS[0].name; }