feat(web): OLED Black theme + per-appearance custom colors (#937)

* test: reproduce issue #866

* feat(web): OLED Black theme + per-appearance custom colors (closes #866)

Add an explicit OLED Black appearance (true #000 canvas, border-based
elevation) alongside system/dark/light, and a curated "key color"
customizer. Each key color (background, surface, text, hint, accent,
border, user bubble) cascades to its --app-* tokens and is stored per
appearance so a color tuned for light never leaks onto pure black.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
SSU-WEI HUANG
2026-06-18 10:15:34 +08:00
committed by GitHub
co-authored by HAPI
parent ce67823fc3
commit dfb1805fd6
11 changed files with 696 additions and 13 deletions
+61
View File
@@ -35,6 +35,7 @@ import {
type ChatSurfaceColorPreset,
} from '@/hooks/useChatSurfaceColors'
import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme'
import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors'
import { PROTOCOL_VERSION } from '@hapi/protocol'
import { VoiceRespondsControls, VoiceSoundsControls, VoicePersonaControls, VoiceDiagnosticsControls } from '@/components/settings/VoiceAdvancedControls'
@@ -308,6 +309,65 @@ function ChatSurfaceColorControl(props: {
)
}
function ThemeColorControl(props: { t: (key: string) => string }) {
const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors()
return (
<div className="border-t border-[var(--app-divider)] px-3 py-3">
<div className="mb-1 flex items-center justify-between gap-3">
<span className="text-[var(--app-fg)]">{props.t('settings.display.themeColors.title')}</span>
{hasAnyCustom && (
<button
type="button"
onClick={resetAll}
className="text-sm text-[var(--app-link)] transition-colors hover:underline"
>
{props.t('settings.display.themeColors.resetAll')}
</button>
)}
</div>
<div className="mb-3 text-sm text-[var(--app-hint)]">{props.t('settings.display.themeColors.description')}</div>
<div className="flex flex-col gap-2">
{keys.map((key) => {
const value = getPickerValue(key.id)
const customized = isCustomized(key.id)
return (
<div key={key.id} className="flex items-center justify-between gap-3">
<span className="text-sm text-[var(--app-fg)]">{props.t(key.labelKey)}</span>
<div className="flex items-center gap-2">
{customized && (
<button
type="button"
onClick={() => resetColor(key.id as ThemeColorKeyId)}
className="text-xs text-[var(--app-hint)] transition-colors hover:text-[var(--app-link)]"
>
{props.t('settings.display.themeColors.reset')}
</button>
)}
<label
className={`inline-flex items-center rounded-xl border px-2 py-1 transition-colors ${
customized
? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'border-[var(--app-border)] bg-[var(--app-bg)]'
}`}
>
<input
aria-label={props.t(key.labelKey)}
type="color"
value={value}
onChange={(event) => setColor(key.id as ThemeColorKeyId, event.target.value)}
className="h-8 w-11 cursor-pointer appearance-none border-0 bg-transparent p-0"
/>
</label>
</div>
</div>
)
})}
</div>
</div>
)
}
export default function SettingsPage() {
const { t, locale, setLocale } = useTranslation()
const { api } = useAppContext()
@@ -733,6 +793,7 @@ export default function SettingsPage() {
</div>
)}
</div>
<ThemeColorControl t={t} />
<div ref={fontContainerRef} className="relative">
<button
type="button"