Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/ui/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
41 changes: 2 additions & 39 deletions packages/ui/src/components/settings/general-settings-section.tsx
Original file line number Diff line number Diff line change
@@ -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<BehaviorSetting[]>(() => [
Expand All @@ -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 (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.appearance.theme.title")}</h3>
<p class="settings-card-subtitle">{t("settings.appearance.theme.subtitle")}</p>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-choice-grid">
{themeModeOptions.map((option) => {
const Icon = option.icon
return (
<button type="button" class="settings-choice" data-selected={themeMode() === option.value ? "true" : "false"} onClick={() => setThemeMode(option.value)}>
<span class="settings-choice-icon-wrap"><Icon class="settings-choice-icon" /></span>
<span class="settings-choice-copy">
<span class="settings-choice-label">{modeLabel(option.value)}</span>
<span class="settings-choice-description">{t(`settings.appearance.theme.option.${option.value}`)}</span>
</span>
<span class="settings-choice-check" aria-hidden="true"><Check class="w-4 h-4" /></span>
</button>
)
})}
</div>
</div>
<ThemeSchemeSettings />

<div class="settings-card">
<div class="settings-card-header">
Expand Down
218 changes: 218 additions & 0 deletions packages/ui/src/components/settings/theme-scheme-settings.tsx
Original file line number Diff line number Diff line change
@@ -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<ColorSchemeColors>({ ...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<ColorSchemeColors>) => {
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 (
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.appearance.colorScheme.title")}</h3>
<p class="settings-card-subtitle">{t("settings.appearance.colorScheme.subtitle")}</p>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>

<div class="settings-choice-grid theme-scheme-grid">
<For each={BUILT_IN_COLOR_SCHEMES}>{(scheme) => (
<button
type="button"
class="settings-choice theme-scheme-card"
data-selected={colorScheme().id === scheme.id ? "true" : "false"}
aria-pressed={colorScheme().id === scheme.id}
onClick={() => selectScheme(scheme.id)}
>
<span class="theme-scheme-swatches" aria-hidden="true">
<For each={swatches(scheme.id, scheme.colors)}>{(color) => <span style={{ background: color }} />}</For>
</span>
<span class="settings-choice-copy">
<span class="settings-choice-label">{t(scheme.labelKey)}</span>
<span class="settings-choice-description">{t(scheme.descriptionKey)}</span>
</span>
<span class="settings-choice-check theme-scheme-card-check" aria-hidden="true"><Check /></span>
</button>
)}</For>
</div>

<Show when={colorScheme().id === "custom"}>
<div class="theme-scheme-editor">
<fieldset class="theme-scheme-appearance">
<legend>{t("settings.appearance.colorScheme.custom.appearance")}</legend>
<div class="theme-scheme-appearance-options">
<For each={["light", "dark"] as const}>{(option) => (
<button
type="button"
class="theme-scheme-appearance-option"
data-selected={appearance() === option ? "true" : "false"}
aria-pressed={appearance() === option}
onClick={() => {
setDirty(true)
setAppearance(option)
}}
>
{t(`settings.appearance.colorScheme.custom.appearance.${option}`)}
<Check class="theme-scheme-appearance-check" aria-hidden="true" />
</button>
)}</For>
</div>
</fieldset>

<fieldset class="theme-scheme-colors">
<legend>{t("settings.appearance.colorScheme.custom.colors")}</legend>
<div class="theme-scheme-color-grid">
<For each={COLOR_FIELDS}>{(field) => {
const label = () => t(field.labelKey)
const pickerValue = () => isCanonicalHexColor(draftColors()[field.key]) ? draftColors()[field.key] : DEFAULT_CUSTOM_COLORS[field.key]
return (
<div class="theme-scheme-color-field">
<span>{label()}</span>
<span class="theme-scheme-color-controls">
<input
type="color"
value={pickerValue()}
aria-label={t("settings.appearance.colorScheme.custom.pickerAriaLabel", { name: label() })}
onInput={(event) => updateColor(field.key, event.currentTarget.value.toUpperCase())}
/>
<input
type="text"
value={draftColors()[field.key]}
aria-label={t("settings.appearance.colorScheme.custom.valueAriaLabel", { name: label() })}
aria-invalid={!isCanonicalHexColor(draftColors()[field.key])}
dir="ltr"
spellcheck={false}
onInput={(event) => updateColor(field.key, event.currentTarget.value.toUpperCase())}
/>
</span>
</div>
)
}}</For>
</div>
</fieldset>

<Show when={!validDraft()}>
<p class="theme-scheme-warning" role="alert">
{t(validFormat() ? "settings.appearance.colorScheme.custom.warning.contrast" : "settings.appearance.colorScheme.custom.warning.format")}
</p>
</Show>
<div class="theme-scheme-actions">
<button type="button" class="selector-button selector-button-secondary" onClick={resetCustom}>
{t("settings.appearance.colorScheme.custom.reset")}
</button>
<button type="button" class="selector-button selector-button-primary" disabled={!validDraft()} onClick={saveCustom}>
{t("settings.appearance.colorScheme.custom.save")}
</button>
</div>
</div>
</Show>
</div>
)
}
40 changes: 40 additions & 0 deletions packages/ui/src/lib/i18n/messages/de/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading