diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index f18d13a8a..e5d140ecc 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -934,7 +934,7 @@ export default function PromptInput(props: PromptInputProps) { data-compact-auto={compactLayoutEnabled() ? "true" : undefined} style={ isDragging() - ? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);" + ? "border-color: var(--accent-primary); background-color: color-mix(in srgb, var(--accent-primary) 5%, transparent);" : "" } onDragOver={handleDragOver} diff --git a/packages/ui/src/components/settings/general-settings-section.tsx b/packages/ui/src/components/settings/general-settings-section.tsx index ed7a34187..1dab4d2e1 100644 --- a/packages/ui/src/components/settings/general-settings-section.tsx +++ b/packages/ui/src/components/settings/general-settings-section.tsx @@ -1,22 +1,14 @@ -import { Check, Laptop, Moon, Sun } from "lucide-solid" import { createMemo, type Component } from "solid-js" import { useI18n } from "../../lib/i18n" import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry" -import { useTheme, type ThemeMode } from "../../lib/theme" import { useConfig } from "../../stores/preferences" import { LocaleSelector } from "../locale-selector" import { BehaviorSettingRows } from "./behavior-setting-rows" import { StartupStateSettingsCard } from "./startup-state-settings-card" - -const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [ - { value: "system", icon: Laptop }, - { value: "light", icon: Sun }, - { value: "dark", icon: Moon }, -] +import { ThemeSchemeSettings } from "./theme-scheme-settings" export const GeneralSettingsSection: Component = () => { const { t } = useI18n() - const { themeMode, setThemeMode } = useTheme() const config = useConfig() const { updatePreferences } = config const generalSettings = createMemo(() => [ @@ -39,38 +31,9 @@ export const GeneralSettingsSection: Component = () => { }, ]) - const modeLabel = (mode: ThemeMode) => { - if (mode === "system") return t("theme.mode.system") - if (mode === "light") return t("theme.mode.light") - return t("theme.mode.dark") - } - return (
-
-
-
-

{t("settings.appearance.theme.title")}

-

{t("settings.appearance.theme.subtitle")}

-
- {t("settings.scope.device")} -
-
- {themeModeOptions.map((option) => { - const Icon = option.icon - return ( - - ) - })} -
-
+
diff --git a/packages/ui/src/components/settings/theme-scheme-settings.tsx b/packages/ui/src/components/settings/theme-scheme-settings.tsx new file mode 100644 index 000000000..e0b6d30d8 --- /dev/null +++ b/packages/ui/src/components/settings/theme-scheme-settings.tsx @@ -0,0 +1,218 @@ +import { Check } from "lucide-solid" +import { createEffect, createMemo, createSignal, For, Show, untrack, type Component } from "solid-js" +import { useI18n } from "../../lib/i18n" +import { useTheme } from "../../lib/theme" +import { useConfig } from "../../stores/preferences" +import { + BUILT_IN_COLOR_SCHEMES, + DEFAULT_CUSTOM_COLORS, + isCanonicalHexColor, + normalizeColorScheme, + validateColorSchemeColors, + type ColorSchemeColors, + type NormalizedColorScheme, +} from "../../lib/theme-scheme" + +const COLOR_FIELDS: ReadonlyArray<{ key: keyof ColorSchemeColors; labelKey: string }> = [ + { key: "surfaceBase", labelKey: "settings.appearance.colorScheme.custom.field.surfaceBase" }, + { key: "surfaceSecondary", labelKey: "settings.appearance.colorScheme.custom.field.surfaceSecondary" }, + { key: "surfaceMuted", labelKey: "settings.appearance.colorScheme.custom.field.surfaceMuted" }, + { key: "borderBase", labelKey: "settings.appearance.colorScheme.custom.field.borderBase" }, + { key: "textPrimary", labelKey: "settings.appearance.colorScheme.custom.field.textPrimary" }, + { key: "textMuted", labelKey: "settings.appearance.colorScheme.custom.field.textMuted" }, + { key: "accentPrimary", labelKey: "settings.appearance.colorScheme.custom.field.accentPrimary" }, + { key: "statusSuccess", labelKey: "settings.appearance.colorScheme.custom.field.statusSuccess" }, + { key: "statusWarning", labelKey: "settings.appearance.colorScheme.custom.field.statusWarning" }, + { key: "statusError", labelKey: "settings.appearance.colorScheme.custom.field.statusError" }, +] + +const customSelection = (scheme: NormalizedColorScheme): NormalizedColorScheme => + scheme.id === "custom" + ? normalizeColorScheme(scheme) + : normalizeColorScheme({ id: "custom", appearance: "dark", colors: DEFAULT_CUSTOM_COLORS }) + +const SYSTEM_SWATCHES = ["#FFFFFF", "#1A1A1A", "#0066FF", "#0080FF"] +const LIGHT_SWATCHES = ["#FFFFFF", "#F5F5F5", "#111827", "#0066FF"] + +export const ThemeSchemeSettings: Component = () => { + const { t } = useI18n() + const { colorScheme, setColorScheme } = useTheme() + const { customColorSchemePreference } = useConfig() + const initialCustom = customSelection(customColorSchemePreference()) + const [appearance, setAppearance] = createSignal<"light" | "dark">(initialCustom.appearance === "light" ? "light" : "dark") + const [draftColors, setDraftColors] = createSignal({ ...initialCustom.colors! }) + const [dirty, setDirty] = createSignal(false) + let lastPersistedCustom = JSON.stringify(initialCustom) + const validFormat = createMemo(() => COLOR_FIELDS.every(({ key }) => isCanonicalHexColor(draftColors()[key]))) + const validDraft = createMemo(() => validateColorSchemeColors(draftColors())) + + const updateColor = (key: keyof ColorSchemeColors, value: string) => { + setDirty(true) + setDraftColors((current) => ({ ...current, [key]: value })) + } + + createEffect(() => { + const saved = customSelection(customColorSchemePreference()) + const serialized = JSON.stringify(saved) + if (serialized === lastPersistedCustom) return + lastPersistedCustom = serialized + const local = untrack(() => ({ + active: colorScheme().id === "custom", + appearance: appearance(), + colors: draftColors(), + dirty: dirty(), + })) + const matchesDraft = saved.appearance === local.appearance && JSON.stringify(saved.colors) === JSON.stringify(local.colors) + if (local.active && local.dirty && !matchesDraft) return + setAppearance(saved.appearance === "light" ? "light" : "dark") + setDraftColors({ ...saved.colors! }) + setDirty(false) + }) + + const selectScheme = (id: NormalizedColorScheme["id"]) => { + if (colorScheme().id === id) return + if (id !== "custom") { + const saved = customSelection(customColorSchemePreference()) + setAppearance(saved.appearance === "light" ? "light" : "dark") + setDraftColors({ ...saved.colors! }) + setDirty(false) + setColorScheme(normalizeColorScheme(id)) + return + } + const saved = customSelection(customColorSchemePreference()) + setDirty(false) + setDraftColors({ ...saved.colors! }) + setAppearance(saved.appearance === "light" ? "light" : "dark") + setColorScheme(saved) + } + + const saveCustom = () => { + const colors = draftColors() + if (!validateColorSchemeColors(colors)) return + const next: NormalizedColorScheme = { id: "custom", appearance: appearance(), colors: { ...colors } } + setColorScheme(next) + } + + const resetCustom = () => { + setDirty(true) + setDraftColors({ ...DEFAULT_CUSTOM_COLORS }) + setAppearance("dark") + } + + const swatches = (id: NormalizedColorScheme["id"], colors?: Readonly) => { + if (id === "custom") { + const draft = draftColors() + return (["surfaceBase", "surfaceSecondary", "textPrimary", "accentPrimary"] as const).map((key) => + isCanonicalHexColor(draft[key]) ? draft[key] : DEFAULT_CUSTOM_COLORS[key], + ) + } + if (id === "system") return SYSTEM_SWATCHES + if (id === "light") return LIGHT_SWATCHES + if (colors) return [colors.surfaceBase, colors.surfaceSecondary, colors.textPrimary, colors.accentPrimary] + return LIGHT_SWATCHES + } + + return ( +
+
+
+

{t("settings.appearance.colorScheme.title")}

+

{t("settings.appearance.colorScheme.subtitle")}

+
+ {t("settings.scope.server")} +
+ +
+ {(scheme) => ( + + )} +
+ + +
+
+ {t("settings.appearance.colorScheme.custom.appearance")} +
+ {(option) => ( + + )} +
+
+ +
+ {t("settings.appearance.colorScheme.custom.colors")} +
+ {(field) => { + const label = () => t(field.labelKey) + const pickerValue = () => isCanonicalHexColor(draftColors()[field.key]) ? draftColors()[field.key] : DEFAULT_CUSTOM_COLORS[field.key] + return ( +
+ {label()} + + updateColor(field.key, event.currentTarget.value.toUpperCase())} + /> + updateColor(field.key, event.currentTarget.value.toUpperCase())} + /> + +
+ ) + }}
+
+
+ + + + +
+ + +
+
+
+
+ ) +} diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 55661287f..dd6a85c75 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "An Betriebssystem-Einstellung anpassen", "settings.appearance.theme.option.light": "Helles Design verwenden", "settings.appearance.theme.option.dark": "Dunkles Design verwenden", + "settings.appearance.colorScheme.title": "Farbschema", + "settings.appearance.colorScheme.subtitle": "Wählen Sie die Palette für Clients dieses CodeNomad-Servers.", + "settings.appearance.colorScheme.option.system": "System", + "settings.appearance.colorScheme.option.light": "Hell", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Klassisch", + "settings.appearance.colorScheme.option.basalt": "Basalt", + "settings.appearance.colorScheme.option.fjord": "Fjord", + "settings.appearance.colorScheme.option.lichen": "Flechte", + "settings.appearance.colorScheme.option.velvet": "Samt", + "settings.appearance.colorScheme.option.ember": "Glut", + "settings.appearance.colorScheme.option.custom": "Benutzerdefiniert", + "settings.appearance.colorScheme.description.system": "Folgt der Darstellung des Betriebssystems.", + "settings.appearance.colorScheme.description.light": "Eine helle, neutrale Palette.", + "settings.appearance.colorScheme.description.codeNomadClassic": "Die ursprüngliche dunkle CodeNomad-Palette.", + "settings.appearance.colorScheme.description.basalt": "Neutrales Anthrazit mit kühlem blauem Akzent.", + "settings.appearance.colorScheme.description.fjord": "Tiefes Blaugrau mit meergrünem Akzent.", + "settings.appearance.colorScheme.description.lichen": "Erdiges Grüngrau mit moosigem Akzent.", + "settings.appearance.colorScheme.description.velvet": "Tiefes Pflaume mit warmem Pfirsichakzent.", + "settings.appearance.colorScheme.description.ember": "Warmes Braunschwarz mit Kupferakzent.", + "settings.appearance.colorScheme.description.custom": "Erstellen und speichern Sie Ihre eigene Palette.", + "settings.appearance.colorScheme.custom.appearance": "Benutzerdefinierte Darstellung", + "settings.appearance.colorScheme.custom.appearance.light": "Hell", + "settings.appearance.colorScheme.custom.appearance.dark": "Dunkel", + "settings.appearance.colorScheme.custom.colors": "Farben", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Grundfläche", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Sekundäre Fläche", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Gedämpfte Fläche", + "settings.appearance.colorScheme.custom.field.borderBase": "Rahmen", + "settings.appearance.colorScheme.custom.field.textPrimary": "Primärtext", + "settings.appearance.colorScheme.custom.field.textMuted": "Gedämpfter Text", + "settings.appearance.colorScheme.custom.field.accentPrimary": "Akzent", + "settings.appearance.colorScheme.custom.field.statusSuccess": "Erfolg", + "settings.appearance.colorScheme.custom.field.statusWarning": "Warnung", + "settings.appearance.colorScheme.custom.field.statusError": "Fehler", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "{name} auswählen", + "settings.appearance.colorScheme.custom.valueAriaLabel": "Hexadezimalwert für {name}", + "settings.appearance.colorScheme.custom.warning.format": "Verwenden Sie Großbuchstaben im Format #RRGGBB.", + "settings.appearance.colorScheme.custom.warning.contrast": "Erhöhen Sie vor dem Speichern den Kontrast.", + "settings.appearance.colorScheme.custom.reset": "Zurücksetzen", + "settings.appearance.colorScheme.custom.save": "Speichern", "settings.appearance.startup.title": "Startverhalten", "settings.appearance.startup.subtitle": "Lege fest, was dieses Gerät beim Start von CodeNomad wiederherstellt.", "settings.appearance.startup.restore.title": "Vorherigen Zustand wiederherstellen", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index bb53e5eca..e9d23da91 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "Match your operating system setting", "settings.appearance.theme.option.light": "Use the light appearance", "settings.appearance.theme.option.dark": "Use the dark appearance", + "settings.appearance.colorScheme.title": "Color scheme", + "settings.appearance.colorScheme.subtitle": "Choose the palette used by clients connected to this CodeNomad server.", + "settings.appearance.colorScheme.option.system": "System", + "settings.appearance.colorScheme.option.light": "Light", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Classic", + "settings.appearance.colorScheme.option.basalt": "Basalt", + "settings.appearance.colorScheme.option.fjord": "Fjord", + "settings.appearance.colorScheme.option.lichen": "Lichen", + "settings.appearance.colorScheme.option.velvet": "Velvet", + "settings.appearance.colorScheme.option.ember": "Ember", + "settings.appearance.colorScheme.option.custom": "Custom", + "settings.appearance.colorScheme.description.system": "Follow your operating system appearance.", + "settings.appearance.colorScheme.description.light": "A bright, neutral palette.", + "settings.appearance.colorScheme.description.codeNomadClassic": "The original CodeNomad dark palette.", + "settings.appearance.colorScheme.description.basalt": "Neutral charcoal with a cool blue accent.", + "settings.appearance.colorScheme.description.fjord": "Deep blue-gray with a sea-green accent.", + "settings.appearance.colorScheme.description.lichen": "Earthy green-gray with a moss accent.", + "settings.appearance.colorScheme.description.velvet": "Deep plum with a warm peach accent.", + "settings.appearance.colorScheme.description.ember": "Warm brown-black with a copper accent.", + "settings.appearance.colorScheme.description.custom": "Build and save your own palette.", + "settings.appearance.colorScheme.custom.appearance": "Custom appearance", + "settings.appearance.colorScheme.custom.appearance.light": "Light", + "settings.appearance.colorScheme.custom.appearance.dark": "Dark", + "settings.appearance.colorScheme.custom.colors": "Colors", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Base surface", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Secondary surface", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Muted surface", + "settings.appearance.colorScheme.custom.field.borderBase": "Border", + "settings.appearance.colorScheme.custom.field.textPrimary": "Primary text", + "settings.appearance.colorScheme.custom.field.textMuted": "Muted text", + "settings.appearance.colorScheme.custom.field.accentPrimary": "Accent", + "settings.appearance.colorScheme.custom.field.statusSuccess": "Success", + "settings.appearance.colorScheme.custom.field.statusWarning": "Warning", + "settings.appearance.colorScheme.custom.field.statusError": "Error", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "Choose {name}", + "settings.appearance.colorScheme.custom.valueAriaLabel": "Hex value for {name}", + "settings.appearance.colorScheme.custom.warning.format": "Use uppercase colors in #RRGGBB format.", + "settings.appearance.colorScheme.custom.warning.contrast": "Increase color contrast before saving.", + "settings.appearance.colorScheme.custom.reset": "Reset", + "settings.appearance.colorScheme.custom.save": "Save", "settings.appearance.startup.title": "Startup", "settings.appearance.startup.subtitle": "Choose what this device restores when CodeNomad starts.", "settings.appearance.startup.restore.title": "Restore previous state", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 340773fd3..7c3a04c9b 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "Coincidir con la configuración del sistema operativo", "settings.appearance.theme.option.light": "Usar apariencia clara", "settings.appearance.theme.option.dark": "Usar apariencia oscura", + "settings.appearance.colorScheme.title": "Esquema de color", + "settings.appearance.colorScheme.subtitle": "Elige la paleta de los clientes conectados a este servidor CodeNomad.", + "settings.appearance.colorScheme.option.system": "Sistema", + "settings.appearance.colorScheme.option.light": "Claro", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Clásico", + "settings.appearance.colorScheme.option.basalt": "Basalto", + "settings.appearance.colorScheme.option.fjord": "Fiordo", + "settings.appearance.colorScheme.option.lichen": "Liquen", + "settings.appearance.colorScheme.option.velvet": "Terciopelo", + "settings.appearance.colorScheme.option.ember": "Brasa", + "settings.appearance.colorScheme.option.custom": "Personalizado", + "settings.appearance.colorScheme.description.system": "Sigue la apariencia del sistema operativo.", + "settings.appearance.colorScheme.description.light": "Una paleta clara y neutra.", + "settings.appearance.colorScheme.description.codeNomadClassic": "La paleta oscura original de CodeNomad.", + "settings.appearance.colorScheme.description.basalt": "Gris carbón neutro con un acento azul frío.", + "settings.appearance.colorScheme.description.fjord": "Azul grisáceo profundo con un acento verde mar.", + "settings.appearance.colorScheme.description.lichen": "Verde grisáceo terroso con un acento musgo.", + "settings.appearance.colorScheme.description.velvet": "Ciruela intenso con un acento melocotón cálido.", + "settings.appearance.colorScheme.description.ember": "Marrón negruzco cálido con un acento cobrizo.", + "settings.appearance.colorScheme.description.custom": "Crea y guarda tu propia paleta.", + "settings.appearance.colorScheme.custom.appearance": "Apariencia personalizada", + "settings.appearance.colorScheme.custom.appearance.light": "Clara", + "settings.appearance.colorScheme.custom.appearance.dark": "Oscura", + "settings.appearance.colorScheme.custom.colors": "Colores", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Superficie base", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Superficie secundaria", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Superficie atenuada", + "settings.appearance.colorScheme.custom.field.borderBase": "Borde", + "settings.appearance.colorScheme.custom.field.textPrimary": "Texto principal", + "settings.appearance.colorScheme.custom.field.textMuted": "Texto atenuado", + "settings.appearance.colorScheme.custom.field.accentPrimary": "Acento", + "settings.appearance.colorScheme.custom.field.statusSuccess": "Éxito", + "settings.appearance.colorScheme.custom.field.statusWarning": "Advertencia", + "settings.appearance.colorScheme.custom.field.statusError": "Error", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "Elegir {name}", + "settings.appearance.colorScheme.custom.valueAriaLabel": "Valor hexadecimal de {name}", + "settings.appearance.colorScheme.custom.warning.format": "Usa colores en mayúsculas con formato #RRGGBB.", + "settings.appearance.colorScheme.custom.warning.contrast": "Aumenta el contraste antes de guardar.", + "settings.appearance.colorScheme.custom.reset": "Restablecer", + "settings.appearance.colorScheme.custom.save": "Guardar", "settings.appearance.startup.title": "Inicio", "settings.appearance.startup.subtitle": "Elige qué restaura este dispositivo al iniciar CodeNomad.", "settings.appearance.startup.restore.title": "Restaurar el estado anterior", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 774a16189..e32eece86 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "Suivre le réglage de votre système d'exploitation", "settings.appearance.theme.option.light": "Utiliser l'apparence claire", "settings.appearance.theme.option.dark": "Utiliser l'apparence sombre", + "settings.appearance.colorScheme.title": "Palette de couleurs", + "settings.appearance.colorScheme.subtitle": "Choisissez la palette des clients connectés à ce serveur CodeNomad.", + "settings.appearance.colorScheme.option.system": "Système", + "settings.appearance.colorScheme.option.light": "Clair", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Classique", + "settings.appearance.colorScheme.option.basalt": "Basalte", + "settings.appearance.colorScheme.option.fjord": "Fjord", + "settings.appearance.colorScheme.option.lichen": "Lichen", + "settings.appearance.colorScheme.option.velvet": "Velours", + "settings.appearance.colorScheme.option.ember": "Braise", + "settings.appearance.colorScheme.option.custom": "Personnalisée", + "settings.appearance.colorScheme.description.system": "Suit l'apparence du système d'exploitation.", + "settings.appearance.colorScheme.description.light": "Une palette claire et neutre.", + "settings.appearance.colorScheme.description.codeNomadClassic": "La palette sombre d'origine de CodeNomad.", + "settings.appearance.colorScheme.description.basalt": "Un gris charbon neutre rehaussé de bleu froid.", + "settings.appearance.colorScheme.description.fjord": "Un bleu-gris profond rehaussé de vert marin.", + "settings.appearance.colorScheme.description.lichen": "Un vert-gris terreux rehaussé de mousse.", + "settings.appearance.colorScheme.description.velvet": "Un prune profond rehaussé de pêche chaude.", + "settings.appearance.colorScheme.description.ember": "Un brun-noir chaud rehaussé de cuivre.", + "settings.appearance.colorScheme.description.custom": "Créez et enregistrez votre propre palette.", + "settings.appearance.colorScheme.custom.appearance": "Apparence personnalisée", + "settings.appearance.colorScheme.custom.appearance.light": "Claire", + "settings.appearance.colorScheme.custom.appearance.dark": "Sombre", + "settings.appearance.colorScheme.custom.colors": "Couleurs", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Surface de base", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Surface secondaire", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Surface atténuée", + "settings.appearance.colorScheme.custom.field.borderBase": "Bordure", + "settings.appearance.colorScheme.custom.field.textPrimary": "Texte principal", + "settings.appearance.colorScheme.custom.field.textMuted": "Texte atténué", + "settings.appearance.colorScheme.custom.field.accentPrimary": "Accent", + "settings.appearance.colorScheme.custom.field.statusSuccess": "Succès", + "settings.appearance.colorScheme.custom.field.statusWarning": "Avertissement", + "settings.appearance.colorScheme.custom.field.statusError": "Erreur", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "Choisir {name}", + "settings.appearance.colorScheme.custom.valueAriaLabel": "Valeur hexadécimale pour {name}", + "settings.appearance.colorScheme.custom.warning.format": "Utilisez le format #RRGGBB en majuscules.", + "settings.appearance.colorScheme.custom.warning.contrast": "Augmentez le contraste avant d'enregistrer.", + "settings.appearance.colorScheme.custom.reset": "Réinitialiser", + "settings.appearance.colorScheme.custom.save": "Enregistrer", "settings.appearance.startup.title": "Démarrage", "settings.appearance.startup.subtitle": "Choisissez ce que cet appareil restaure au démarrage de CodeNomad.", "settings.appearance.startup.restore.title": "Restaurer l’état précédent", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 8b3fd078a..546b75a08 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "התאם להגדרת מערכת ההפעלה", "settings.appearance.theme.option.light": "השתמש במראה בהיר", "settings.appearance.theme.option.dark": "השתמש במראה כהה", + "settings.appearance.colorScheme.title": "ערכת צבעים", + "settings.appearance.colorScheme.subtitle": "בחר את לוח הצבעים ללקוחות המחוברים לשרת CodeNomad הזה.", + "settings.appearance.colorScheme.option.system": "מערכת", + "settings.appearance.colorScheme.option.light": "בהיר", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad קלאסי", + "settings.appearance.colorScheme.option.basalt": "בזלת", + "settings.appearance.colorScheme.option.fjord": "פיורד", + "settings.appearance.colorScheme.option.lichen": "חזזית", + "settings.appearance.colorScheme.option.velvet": "קטיפה", + "settings.appearance.colorScheme.option.ember": "גחל", + "settings.appearance.colorScheme.option.custom": "מותאם אישית", + "settings.appearance.colorScheme.description.system": "מתאים למראה של מערכת ההפעלה.", + "settings.appearance.colorScheme.description.light": "לוח צבעים בהיר וניטרלי.", + "settings.appearance.colorScheme.description.codeNomadClassic": "לוח הצבעים הכהה המקורי של CodeNomad.", + "settings.appearance.colorScheme.description.basalt": "אפור פחם ניטרלי עם הדגשה כחולה קרירה.", + "settings.appearance.colorScheme.description.fjord": "כחול-אפור עמוק עם הדגשה ירוקה-ימית.", + "settings.appearance.colorScheme.description.lichen": "ירוק-אפור אדמתי עם הדגשה בגוון טחב.", + "settings.appearance.colorScheme.description.velvet": "שזיף עמוק עם הדגשה אפרסקית חמימה.", + "settings.appearance.colorScheme.description.ember": "חום-שחור חמים עם הדגשת נחושת.", + "settings.appearance.colorScheme.description.custom": "צור ושמור לוח צבעים משלך.", + "settings.appearance.colorScheme.custom.appearance": "מראה מותאם אישית", + "settings.appearance.colorScheme.custom.appearance.light": "בהיר", + "settings.appearance.colorScheme.custom.appearance.dark": "כהה", + "settings.appearance.colorScheme.custom.colors": "צבעים", + "settings.appearance.colorScheme.custom.field.surfaceBase": "משטח בסיס", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "משטח משני", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "משטח מעומעם", + "settings.appearance.colorScheme.custom.field.borderBase": "גבול", + "settings.appearance.colorScheme.custom.field.textPrimary": "טקסט ראשי", + "settings.appearance.colorScheme.custom.field.textMuted": "טקסט מעומעם", + "settings.appearance.colorScheme.custom.field.accentPrimary": "הדגשה", + "settings.appearance.colorScheme.custom.field.statusSuccess": "הצלחה", + "settings.appearance.colorScheme.custom.field.statusWarning": "אזהרה", + "settings.appearance.colorScheme.custom.field.statusError": "שגיאה", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "בחירת {name}", + "settings.appearance.colorScheme.custom.valueAriaLabel": "ערך הקסדצימלי עבור {name}", + "settings.appearance.colorScheme.custom.warning.format": "יש להשתמש באותיות גדולות בתבנית #RRGGBB.", + "settings.appearance.colorScheme.custom.warning.contrast": "יש להגדיל את ניגודיות הצבעים לפני השמירה.", + "settings.appearance.colorScheme.custom.reset": "איפוס", + "settings.appearance.colorScheme.custom.save": "שמירה", "settings.appearance.startup.title": "הפעלה", "settings.appearance.startup.subtitle": "בחר מה ישוחזר במכשיר זה בעת הפעלת CodeNomad.", "settings.appearance.startup.restore.title": "שחזור המצב הקודם", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 5c2bd11a7..59226212b 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "OS の設定に合わせる", "settings.appearance.theme.option.light": "ライト表示を使用", "settings.appearance.theme.option.dark": "ダーク表示を使用", + "settings.appearance.colorScheme.title": "カラースキーム", + "settings.appearance.colorScheme.subtitle": "この CodeNomad サーバーに接続するクライアントの配色を選択します。", + "settings.appearance.colorScheme.option.system": "システム", + "settings.appearance.colorScheme.option.light": "ライト", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad クラシック", + "settings.appearance.colorScheme.option.basalt": "バサルト", + "settings.appearance.colorScheme.option.fjord": "フィヨルド", + "settings.appearance.colorScheme.option.lichen": "ライケン", + "settings.appearance.colorScheme.option.velvet": "ベルベット", + "settings.appearance.colorScheme.option.ember": "エンバー", + "settings.appearance.colorScheme.option.custom": "カスタム", + "settings.appearance.colorScheme.description.system": "OS の表示設定に合わせます。", + "settings.appearance.colorScheme.description.light": "明るくニュートラルな配色です。", + "settings.appearance.colorScheme.description.codeNomadClassic": "CodeNomad オリジナルのダーク配色です。", + "settings.appearance.colorScheme.description.basalt": "チャコールに寒色の青を合わせた配色です。", + "settings.appearance.colorScheme.description.fjord": "深いブルーグレーに青緑を合わせた配色です。", + "settings.appearance.colorScheme.description.lichen": "土色のグリーングレーに苔色を合わせた配色です。", + "settings.appearance.colorScheme.description.velvet": "深いプラム色に暖かなピーチ色を合わせた配色です。", + "settings.appearance.colorScheme.description.ember": "暖かな黒褐色に銅色を合わせた配色です。", + "settings.appearance.colorScheme.description.custom": "独自の配色を作成して保存します。", + "settings.appearance.colorScheme.custom.appearance": "カスタム表示", + "settings.appearance.colorScheme.custom.appearance.light": "ライト", + "settings.appearance.colorScheme.custom.appearance.dark": "ダーク", + "settings.appearance.colorScheme.custom.colors": "色", + "settings.appearance.colorScheme.custom.field.surfaceBase": "基本サーフェス", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "第2サーフェス", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "控えめなサーフェス", + "settings.appearance.colorScheme.custom.field.borderBase": "境界線", + "settings.appearance.colorScheme.custom.field.textPrimary": "メインテキスト", + "settings.appearance.colorScheme.custom.field.textMuted": "控えめなテキスト", + "settings.appearance.colorScheme.custom.field.accentPrimary": "アクセント", + "settings.appearance.colorScheme.custom.field.statusSuccess": "成功", + "settings.appearance.colorScheme.custom.field.statusWarning": "警告", + "settings.appearance.colorScheme.custom.field.statusError": "エラー", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "{name}を選択", + "settings.appearance.colorScheme.custom.valueAriaLabel": "{name}の16進値", + "settings.appearance.colorScheme.custom.warning.format": "大文字の #RRGGBB 形式で入力してください。", + "settings.appearance.colorScheme.custom.warning.contrast": "保存する前に色のコントラストを上げてください。", + "settings.appearance.colorScheme.custom.reset": "リセット", + "settings.appearance.colorScheme.custom.save": "保存", "settings.appearance.startup.title": "起動", "settings.appearance.startup.subtitle": "CodeNomad の起動時にこのデバイスで復元する内容を選択します。", "settings.appearance.startup.restore.title": "前回の状態を復元", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 8fff71e90..3701f315f 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "तपाईंको अपरेटिङ सिस्टम सेटिङ अनुसार", "settings.appearance.theme.option.light": "उज्यालो रूप (Light appearance) प्रयोग गर्नुहोस्", "settings.appearance.theme.option.dark": "अँध्यारो रूप (Dark appearance) प्रयोग गर्नुहोस्", + "settings.appearance.colorScheme.title": "रङ योजना", + "settings.appearance.colorScheme.subtitle": "यो CodeNomad सर्भरमा जोडिएका क्लाइन्टहरूको रङ प्यालेट छान्नुहोस्।", + "settings.appearance.colorScheme.option.system": "प्रणाली", + "settings.appearance.colorScheme.option.light": "उज्यालो", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad क्लासिक", + "settings.appearance.colorScheme.option.basalt": "बेसाल्ट", + "settings.appearance.colorScheme.option.fjord": "फ्योर्ड", + "settings.appearance.colorScheme.option.lichen": "लाइकेन", + "settings.appearance.colorScheme.option.velvet": "भेल्भेट", + "settings.appearance.colorScheme.option.ember": "एम्बर", + "settings.appearance.colorScheme.option.custom": "अनुकूलित", + "settings.appearance.colorScheme.description.system": "अपरेटिङ सिस्टमको रूप पछ्याउँछ।", + "settings.appearance.colorScheme.description.light": "उज्यालो र तटस्थ प्यालेट।", + "settings.appearance.colorScheme.description.codeNomadClassic": "CodeNomad को मौलिक अँध्यारो प्यालेट।", + "settings.appearance.colorScheme.description.basalt": "चिसो नीलो जोडसहितको तटस्थ कोइला रङ।", + "settings.appearance.colorScheme.description.fjord": "समुद्री हरियो जोडसहितको गाढा नीलो-खैरो।", + "settings.appearance.colorScheme.description.lichen": "काइ-जस्तो जोडसहितको माटो रङको हरियो-खैरो।", + "settings.appearance.colorScheme.description.velvet": "न्यानो आरु रङको जोडसहितको गाढा बैजनी।", + "settings.appearance.colorScheme.description.ember": "तामाको जोडसहितको न्यानो खैरो-कालो।", + "settings.appearance.colorScheme.description.custom": "आफ्नै प्यालेट बनाएर सुरक्षित गर्नुहोस्।", + "settings.appearance.colorScheme.custom.appearance": "अनुकूलित रूप", + "settings.appearance.colorScheme.custom.appearance.light": "उज्यालो", + "settings.appearance.colorScheme.custom.appearance.dark": "अँध्यारो", + "settings.appearance.colorScheme.custom.colors": "रङहरू", + "settings.appearance.colorScheme.custom.field.surfaceBase": "आधार सतह", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "दोस्रो सतह", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "मधुरो सतह", + "settings.appearance.colorScheme.custom.field.borderBase": "किनारा", + "settings.appearance.colorScheme.custom.field.textPrimary": "मुख्य पाठ", + "settings.appearance.colorScheme.custom.field.textMuted": "मधुरो पाठ", + "settings.appearance.colorScheme.custom.field.accentPrimary": "जोड रङ", + "settings.appearance.colorScheme.custom.field.statusSuccess": "सफलता", + "settings.appearance.colorScheme.custom.field.statusWarning": "चेतावनी", + "settings.appearance.colorScheme.custom.field.statusError": "त्रुटि", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "{name} छान्नुहोस्", + "settings.appearance.colorScheme.custom.valueAriaLabel": "{name} को हेक्स मान", + "settings.appearance.colorScheme.custom.warning.format": "ठूला अक्षरसहित #RRGGBB ढाँचा प्रयोग गर्नुहोस्।", + "settings.appearance.colorScheme.custom.warning.contrast": "सुरक्षित गर्नुअघि रङको कन्ट्रास्ट बढाउनुहोस्।", + "settings.appearance.colorScheme.custom.reset": "रिसेट गर्नुहोस्", + "settings.appearance.colorScheme.custom.save": "सुरक्षित गर्नुहोस्", "settings.appearance.startup.title": "सुरुआत", "settings.appearance.startup.subtitle": "CodeNomad सुरु हुँदा यस यन्त्रमा के पुनर्स्थापना गर्ने छान्नुहोस्।", "settings.appearance.startup.restore.title": "अघिल्लो अवस्था पुनर्स्थापना गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 8f7578aef..20b1211ed 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "Следовать настройкам операционной системы", "settings.appearance.theme.option.light": "Использовать светлую тему", "settings.appearance.theme.option.dark": "Использовать тёмную тему", + "settings.appearance.colorScheme.title": "Цветовая схема", + "settings.appearance.colorScheme.subtitle": "Выберите палитру для клиентов этого сервера CodeNomad.", + "settings.appearance.colorScheme.option.system": "Системная", + "settings.appearance.colorScheme.option.light": "Светлая", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Классика", + "settings.appearance.colorScheme.option.basalt": "Базальт", + "settings.appearance.colorScheme.option.fjord": "Фьорд", + "settings.appearance.colorScheme.option.lichen": "Лишайник", + "settings.appearance.colorScheme.option.velvet": "Бархат", + "settings.appearance.colorScheme.option.ember": "Уголёк", + "settings.appearance.colorScheme.option.custom": "Своя", + "settings.appearance.colorScheme.description.system": "Следует оформлению операционной системы.", + "settings.appearance.colorScheme.description.light": "Светлая нейтральная палитра.", + "settings.appearance.colorScheme.description.codeNomadClassic": "Оригинальная тёмная палитра CodeNomad.", + "settings.appearance.colorScheme.description.basalt": "Нейтральный угольный цвет с холодным синим акцентом.", + "settings.appearance.colorScheme.description.fjord": "Глубокий серо-синий цвет с морским зелёным акцентом.", + "settings.appearance.colorScheme.description.lichen": "Землистый серо-зелёный цвет с мшистым акцентом.", + "settings.appearance.colorScheme.description.velvet": "Глубокий сливовый цвет с тёплым персиковым акцентом.", + "settings.appearance.colorScheme.description.ember": "Тёплый чёрно-коричневый цвет с медным акцентом.", + "settings.appearance.colorScheme.description.custom": "Создайте и сохраните собственную палитру.", + "settings.appearance.colorScheme.custom.appearance": "Своя тема", + "settings.appearance.colorScheme.custom.appearance.light": "Светлая", + "settings.appearance.colorScheme.custom.appearance.dark": "Тёмная", + "settings.appearance.colorScheme.custom.colors": "Цвета", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Основной фон", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "Вторичный фон", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Приглушённый фон", + "settings.appearance.colorScheme.custom.field.borderBase": "Граница", + "settings.appearance.colorScheme.custom.field.textPrimary": "Основной текст", + "settings.appearance.colorScheme.custom.field.textMuted": "Приглушённый текст", + "settings.appearance.colorScheme.custom.field.accentPrimary": "Акцент", + "settings.appearance.colorScheme.custom.field.statusSuccess": "Успех", + "settings.appearance.colorScheme.custom.field.statusWarning": "Предупреждение", + "settings.appearance.colorScheme.custom.field.statusError": "Ошибка", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "Выбрать цвет: {name}", + "settings.appearance.colorScheme.custom.valueAriaLabel": "Шестнадцатеричное значение: {name}", + "settings.appearance.colorScheme.custom.warning.format": "Используйте формат #RRGGBB с заглавными буквами.", + "settings.appearance.colorScheme.custom.warning.contrast": "Увеличьте контраст перед сохранением.", + "settings.appearance.colorScheme.custom.reset": "Сбросить", + "settings.appearance.colorScheme.custom.save": "Сохранить", "settings.appearance.startup.title": "Запуск", "settings.appearance.startup.subtitle": "Выберите, что восстанавливать на этом устройстве при запуске CodeNomad.", "settings.appearance.startup.restore.title": "Восстанавливать предыдущее состояние", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index d16f52114..394cab13c 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -132,6 +132,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "İşletim sistemi ayarıyla eşleş", "settings.appearance.theme.option.light": "Açık görünümü kullan", "settings.appearance.theme.option.dark": "Koyu görünümü kullan", + "settings.appearance.colorScheme.title": "Renk şeması", + "settings.appearance.colorScheme.subtitle": "Bu CodeNomad sunucusuna bağlı istemcilerin paletini seçin.", + "settings.appearance.colorScheme.option.system": "Sistem", + "settings.appearance.colorScheme.option.light": "Açık", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad Klasik", + "settings.appearance.colorScheme.option.basalt": "Bazalt", + "settings.appearance.colorScheme.option.fjord": "Fiyort", + "settings.appearance.colorScheme.option.lichen": "Liken", + "settings.appearance.colorScheme.option.velvet": "Kadife", + "settings.appearance.colorScheme.option.ember": "Kor", + "settings.appearance.colorScheme.option.custom": "Özel", + "settings.appearance.colorScheme.description.system": "İşletim sisteminin görünümünü izler.", + "settings.appearance.colorScheme.description.light": "Açık ve nötr bir palet.", + "settings.appearance.colorScheme.description.codeNomadClassic": "CodeNomad'in özgün koyu paleti.", + "settings.appearance.colorScheme.description.basalt": "Soğuk mavi vurgulu nötr kömür grisi.", + "settings.appearance.colorScheme.description.fjord": "Deniz yeşili vurgulu derin mavi-gri.", + "settings.appearance.colorScheme.description.lichen": "Yosun vurgulu doğal yeşil-gri.", + "settings.appearance.colorScheme.description.velvet": "Sıcak şeftali vurgulu koyu erik rengi.", + "settings.appearance.colorScheme.description.ember": "Bakır vurgulu sıcak kahverengi-siyah.", + "settings.appearance.colorScheme.description.custom": "Kendi paletinizi oluşturup kaydedin.", + "settings.appearance.colorScheme.custom.appearance": "Özel görünüm", + "settings.appearance.colorScheme.custom.appearance.light": "Açık", + "settings.appearance.colorScheme.custom.appearance.dark": "Koyu", + "settings.appearance.colorScheme.custom.colors": "Renkler", + "settings.appearance.colorScheme.custom.field.surfaceBase": "Temel yüzey", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "İkincil yüzey", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "Soluk yüzey", + "settings.appearance.colorScheme.custom.field.borderBase": "Kenarlık", + "settings.appearance.colorScheme.custom.field.textPrimary": "Ana metin", + "settings.appearance.colorScheme.custom.field.textMuted": "Soluk metin", + "settings.appearance.colorScheme.custom.field.accentPrimary": "Vurgu", + "settings.appearance.colorScheme.custom.field.statusSuccess": "Başarı", + "settings.appearance.colorScheme.custom.field.statusWarning": "Uyarı", + "settings.appearance.colorScheme.custom.field.statusError": "Hata", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "{name} seç", + "settings.appearance.colorScheme.custom.valueAriaLabel": "{name} için onaltılık değer", + "settings.appearance.colorScheme.custom.warning.format": "Büyük harflerle #RRGGBB biçimini kullanın.", + "settings.appearance.colorScheme.custom.warning.contrast": "Kaydetmeden önce renk kontrastını artırın.", + "settings.appearance.colorScheme.custom.reset": "Sıfırla", + "settings.appearance.colorScheme.custom.save": "Kaydet", "settings.appearance.startup.title": "Başlangıç", "settings.appearance.startup.subtitle": "CodeNomad başladığında bu cihazın neleri geri yükleyeceğini seçin.", "settings.appearance.startup.restore.title": "Önceki durumu geri yükle", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 55392374b..4f4a40ba3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -142,6 +142,46 @@ export const settingsMessages = { "settings.appearance.theme.option.system": "跟随操作系统设置", "settings.appearance.theme.option.light": "使用浅色外观", "settings.appearance.theme.option.dark": "使用深色外观", + "settings.appearance.colorScheme.title": "配色方案", + "settings.appearance.colorScheme.subtitle": "选择连接到此 CodeNomad 服务器的客户端所用配色。", + "settings.appearance.colorScheme.option.system": "系统", + "settings.appearance.colorScheme.option.light": "浅色", + "settings.appearance.colorScheme.option.codeNomadClassic": "CodeNomad 经典", + "settings.appearance.colorScheme.option.basalt": "玄武岩", + "settings.appearance.colorScheme.option.fjord": "峡湾", + "settings.appearance.colorScheme.option.lichen": "地衣", + "settings.appearance.colorScheme.option.velvet": "天鹅绒", + "settings.appearance.colorScheme.option.ember": "余烬", + "settings.appearance.colorScheme.option.custom": "自定义", + "settings.appearance.colorScheme.description.system": "跟随操作系统外观。", + "settings.appearance.colorScheme.description.light": "明亮、中性的配色。", + "settings.appearance.colorScheme.description.codeNomadClassic": "CodeNomad 原版深色配色。", + "settings.appearance.colorScheme.description.basalt": "中性炭灰搭配冷蓝色强调色。", + "settings.appearance.colorScheme.description.fjord": "深蓝灰搭配海绿色强调色。", + "settings.appearance.colorScheme.description.lichen": "大地绿灰搭配苔绿色强调色。", + "settings.appearance.colorScheme.description.velvet": "深梅紫搭配暖桃色强调色。", + "settings.appearance.colorScheme.description.ember": "暖棕黑搭配铜色强调色。", + "settings.appearance.colorScheme.description.custom": "创建并保存自己的配色。", + "settings.appearance.colorScheme.custom.appearance": "自定义外观", + "settings.appearance.colorScheme.custom.appearance.light": "浅色", + "settings.appearance.colorScheme.custom.appearance.dark": "深色", + "settings.appearance.colorScheme.custom.colors": "颜色", + "settings.appearance.colorScheme.custom.field.surfaceBase": "基础表面", + "settings.appearance.colorScheme.custom.field.surfaceSecondary": "次级表面", + "settings.appearance.colorScheme.custom.field.surfaceMuted": "弱化表面", + "settings.appearance.colorScheme.custom.field.borderBase": "边框", + "settings.appearance.colorScheme.custom.field.textPrimary": "主要文本", + "settings.appearance.colorScheme.custom.field.textMuted": "弱化文本", + "settings.appearance.colorScheme.custom.field.accentPrimary": "强调色", + "settings.appearance.colorScheme.custom.field.statusSuccess": "成功", + "settings.appearance.colorScheme.custom.field.statusWarning": "警告", + "settings.appearance.colorScheme.custom.field.statusError": "错误", + "settings.appearance.colorScheme.custom.pickerAriaLabel": "选择{name}", + "settings.appearance.colorScheme.custom.valueAriaLabel": "{name}的十六进制值", + "settings.appearance.colorScheme.custom.warning.format": "请使用大写的 #RRGGBB 格式。", + "settings.appearance.colorScheme.custom.warning.contrast": "请先提高颜色对比度再保存。", + "settings.appearance.colorScheme.custom.reset": "重置", + "settings.appearance.colorScheme.custom.save": "保存", "settings.appearance.startup.title": "启动", "settings.appearance.startup.subtitle": "选择 CodeNomad 启动时在此设备上恢复的内容。", "settings.appearance.startup.restore.title": "恢复上次状态", diff --git a/packages/ui/src/lib/theme-scheme.test.ts b/packages/ui/src/lib/theme-scheme.test.ts new file mode 100644 index 000000000..134075277 --- /dev/null +++ b/packages/ui/src/lib/theme-scheme.test.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { + BUILT_IN_COLOR_SCHEMES, + DEFAULT_CUSTOM_COLORS, + applyColorScheme, + contrastRatio, + normalizeColorScheme, + textOnColor, + validateColorSchemeColors, + type ColorSchemeTarget, +} from "./theme-scheme.ts" + +function target() { + const properties = new Map() + const attributes = new Map() + const value: ColorSchemeTarget = { + style: { + setProperty(name, propertyValue) { + properties.set(name, propertyValue) + }, + removeProperty(name) { + return properties.delete(name) + }, + }, + dataset: {}, + setAttribute(name, attributeValue) { + attributes.set(name, attributeValue) + }, + removeAttribute(name) { + attributes.delete(name) + }, + } + return { value, properties, attributes } +} + +describe("normalizeColorScheme", () => { + it("fails malformed persistence closed to safe defaults", () => { + assert.equal(normalizeColorScheme({ id: "unknown" }).id, "system") + assert.equal(normalizeColorScheme({ id: "toString" }).id, "system") + assert.deepEqual(normalizeColorScheme({ id: "custom", colors: { surfaceBase: "#fff" } }), { + id: "custom", + appearance: "dark", + colors: DEFAULT_CUSTOM_COLORS, + }) + assert.equal(normalizeColorScheme({ id: "custom", colors: { ...DEFAULT_CUSTOM_COLORS, surfaceBase: "#ffffff" } }).colors?.surfaceBase, "#17181A") + }) + + it("maps legacy themes only when the color scheme is invalid", () => { + assert.equal(normalizeColorScheme(undefined, "system").id, "system") + assert.equal(normalizeColorScheme(undefined, "light").id, "light") + assert.equal(normalizeColorScheme(undefined, "dark").id, "classic") + assert.equal(normalizeColorScheme("fjord", "light").id, "fjord") + }) + + it("persists a custom scheme's independent appearance", () => { + assert.equal(normalizeColorScheme({ id: "custom", appearance: "light", colors: DEFAULT_CUSTOM_COLORS }).appearance, "light") + assert.equal(normalizeColorScheme({ id: "custom", appearance: "system", colors: DEFAULT_CUSTOM_COLORS }).appearance, "dark") + }) +}) + +describe("built-in color schemes", () => { + it("keeps every preset within its contrast requirements", () => { + for (const scheme of BUILT_IN_COLOR_SCHEMES) { + if (scheme.colors) assert.equal(validateColorSchemeColors(scheme.colors), true, scheme.id) + } + }) + + it("uses the specified independent preset accents", () => { + const accents = Object.fromEntries(BUILT_IN_COLOR_SCHEMES.filter((scheme) => !scheme.editable).map((scheme) => [scheme.id, scheme.colors?.accentPrimary])) + assert.equal(accents.basalt, "#8FA8FF") + assert.equal(accents.fjord, "#67C9BA") + assert.equal(accents.lichen, "#A9C47F") + assert.equal(accents.velvet, "#E5A77D") + assert.equal(accents.ember, "#D79A66") + }) + + it("preserves the exact CodeNomad Classic dark palette", () => { + assert.deepEqual(BUILT_IN_COLOR_SCHEMES.find((scheme) => scheme.id === "classic")?.colors, { + surfaceBase: "#1A1A1A", + surfaceSecondary: "#2A2A2A", + surfaceMuted: "#212529", + borderBase: "#3A3A3A", + textPrimary: "#CFD4DC", + textMuted: "#999999", + accentPrimary: "#0080FF", + statusSuccess: "#4CAF50", + statusWarning: "#FF9800", + statusError: "#F44336", + }) + }) +}) + +describe("applyColorScheme", () => { + it("clears stale overrides when switching to system or light", () => { + const root = target() + applyColorScheme(normalizeColorScheme("fjord"), { target: root.value }) + assert.ok(root.properties.size > 0) + assert.equal(root.attributes.get("data-theme"), "dark") + + applyColorScheme(normalizeColorScheme("system"), { target: root.value, systemDark: true }) + assert.equal(root.properties.size, 0) + assert.equal(root.attributes.has("data-theme"), false) + assert.equal(root.value.dataset.colorScheme, "system") + + applyColorScheme(normalizeColorScheme("ember"), { target: root.value }) + applyColorScheme(normalizeColorScheme("light"), { target: root.value }) + assert.equal(root.properties.size, 0) + assert.equal(root.attributes.get("data-theme"), "light") + }) + + it("uses the existing dark CSS tokens for CodeNomad Classic", () => { + const root = target() + applyColorScheme(normalizeColorScheme("fjord"), { target: root.value }) + applyColorScheme(normalizeColorScheme("classic"), { target: root.value }) + assert.equal(root.properties.size, 0) + assert.equal(root.attributes.get("data-theme"), "dark") + }) + + it("resolves system appearance without imposing a data theme", () => { + const darkRoot = target() + const lightRoot = target() + assert.equal(applyColorScheme(normalizeColorScheme("system"), { target: darkRoot.value, systemDark: true }), true) + assert.equal(applyColorScheme(normalizeColorScheme("system"), { target: lightRoot.value, systemDark: false }), false) + assert.equal(darkRoot.attributes.has("data-theme"), false) + assert.equal(lightRoot.attributes.has("data-theme"), false) + }) + + it("derives readable text on accent", () => { + const root = target() + const scheme = normalizeColorScheme("basalt") + applyColorScheme(scheme, { target: root.value }) + const text = root.properties.get("--text-on-accent") + assert.equal(text, textOnColor("#8FA8FF")) + assert.ok(contrastRatio(text ?? "", "#8FA8FF") >= 4.5) + }) + + it("derives legacy blue UI states from the selected accent", () => { + const root = target() + applyColorScheme(normalizeColorScheme("fjord"), { target: root.value }) + assert.equal(root.properties.get("--attachment-chip-text"), "#67C9BA") + assert.equal(root.properties.get("--dropdown-highlight-bg"), "rgba(103, 201, 186, 0.2)") + }) +}) + +describe("custom color validation", () => { + it("rejects weak text and emphasis contrast", () => { + assert.equal(validateColorSchemeColors(DEFAULT_CUSTOM_COLORS), true) + assert.equal(validateColorSchemeColors({ ...DEFAULT_CUSTOM_COLORS, textMuted: "#555555" }), false) + assert.equal(validateColorSchemeColors({ ...DEFAULT_CUSTOM_COLORS, accentPrimary: "#303030" }), false) + assert.equal(validateColorSchemeColors({ ...DEFAULT_CUSTOM_COLORS, statusWarning: "orange" }), false) + assert.equal(validateColorSchemeColors({ + ...DEFAULT_CUSTOM_COLORS, + surfaceBase: "#FFFFFF", + surfaceSecondary: "#000000", + textPrimary: "#767676", + textMuted: "#767676", + accentPrimary: "#FFFFFF", + statusSuccess: "#FFFFFF", + statusWarning: "#FFFFFF", + statusError: "#FFFFFF", + }), false) + }) +}) diff --git a/packages/ui/src/lib/theme-scheme.ts b/packages/ui/src/lib/theme-scheme.ts new file mode 100644 index 000000000..ab1a45cdb --- /dev/null +++ b/packages/ui/src/lib/theme-scheme.ts @@ -0,0 +1,415 @@ +export const COLOR_SCHEME_IDS = ["system", "light", "classic", "basalt", "fjord", "lichen", "velvet", "ember", "custom"] as const + +export type ColorSchemeId = (typeof COLOR_SCHEME_IDS)[number] +export type ColorSchemeAppearance = "system" | "light" | "dark" + +export interface ColorSchemeColors { + surfaceBase: string + surfaceSecondary: string + surfaceMuted: string + borderBase: string + textPrimary: string + textMuted: string + accentPrimary: string + statusSuccess: string + statusWarning: string + statusError: string +} + +export interface ColorSchemeDefinition { + id: ColorSchemeId + labelKey: string + descriptionKey: string + appearance: ColorSchemeAppearance + editable: boolean + colors?: Readonly +} + +export interface NormalizedColorScheme { + id: ColorSchemeId + appearance: ColorSchemeAppearance + colors?: ColorSchemeColors +} + +export interface ColorSchemeTarget { + style: { + setProperty(name: string, value: string): void + removeProperty(name: string): unknown + } + dataset: Record + setAttribute(name: string, value: string): void + removeAttribute(name: string): void +} + +const COLOR_KEYS: readonly (keyof ColorSchemeColors)[] = [ + "surfaceBase", + "surfaceSecondary", + "surfaceMuted", + "borderBase", + "textPrimary", + "textMuted", + "accentPrimary", + "statusSuccess", + "statusWarning", + "statusError", +] + +export const DEFAULT_CUSTOM_COLORS: Readonly = { + surfaceBase: "#17181A", + surfaceSecondary: "#202226", + surfaceMuted: "#292C31", + borderBase: "#3B3F46", + textPrimary: "#F0F1F3", + textMuted: "#B2B6BE", + accentPrimary: "#8FA8FF", + statusSuccess: "#72C497", + statusWarning: "#D8B36A", + statusError: "#E28181", +} + +export const BUILT_IN_COLOR_SCHEMES: readonly ColorSchemeDefinition[] = [ + { + id: "system", + labelKey: "settings.appearance.colorScheme.option.system", + descriptionKey: "settings.appearance.colorScheme.description.system", + appearance: "system", + editable: false, + }, + { + id: "light", + labelKey: "settings.appearance.colorScheme.option.light", + descriptionKey: "settings.appearance.colorScheme.description.light", + appearance: "light", + editable: false, + }, + { + id: "classic", + labelKey: "settings.appearance.colorScheme.option.codeNomadClassic", + descriptionKey: "settings.appearance.colorScheme.description.codeNomadClassic", + appearance: "dark", + editable: false, + colors: { + surfaceBase: "#1A1A1A", + surfaceSecondary: "#2A2A2A", + surfaceMuted: "#212529", + borderBase: "#3A3A3A", + textPrimary: "#CFD4DC", + textMuted: "#999999", + accentPrimary: "#0080FF", + statusSuccess: "#4CAF50", + statusWarning: "#FF9800", + statusError: "#F44336", + }, + }, + { + id: "basalt", + labelKey: "settings.appearance.colorScheme.option.basalt", + descriptionKey: "settings.appearance.colorScheme.description.basalt", + appearance: "dark", + editable: false, + colors: DEFAULT_CUSTOM_COLORS, + }, + { + id: "fjord", + labelKey: "settings.appearance.colorScheme.option.fjord", + descriptionKey: "settings.appearance.colorScheme.description.fjord", + appearance: "dark", + editable: false, + colors: { + surfaceBase: "#131A1F", + surfaceSecondary: "#1B252C", + surfaceMuted: "#243139", + borderBase: "#354650", + textPrimary: "#E7EEF1", + textMuted: "#A8B8BF", + accentPrimary: "#67C9BA", + statusSuccess: "#72C497", + statusWarning: "#D8B36A", + statusError: "#E28181", + }, + }, + { + id: "lichen", + labelKey: "settings.appearance.colorScheme.option.lichen", + descriptionKey: "settings.appearance.colorScheme.description.lichen", + appearance: "dark", + editable: false, + colors: { + surfaceBase: "#181A15", + surfaceSecondary: "#22251D", + surfaceMuted: "#2C3025", + borderBase: "#41473A", + textPrimary: "#ECEEE8", + textMuted: "#B2B8A7", + accentPrimary: "#A9C47F", + statusSuccess: "#77C49A", + statusWarning: "#D6B36D", + statusError: "#DF8580", + }, + }, + { + id: "velvet", + labelKey: "settings.appearance.colorScheme.option.velvet", + descriptionKey: "settings.appearance.colorScheme.description.velvet", + appearance: "dark", + editable: false, + colors: { + surfaceBase: "#160F16", + surfaceSecondary: "#211721", + surfaceMuted: "#2B1E2B", + borderBase: "#443044", + textPrimary: "#F1EAF0", + textMuted: "#BAAAB7", + accentPrimary: "#E5A77D", + statusSuccess: "#78C59A", + statusWarning: "#DDB46F", + statusError: "#E28787", + }, + }, + { + id: "ember", + labelKey: "settings.appearance.colorScheme.option.ember", + descriptionKey: "settings.appearance.colorScheme.description.ember", + appearance: "dark", + editable: false, + colors: { + surfaceBase: "#1B1714", + surfaceSecondary: "#27211D", + surfaceMuted: "#312923", + borderBase: "#493D34", + textPrimary: "#F1ECE7", + textMuted: "#BDB1A6", + accentPrimary: "#D79A66", + statusSuccess: "#78C296", + statusWarning: "#D8AE62", + statusError: "#DE817A", + }, + }, + { + id: "custom", + labelKey: "settings.appearance.colorScheme.option.custom", + descriptionKey: "settings.appearance.colorScheme.description.custom", + appearance: "dark", + editable: true, + colors: DEFAULT_CUSTOM_COLORS, + }, +] + +const SCHEMES_BY_ID = Object.fromEntries(BUILT_IN_COLOR_SCHEMES.map((scheme) => [scheme.id, scheme])) as Record< + ColorSchemeId, + ColorSchemeDefinition +> + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +export const isCanonicalHexColor = (value: unknown): value is string => + typeof value === "string" && /^#[0-9A-F]{6}$/.test(value) + +export function isColorSchemeColors(value: unknown): value is ColorSchemeColors { + return isRecord(value) && COLOR_KEYS.every((key) => isCanonicalHexColor(value[key])) +} + +const copyColors = (colors: Readonly): ColorSchemeColors => ({ ...colors }) + +function selectionFor( + id: ColorSchemeId, + colors?: Readonly, + appearance: ColorSchemeAppearance = SCHEMES_BY_ID[id].appearance, +): NormalizedColorScheme { + const definition = SCHEMES_BY_ID[id] + return { + id, + appearance, + ...(definition.colors ? { colors: copyColors(colors ?? definition.colors) } : {}), + } +} + +export function normalizeColorScheme(value: unknown, legacyTheme?: unknown): NormalizedColorScheme { + const id = typeof value === "string" ? value : isRecord(value) ? value.id : undefined + + if (typeof id === "string" && COLOR_SCHEME_IDS.includes(id as ColorSchemeId)) { + const schemeId = id as ColorSchemeId + if (schemeId !== "custom") return selectionFor(schemeId) + const colors = isRecord(value) && isColorSchemeColors(value.colors) ? value.colors : DEFAULT_CUSTOM_COLORS + const appearance = isRecord(value) && value.appearance === "light" ? "light" : "dark" + return selectionFor("custom", colors, appearance) + } + + if (legacyTheme === "light") return selectionFor("light") + if (legacyTheme === "dark") return selectionFor("classic") + return selectionFor("system") +} + +const channel = (color: string, offset: number) => Number.parseInt(color.slice(offset, offset + 2), 16) + +const luminance = (color: string) => { + const linear = (value: number) => { + const normalized = value / 255 + return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * linear(channel(color, 1)) + 0.7152 * linear(channel(color, 3)) + 0.0722 * linear(channel(color, 5)) +} + +export function contrastRatio(first: string, second: string): number { + if (!isCanonicalHexColor(first) || !isCanonicalHexColor(second)) return 0 + const [bright, dark] = [luminance(first), luminance(second)].sort((a, b) => b - a) + return (bright + 0.05) / (dark + 0.05) +} + +export function validateColorSchemeColors(value: unknown): value is ColorSchemeColors { + if (!isColorSchemeColors(value)) return false + const textPairs: Array<[string, string]> = [ + [value.textPrimary, value.surfaceBase], + [value.textPrimary, value.surfaceSecondary], + [value.textMuted, value.surfaceBase], + [value.textMuted, value.surfaceSecondary], + ] + const emphasis = [value.accentPrimary, value.statusSuccess, value.statusWarning, value.statusError] + const emphasisSurfaces = [value.surfaceBase, value.surfaceSecondary] + return ( + textPairs.every(([foreground, background]) => contrastRatio(foreground, background) >= 4.5) && + emphasis.every((foreground) => emphasisSurfaces.every((background) => contrastRatio(foreground, background) >= 3)) + ) +} + +const mix = (first: string, second: string, firstWeight: number) => { + const mixed = [1, 3, 5].map((offset) => + Math.round(channel(first, offset) * firstWeight + channel(second, offset) * (1 - firstWeight)), + ) + return `#${mixed.map((value) => value.toString(16).padStart(2, "0")).join("").toUpperCase()}` +} + +const alpha = (color: string, opacity: number) => + `rgba(${channel(color, 1)}, ${channel(color, 3)}, ${channel(color, 5)}, ${opacity})` + +export const textOnColor = (color: string): "#000000" | "#FFFFFF" => + contrastRatio("#000000", color) >= contrastRatio("#FFFFFF", color) ? "#000000" : "#FFFFFF" + +const APPLIED_PROPERTIES = [ + "--surface-base", + "--surface-primary", + "--surface-secondary", + "--surface-muted", + "--surface-code", + "--surface-hover", + "--border-base", + "--border-secondary", + "--border-muted", + "--border-strong", + "--border-critical", + "--text-primary", + "--text-secondary", + "--text-muted", + "--text-inverted", + "--text-on-accent", + "--accent-primary", + "--accent-hover", + "--focus-ring-color", + "--focus-ring-offset", + "--list-item-highlight-bg", + "--list-item-highlight-bg-solid", + "--list-item-highlight-border", + "--attachment-chip-bg", + "--attachment-chip-text", + "--attachment-chip-ring", + "--badge-neutral-bg", + "--badge-neutral-text", + "--env-vars-bg", + "--env-vars-border", + "--env-vars-text", + "--dropdown-highlight-bg", + "--selection-highlight-bg", + "--selection-highlight-strong-bg", + "--status-success", + "--status-warning", + "--status-error", + "--status-success-bg", + "--status-warning-bg", + "--status-error-bg", + "--status-success-ring", + "--status-warning-ring", + "--status-error-ring", + "--status-ready-fg", + "--status-ready-bg", + "--status-starting-fg", + "--status-starting-bg", + "--status-error-fg", +] as const + +function derivedProperties(colors: ColorSchemeColors, dark: boolean): Record<(typeof APPLIED_PROPERTIES)[number], string> { + const textOnAccent = textOnColor(colors.accentPrimary) + return { + "--surface-base": colors.surfaceBase, + "--surface-primary": colors.surfaceBase, + "--surface-secondary": colors.surfaceSecondary, + "--surface-muted": colors.surfaceMuted, + "--surface-code": dark ? colors.surfaceBase : colors.surfaceMuted, + "--surface-hover": mix(colors.textPrimary, colors.surfaceSecondary, dark ? 0.12 : 0.08), + "--border-base": colors.borderBase, + "--border-secondary": mix(colors.borderBase, colors.surfaceSecondary, 0.72), + "--border-muted": mix(colors.borderBase, colors.surfaceSecondary, 0.5), + "--border-strong": mix(colors.textPrimary, colors.borderBase, 0.3), + "--border-critical": colors.statusError, + "--text-primary": colors.textPrimary, + "--text-secondary": colors.textMuted, + "--text-muted": colors.textMuted, + "--text-inverted": textOnAccent, + "--text-on-accent": textOnAccent, + "--accent-primary": colors.accentPrimary, + "--accent-hover": mix(dark ? "#FFFFFF" : "#000000", colors.accentPrimary, dark ? 0.14 : 0.18), + "--focus-ring-color": colors.accentPrimary, + "--focus-ring-offset": colors.surfaceBase, + "--list-item-highlight-bg": alpha(colors.accentPrimary, dark ? 0.2 : 0.1), + "--list-item-highlight-bg-solid": mix(colors.accentPrimary, colors.surfaceSecondary, dark ? 0.22 : 0.1), + "--list-item-highlight-border": alpha(colors.accentPrimary, dark ? 0.4 : 0.25), + "--attachment-chip-bg": alpha(colors.accentPrimary, 0.1), + "--attachment-chip-text": colors.accentPrimary, + "--attachment-chip-ring": alpha(colors.accentPrimary, dark ? 0.2 : 0.1), + "--badge-neutral-bg": alpha(colors.accentPrimary, dark ? 0.15 : 0.05), + "--badge-neutral-text": colors.accentPrimary, + "--env-vars-bg": alpha(colors.accentPrimary, dark ? 0.2 : 0.1), + "--env-vars-border": alpha(colors.accentPrimary, dark ? 0.3 : 0.2), + "--env-vars-text": colors.accentPrimary, + "--dropdown-highlight-bg": alpha(colors.accentPrimary, dark ? 0.2 : 0.1), + "--selection-highlight-bg": alpha(colors.accentPrimary, dark ? 0.18 : 0.12), + "--selection-highlight-strong-bg": alpha(colors.accentPrimary, dark ? 0.28 : 0.18), + "--status-success": colors.statusSuccess, + "--status-warning": colors.statusWarning, + "--status-error": colors.statusError, + "--status-success-bg": alpha(colors.statusSuccess, 0.16), + "--status-warning-bg": alpha(colors.statusWarning, 0.16), + "--status-error-bg": alpha(colors.statusError, 0.16), + "--status-success-ring": alpha(colors.statusSuccess, 0.42), + "--status-warning-ring": alpha(colors.statusWarning, 0.42), + "--status-error-ring": alpha(colors.statusError, 0.42), + "--status-ready-fg": colors.statusSuccess, + "--status-ready-bg": alpha(colors.statusSuccess, 0.16), + "--status-starting-fg": colors.statusWarning, + "--status-starting-bg": alpha(colors.statusWarning, 0.16), + "--status-error-fg": colors.statusError, + } +} + +export function applyColorScheme( + scheme: NormalizedColorScheme, + options: { target?: ColorSchemeTarget; systemDark?: boolean } = {}, +): boolean { + const dark = scheme.appearance === "dark" || (scheme.appearance === "system" && Boolean(options.systemDark)) + const target = options.target ?? (typeof document === "undefined" ? undefined : document.documentElement) + if (!target) return dark + + for (const property of APPLIED_PROPERTIES) target.style.removeProperty(property) + target.dataset.colorScheme = scheme.id + + if (scheme.appearance === "system") target.removeAttribute("data-theme") + else target.setAttribute("data-theme", dark ? "dark" : "light") + + if (scheme.colors && scheme.id !== "classic") { + for (const [property, value] of Object.entries(derivedProperties(scheme.colors, dark))) { + target.style.setProperty(property, value) + } + } + + return dark +} diff --git a/packages/ui/src/lib/theme.tsx b/packages/ui/src/lib/theme.tsx index 61ad6e4ff..3a66e6809 100644 --- a/packages/ui/src/lib/theme.tsx +++ b/packages/ui/src/lib/theme.tsx @@ -2,6 +2,11 @@ import { createContext, createEffect, createMemo, createSignal, onMount, useCont import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/styles" import CssBaseline from "@suid/material/CssBaseline" import { useConfig } from "../stores/preferences" +import { + applyColorScheme, + normalizeColorScheme, + type NormalizedColorScheme, +} from "./theme-scheme" export type ThemeMode = "system" | "light" | "dark" @@ -10,19 +15,12 @@ interface ThemeContextValue { themeMode: () => ThemeMode setThemeMode: (mode: ThemeMode) => void cycleThemeMode: () => void + colorScheme: () => NormalizedColorScheme + setColorScheme: (scheme: NormalizedColorScheme) => void } const ThemeContext = createContext() -function applyThemeMode(mode: ThemeMode) { - if (typeof document === "undefined") return - if (mode === "system") { - document.documentElement.removeAttribute("data-theme") - return - } - document.documentElement.setAttribute("data-theme", mode) -} - interface ResolvedPaletteColors { backgroundDefault: string backgroundPaper: string @@ -69,7 +67,7 @@ const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => { backgroundDefault: readCssVar("--surface-base", fallbackSet.backgroundDefault, rootStyle), backgroundPaper: readCssVar("--surface-secondary", fallbackSet.backgroundPaper, rootStyle), primary: readCssVar("--accent-primary", fallbackSet.primary, rootStyle), - primaryContrast: readCssVar("--text-inverted", fallbackSet.primaryContrast, rootStyle), + primaryContrast: readCssVar("--text-on-accent", fallbackSet.primaryContrast, rootStyle), textPrimary: readCssVar("--text-primary", fallbackSet.textPrimary, rootStyle), textSecondary: readCssVar("--text-secondary", fallbackSet.textSecondary, rootStyle), divider: readCssVar("--border-base", fallbackSet.divider, rootStyle), @@ -78,27 +76,22 @@ const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => { export function ThemeProvider(props: { children: JSX.Element }) { const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null - const { themePreference, setThemePreference } = useConfig() + const { colorSchemePreference, setColorSchemePreference } = useConfig() + const [selectedColorScheme, setSelectedColorScheme] = createSignal(colorSchemePreference()) const [isDark, setIsDarkSignal] = createSignal(true) const [themeRevision, setThemeRevision] = createSignal(0) - const themeMode = () => themePreference() as ThemeMode + const colorScheme = () => selectedColorScheme() + const themeMode = (): ThemeMode => colorScheme().appearance + let latestWrite: Promise | null = null - const resolveDarkTheme = () => { - const mode = themeMode() - if (mode === "dark") return true - if (mode === "light") return false - return mediaQuery?.matches ?? false - } + createEffect(() => { + if (latestWrite) return + setSelectedColorScheme(colorSchemePreference()) + }) const applyResolvedTheme = () => { - const mode = themeMode() - const dark = resolveDarkTheme() - if (mode === "system") { - applyThemeMode("system") - } else { - applyThemeMode(mode) - } + const dark = applyColorScheme(colorScheme(), { systemDark: mediaQuery?.matches }) setIsDarkSignal(dark) if (typeof window !== "undefined") { requestAnimationFrame(() => setThemeRevision((v) => v + 1)) @@ -124,8 +117,27 @@ export function ThemeProvider(props: { children: JSX.Element }) { } }) + const setColorScheme = (scheme: NormalizedColorScheme) => { + const normalized = normalizeColorScheme(scheme) + setSelectedColorScheme(normalized) + const write = setColorSchemePreference(normalized) + latestWrite = write + void write.then( + () => { + if (latestWrite !== write) return + latestWrite = null + setSelectedColorScheme(colorSchemePreference()) + }, + () => { + if (latestWrite !== write) return + latestWrite = null + setSelectedColorScheme(colorSchemePreference()) + }, + ) + } + const setThemeMode = (mode: ThemeMode) => { - setThemePreference(mode) + setColorScheme(normalizeColorScheme(mode === "dark" ? "classic" : mode)) } const cycleThemeMode = () => { @@ -206,7 +218,7 @@ export function ThemeProvider(props: { children: JSX.Element }) { }) return ( - + {props.children} diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index dcc612819..afd724d3a 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -7,6 +7,7 @@ import { runtimeEnv } from "./lib/runtime-env" import { I18nProvider, preloadLocaleMessages } from "./lib/i18n" import { storage } from "./lib/storage" import { initializeClientState } from "./stores/client-state" +import { applyColorScheme, normalizeColorScheme } from "./lib/theme-scheme" import "./index.css" import "@git-diff-view/solid/styles/diff-view-pure.css" @@ -27,25 +28,21 @@ async function bootstrap() { await initializeClientState() if (typeof document !== "undefined") { - // renderer/index.html currently seeds a dark theme to avoid a white flash. - // Reset to CSS defaults immediately so the first render matches system - // (and then refine once persisted config loads). - document.documentElement.removeAttribute("data-theme") - try { const uiConfig = await storage.loadConfigOwner("ui") const theme = (uiConfig as any)?.theme + const colorScheme = (uiConfig as any)?.colorScheme const locale = typeof (uiConfig as any)?.settings?.locale === "string" ? (uiConfig as any).settings.locale : undefined - if (theme === "light" || theme === "dark") { - document.documentElement.setAttribute("data-theme", theme) - } else { - document.documentElement.removeAttribute("data-theme") - } + applyColorScheme(normalizeColorScheme(colorScheme, theme), { + systemDark: window.matchMedia("(prefers-color-scheme: dark)").matches, + }) await preloadLocaleMessages(locale) } catch { - // If config fails to load, fall back to CSS defaults. + applyColorScheme(normalizeColorScheme("system"), { + systemDark: window.matchMedia("(prefers-color-scheme: dark)").matches, + }) await preloadLocaleMessages() } } diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 0ad09dd1d..429667de6 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -10,6 +10,10 @@ import { import { getLogger } from "../lib/logger" import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech" import { buildSpeechPatch } from "../lib/speech-patch" +import { + normalizeColorScheme, + type NormalizedColorScheme, +} from "../lib/theme-scheme" import { normalizeModelVisibilityPreference, normalizeModelVisibilityPreferences, @@ -137,6 +141,8 @@ export type ThemePreference = "light" | "dark" | "system" interface UiConfigBucket { theme?: ThemePreference + colorScheme?: unknown + customColorScheme?: unknown settings?: Partial } @@ -561,6 +567,15 @@ const [isLoaded, setIsLoaded] = createSignal(false) const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) const themePreference = createMemo(() => uiConfigBucket().theme ?? "system") +const colorSchemePreference = createMemo(() => normalizeColorScheme(uiConfigBucket().colorScheme, themePreference())) +const customColorSchemePreference = createMemo(() => { + const bucket = uiConfigBucket() + const custom = bucket.customColorScheme ?? (colorSchemePreference().id === "custom" ? bucket.colorScheme : undefined) + const value = typeof custom === "object" && custom !== null && !Array.isArray(custom) + ? { ...(custom as Record), id: "custom" } + : { id: "custom" } + return normalizeColorScheme(value) +}) const serverSettings = createMemo(() => normalizeServerConfig(serverConfigBucket())) const uiState = createMemo(() => normalizeUiState(uiStateBucket())) @@ -689,8 +704,25 @@ async function setProviderModelVisibility(providerId: string, preference: ModelV } function setThemePreference(preference: ThemePreference): void { - if (themePreference() === preference) return - void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error)) + void setColorSchemePreference(normalizeColorScheme(preference === "dark" ? "classic" : preference)) +} + +let colorSchemeWriteQueue = Promise.resolve() + +function setColorSchemePreference(preference: NormalizedColorScheme): Promise { + const normalized = normalizeColorScheme(preference) + const legacyTheme: ThemePreference = normalized.appearance === "system" ? "system" : normalized.appearance + const patch = { + theme: legacyTheme, + colorScheme: normalized, + ...(normalized.id === "custom" ? { customColorScheme: normalized } : {}), + } + const write = colorSchemeWriteQueue.then(() => patchConfigOwner("ui", patch)) + colorSchemeWriteQueue = write.then(() => undefined, () => undefined) + return write.then(() => undefined).catch((error) => { + log.error("Failed to set color scheme", error) + throw error + }) } async function setListeningMode(mode: ListeningMode): Promise { @@ -1007,6 +1039,9 @@ interface ConfigContextValue { providerModelVisibilitySaveFailed: typeof providerModelVisibilitySaveFailed themePreference: typeof themePreference setThemePreference: typeof setThemePreference + colorSchemePreference: typeof colorSchemePreference + customColorSchemePreference: typeof customColorSchemePreference + setColorSchemePreference: typeof setColorSchemePreference // server-owned stable config serverSettings: typeof serverSettings @@ -1071,6 +1106,9 @@ const configContextValue: ConfigContextValue = { providerModelVisibilitySaveFailed, themePreference, setThemePreference, + colorSchemePreference, + customColorSchemePreference, + setColorSchemePreference, serverSettings, setListeningMode, updateEnvironmentVariables, @@ -1163,6 +1201,9 @@ export { opencodeBinaries, themePreference, setThemePreference, + colorSchemePreference, + customColorSchemePreference, + setColorSchemePreference, updatePreferences, setProviderModelVisibility, getProviderModelVisibilityPreference, diff --git a/packages/ui/src/styles/components/theme-scheme-settings.css b/packages/ui/src/styles/components/theme-scheme-settings.css new file mode 100644 index 000000000..de3477012 --- /dev/null +++ b/packages/ui/src/styles/components/theme-scheme-settings.css @@ -0,0 +1,144 @@ +.theme-scheme-grid { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } +.theme-scheme-card { + position: relative; + flex-direction: column; + align-items: stretch; + min-height: 8.5rem; + transition: none; +} +.theme-scheme-card[data-selected="true"] { + box-shadow: none; + transform: none; +} +.theme-scheme-swatches { + display: grid; + grid-template-columns: repeat(4, 1fr); + width: 100%; + height: 2rem; + border: 1px solid var(--border-base); +} +.theme-scheme-swatches > span + span { + border-inline-start: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent); +} +.theme-scheme-card-check { + position: absolute; + inset-block-start: 0.4rem; + inset-inline-end: 0.4rem; + display: grid; + place-items: center; + width: 1.5rem; + height: 1.5rem; + background: var(--surface-base); + border: 1px solid var(--accent-primary); +} +.theme-scheme-card-check svg { + width: 0.875rem; + height: 0.875rem; +} +.theme-scheme-editor { + display: grid; + gap: 1rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-base); +} +.theme-scheme-editor fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.theme-scheme-editor legend { + margin-bottom: 0.625rem; + color: var(--text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); +} +.theme-scheme-appearance-options { + display: flex; + gap: 0.5rem; +} +.theme-scheme-appearance-option { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + min-width: 6rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: var(--surface-base); + color: var(--text-primary); + cursor: pointer; +} +.theme-scheme-appearance-option[data-selected="true"] { + border-color: var(--accent-primary); + background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base)); +} +.theme-scheme-appearance-check { + width: 0.875rem; + height: 0.875rem; + visibility: hidden; +} +.theme-scheme-appearance-option[data-selected="true"] .theme-scheme-appearance-check { visibility: visible; } +.theme-scheme-appearance-option:focus-visible, +.theme-scheme-color-controls input:focus-visible, +.theme-scheme-actions .selector-button:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; + box-shadow: none; +} +.theme-scheme-color-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.75rem; +} +.theme-scheme-color-field { + display: grid; + gap: 0.35rem; + color: var(--text-secondary); + font-size: var(--font-size-xs); +} +.theme-scheme-color-controls { + display: grid; + grid-template-columns: 3rem 1fr; + min-width: 0; +} +.theme-scheme-color-controls input { + min-width: 0; + height: 2.5rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: var(--surface-base); + color: var(--text-primary); +} +.theme-scheme-color-controls input[type="color"] { + width: 3rem; + padding: 0.2rem; + cursor: pointer; +} +.theme-scheme-color-controls input[type="text"] { + padding: 0 0.65rem; + border-inline-start: 0; + font-family: var(--font-family-mono); +} +.theme-scheme-color-controls input[aria-invalid="true"] { border-color: var(--status-error); } +.theme-scheme-warning { + margin: 0; + color: var(--status-error); + font-size: var(--font-size-sm); +} +.theme-scheme-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} +.theme-scheme-actions .selector-button { + width: auto; + border-radius: 0; + transition: none; +} +@media (max-width: 640px) { + .theme-scheme-grid, + .theme-scheme-color-grid { grid-template-columns: 1fr; } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 0c1d60825..099bd8f35 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -13,6 +13,7 @@ @import "./components/form-request-options.css"; @import "./components/toast-history.css"; @import "./components/settings-screen.css"; +@import "./components/theme-scheme-settings.css"; @import "./components/settings-info.css"; @import "./components/config-files-settings.css"; @import "./components/provider-auth.css";