feat(plugins): add theme contributions

Adds plugin-contributed light and dark app themes, including persistence, host-aware coalescing, fallback behavior, documentation, examples, and browser/iOS QA.
This commit is contained in:
Okan YILDIRIM
2026-08-21 11:48:12 +02:00
committed by GitHub
parent a7f3bf7abb
commit e1805ef542
41 changed files with 1238 additions and 203 deletions
+179 -113
View File
@@ -203,95 +203,136 @@ const darkStatusDotColors = {
statusDotRunning: "#5caaf6",
};
// Semantic color tokens - Layer-based system
const lightSurfaces = {
// Surfaces (layers) - shifted one step lighter
surface0: "#ffffff", // App background
surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50)
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
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;
const lightSemanticColors = {
...lightSurfaces,
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: lightSurfaces.surface2, // Sidebar background (darker than main)
surfaceSidebarHover: lightSurfaces.surface1,
surfaceSidebarSelected: lightSurfaces.surface2,
surfaceWorkspace: lightSurfaces.surface0, // Workspace main background
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,
// Text
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",
// Borders - shifted one step lighter
border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines
// Brand
border: "#e4e4e7",
borderAccent: "#ececf1",
accent: "#20744A",
accentBright: "#239956",
accentForeground: "#ffffff",
// Semantic
destructive: "#b04138", // dark warm red on white — calm but unambiguously red
destructiveForeground: "#ffffff",
success: "#20744A",
successForeground: "#ffffff",
// Legacy aliases (for gradual migration)
background: "#ffffff",
popover: "#ffffff",
popoverForeground: "#1a1a1e",
primary: "#18181b",
primaryForeground: "#fafafa",
secondary: "#f4f4f5",
secondaryForeground: "#1a1a1e",
muted: "#f4f4f5",
mutedForeground: "#71717a",
accentBorder: "#ececf1",
input: "#f4f4f5",
destructive: "#b04138",
terminalBlack: "#1a1a1e",
terminalBrightBlack: "#3f3f46",
ring: "#18181b",
...lightDiffColors,
...lightStatusColors,
...lightStatusDotColors,
terminal: {
background: "#ffffff",
foreground: "#1a1a1e",
cursor: "#1a1a1e",
cursorAccent: "#ffffff",
selectionBackground: "rgba(0, 0, 0, 0.15)",
selectionForeground: "#1a1a1e",
black: "#1a1a1e",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#ffffff",
brightBlack: "#3f3f46",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#f59e0b",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#fafafa",
},
} as const;
});
// ---------------------------------------------------------------------------
// Dark theme variant builder
// ---------------------------------------------------------------------------
interface DarkThemeConfig {
export interface DarkThemeConfig {
surface0: string;
surface1: string;
surface2: string;
@@ -309,6 +350,8 @@ interface DarkThemeConfig {
destructive: string;
terminalBlack: string;
terminalBrightBlack: string;
foreground?: string;
ring?: string;
}
const darkTerminalAnsi = {
@@ -328,7 +371,9 @@ const darkTerminalAnsi = {
brightWhite: "#f0f0f2",
} as const;
function buildDarkSemanticColors(tint: DarkThemeConfig) {
export function buildDarkSemanticColors(tint: DarkThemeConfig) {
const foreground = tint.foreground ?? "#fafafa";
const ring = tint.ring ?? "#d4d4d8";
return {
surface0: tint.surface0,
surface1: tint.surface1,
@@ -341,7 +386,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
surfaceSidebarSelected: tint.surface2,
surfaceWorkspace: tint.surface1,
foreground: "#fafafa",
foreground,
foregroundMuted: tint.foregroundMuted,
foregroundExtraMuted: tint.foregroundExtraMuted,
@@ -360,16 +405,16 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
// Legacy aliases (for gradual migration)
background: tint.surface0,
popover: tint.surface2,
popoverForeground: "#fafafa",
primary: "#fafafa",
popoverForeground: foreground,
primary: foreground,
primaryForeground: tint.surface0,
secondary: tint.surface2,
secondaryForeground: "#fafafa",
secondaryForeground: foreground,
muted: tint.surface2,
mutedForeground: tint.foregroundMuted,
accentBorder: tint.borderAccent,
input: tint.surface2,
ring: "#d4d4d8",
ring,
...darkDiffColors,
...darkStatusColors,
@@ -377,11 +422,11 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
terminal: {
background: tint.surface0,
foreground: "#fafafa",
cursor: "#fafafa",
foreground,
cursor: foreground,
cursorAccent: tint.surface0,
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
selectionForeground: foreground,
black: tint.terminalBlack,
...darkTerminalAnsi,
brightBlack: tint.terminalBrightBlack,
@@ -627,7 +672,7 @@ const darkShadow = {
},
} as const;
function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
export function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
return {
colorScheme: "dark" as const,
colors: {
@@ -668,36 +713,42 @@ const pureBlackDarkColors = buildDarkSemanticColors({
export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors);
export const lightTheme = {
colorScheme: "light" as const,
colors: {
...lightSemanticColors,
palette: baseColors,
syntax: lightHighlightColors,
const lightShadow = {
sm: {
shadowColor: "rgba(0, 0, 0, 0.02)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
},
shadow: {
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,
},
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,
},
...commonTheme,
} as const;
export function buildLightTheme(semanticColors: ReturnType<typeof buildLightSemanticColors>) {
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;
@@ -754,8 +805,16 @@ export const THEME_OPTIONS = [
},
] as const;
export type ThemePreference = (typeof THEME_OPTIONS)[number]["name"];
export type ThemeName = Exclude<ThemePreference, "auto">;
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<ThemePreference, "auto" | typeof PLUGIN_THEME_PREFERENCE>;
type ConcreteThemeOption = Exclude<(typeof THEME_OPTIONS)[number], { name: "auto" }>;
export type Theme = ConcreteThemeOption["theme"];
@@ -773,6 +832,9 @@ type ThemeSwatches = {
type RegisteredThemes = {
[Option in ConcreteThemeOption as Option["unistylesName"]]: Option["theme"];
} & {
pluginLight: typeof lightTheme;
pluginDark: typeof darkTheme;
};
export const THEME_TO_UNISTYLES = Object.fromEntries(
@@ -783,9 +845,13 @@ 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]),
) as RegisteredThemes;
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);