From 17882cb6dd79b24b3a494620738b03dd54140481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 29 Aug 2026 14:24:22 +0200 Subject: [PATCH] feat(ui): add customizable color schemes Introduce independent System, Light, CodeNomad Classic, Basalt, Fjord, Lichen, Velvet, Ember, and Custom schemes backed by the existing server-owned UI preferences. Apply the selected palette before rendering and propagate ten validated core colors through semantic CSS variables and SUID. Preserve the exact current dark appearance through CodeNomad Classic, keep a custom draft across preset switches, and serialize rapid persistence writes while retaining instant local feedback. Add a square, responsive settings selector and ten-color custom editor in all supported locales. Reject malformed colors and palettes that fail text, accent, or status contrast requirements. Validated with the UI typecheck, production UI build, focused node tests, diff checks, and browser renders for System, Fjord, Classic, and Custom states. --- packages/ui/src/components/prompt-input.tsx | 2 +- .../settings/general-settings-section.tsx | 41 +- .../settings/theme-scheme-settings.tsx | 218 +++++++++ .../ui/src/lib/i18n/messages/de/settings.ts | 40 ++ .../ui/src/lib/i18n/messages/en/settings.ts | 40 ++ .../ui/src/lib/i18n/messages/es/settings.ts | 40 ++ .../ui/src/lib/i18n/messages/fr/settings.ts | 40 ++ .../ui/src/lib/i18n/messages/he/settings.ts | 40 ++ .../ui/src/lib/i18n/messages/ja/settings.ts | 40 ++ .../ui/src/lib/i18n/messages/ne/settings.ts | 40 ++ .../ui/src/lib/i18n/messages/ru/settings.ts | 40 ++ .../ui/src/lib/i18n/messages/tr/settings.ts | 40 ++ .../src/lib/i18n/messages/zh-Hans/settings.ts | 40 ++ packages/ui/src/lib/theme-scheme.test.ts | 164 +++++++ packages/ui/src/lib/theme-scheme.ts | 415 ++++++++++++++++++ packages/ui/src/lib/theme.tsx | 66 +-- packages/ui/src/main.tsx | 19 +- packages/ui/src/stores/preferences.tsx | 45 +- .../components/theme-scheme-settings.css | 144 ++++++ packages/ui/src/styles/controls.css | 1 + 20 files changed, 1435 insertions(+), 80 deletions(-) create mode 100644 packages/ui/src/components/settings/theme-scheme-settings.tsx create mode 100644 packages/ui/src/lib/theme-scheme.test.ts create mode 100644 packages/ui/src/lib/theme-scheme.ts create mode 100644 packages/ui/src/styles/components/theme-scheme-settings.css 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";