fix(web): clarify settings heading hierarchy (#1177)

This commit is contained in:
Ananovo
2026-07-27 07:38:50 +08:00
committed by GitHub
parent 500407c6b1
commit 84323496c6
13 changed files with 45 additions and 39 deletions
@@ -132,9 +132,9 @@ export function ComposerToolbarLayoutControl() {
const selectedIndex = selectedItem ? selectedItems.indexOf(selectedItem) : -1
return (
<div className="border-t border-[var(--app-divider)] px-3 py-3">
<div className="mb-3">
<h3 className="text-[var(--app-fg)]">{t('settings.chat.composerToolbar.title')}</h3>
<div className="border-t border-[var(--app-divider)] py-3">
<div className="mb-3 px-3">
<h3 className="text-sm font-medium text-[var(--app-fg)]">{t('settings.chat.composerToolbar.title')}</h3>
<p className="mt-0.5 text-xs text-[var(--app-hint)]">{t('settings.chat.composerToolbar.description')}</p>
</div>
<SettingsChoiceGroup
@@ -152,13 +152,13 @@ export function ComposerToolbarLayoutControl() {
<div className="mt-3 flex items-center justify-between gap-3 px-3">
<div>
<h4 className="text-[var(--app-fg)]">{t('settings.chat.composerToolbar.order')}</h4>
<h4 className="text-sm font-medium text-[var(--app-fg)]">{t('settings.chat.composerToolbar.order')}</h4>
<p className="mt-0.5 text-xs text-[var(--app-hint)]">{t('settings.chat.composerToolbar.previewHint')}</p>
</div>
<button type="button" onClick={resetLayout} className="shrink-0 text-sm text-[var(--app-link)] hover:underline">{t('settings.chat.composerToolbar.reset')}</button>
</div>
<div className="mt-2 rounded-[24px] bg-[var(--app-composer-bg,var(--app-subtle-bg))] px-3 pb-2 pt-3 shadow-sm ring-1 ring-[var(--app-border)]">
<div className="mx-3 mt-2 rounded-[24px] bg-[var(--app-composer-bg,var(--app-subtle-bg))] px-3 pb-2 pt-3 shadow-sm ring-1 ring-[var(--app-border)]">
<div className="mb-2 px-1 text-sm text-[var(--app-hint)]">{t('misc.typeAMessage')}</div>
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1 overflow-x-auto">
@@ -174,7 +174,7 @@ export function ComposerToolbarLayoutControl() {
</div>
</div>
{selectedItem && selectedIndex >= 0 ? (
<div className="mt-2 flex items-center justify-between gap-2 rounded-lg bg-[var(--app-subtle-bg)] px-3 py-2 text-sm">
<div className="mx-3 mt-2 flex items-center justify-between gap-2 rounded-lg bg-[var(--app-subtle-bg)] px-3 py-2 text-sm">
<span className="min-w-0 truncate text-[var(--app-hint)]">{t(ITEM_LABEL_KEYS[selectedItem])}</span>
<span className="flex shrink-0 items-center gap-1">
<button
@@ -1,5 +1,9 @@
import type { ReactNode } from 'react'
export function SettingsFieldLabel(props: { children: ReactNode; hidden?: boolean }) {
return props.hidden ? null : <div className="mb-2 text-sm font-medium text-[var(--app-fg)]">{props.children}</div>
}
export function ChevronRightIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={props.className} aria-hidden="true">
@@ -16,12 +20,11 @@ export function CheckIcon(props: { className?: string }) {
)
}
export function SettingsPageContent(props: { title: string; description?: string; children: ReactNode }) {
export function SettingsPageContent(props: { description?: string; children: ReactNode }) {
return (
<div className="mx-auto w-full max-w-[720px] space-y-5 px-3 py-4 lg:px-6 lg:py-6">
<div>
<h1 tabIndex={-1} className="hidden text-xl font-semibold text-[var(--app-fg)] outline-none lg:block">{props.title}</h1>
{props.description ? <p className="text-sm text-[var(--app-hint)] lg:mt-1">{props.description}</p> : null}
{props.description ? <p className="text-sm text-[var(--app-hint)]">{props.description}</p> : null}
</div>
{props.children}
</div>
@@ -31,8 +34,8 @@ export function SettingsPageContent(props: { title: string; description?: string
export function SettingsSection(props: { title?: string; description?: string; children: ReactNode }) {
return (
<section>
{props.title ? <h2 className="mb-1.5 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--app-hint)]">{props.title}</h2> : null}
{props.description ? <p className="mb-2 px-1 text-sm text-[var(--app-hint)]">{props.description}</p> : null}
{props.title ? <h2 className="mb-2 text-base font-semibold text-[var(--app-fg)]">{props.title}</h2> : null}
{props.description ? <p className="mb-2 text-sm text-[var(--app-hint)]">{props.description}</p> : null}
<div className="overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] shadow-sm divide-y divide-[var(--app-divider)]">
{props.children}
</div>
@@ -44,7 +47,7 @@ export function SettingsRow(props: { label: string; description?: string; traili
return (
<div className="flex min-h-12 items-center justify-between gap-3 px-3 py-3">
<div className="min-w-0">
<div className="text-[var(--app-fg)]">{props.label}</div>
<div className="text-sm font-medium text-[var(--app-fg)]">{props.label}</div>
{props.description ? <div className="mt-0.5 text-xs leading-snug text-[var(--app-hint)]">{props.description}</div> : null}
{props.children}
</div>
@@ -67,6 +70,7 @@ export function SettingsSwitch(props: { label: string; description?: string; che
export function SettingsChoiceGroup<T extends string | number>(props: {
label: string
hideLabel?: boolean
value: T
options: ReadonlyArray<{ value: T; label: string; description?: string }>
onChange: (value: T) => void
@@ -75,7 +79,7 @@ export function SettingsChoiceGroup<T extends string | number>(props: {
const columns = props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2'
return (
<div className="px-3 py-3">
<div className="mb-2 text-[var(--app-fg)]">{props.label}</div>
<SettingsFieldLabel hidden={props.hideLabel}>{props.label}</SettingsFieldLabel>
<div role="radiogroup" aria-label={props.label} className={`grid ${columns} gap-2`}>
{props.options.map((option) => {
const selected = props.value === option.value
@@ -104,7 +108,7 @@ export function SettingsLinkRow(props: { label: string; value?: string; descript
return (
<button type="button" onClick={props.onClick} className="flex min-h-12 w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]">
<span className="min-w-0 flex-1">
<span className="block text-[var(--app-fg)]">{props.label}</span>
<span className="block text-sm font-medium text-[var(--app-fg)]">{props.label}</span>
{props.description ? <span className="mt-0.5 block text-xs text-[var(--app-hint)]">{props.description}</span> : null}
</span>
{props.value ? <span className="max-w-[45%] truncate text-sm text-[var(--app-hint)]">{props.value}</span> : null}
@@ -69,7 +69,7 @@ export function VoiceRespondsControls(props: {
return (
<div className="border-t border-[var(--app-divider)] px-3 py-3">
<p className="mb-2 text-[var(--app-fg)]">{props.t('settings.voice.responseLength.label')}</p>
<p className="mb-2 text-sm font-medium text-[var(--app-fg)]">{props.t('settings.voice.responseLength.label')}</p>
<div className="flex gap-2">
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
<button key={opt} type="button" onClick={() => setResponseLength(opt)}
@@ -119,7 +119,7 @@ export function VoicePersonaControls(props: {
<button type="button" onClick={() => setIdentityOpen((v) => !v)}
className="flex w-full items-center justify-between border-t border-[var(--app-divider)] px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={identityOpen}>
<span className="text-[var(--app-fg)]">{props.t('settings.voice.identity.title')}</span>
<span className="text-sm font-medium text-[var(--app-fg)]">{props.t('settings.voice.identity.title')}</span>
<ChevronDownIcon className={`shrink-0 transition-transform ${identityOpen ? 'rotate-180' : ''}`} />
</button>
{identityOpen && (
@@ -145,7 +145,7 @@ export function VoicePersonaControls(props: {
<button type="button" onClick={() => setCharacterOpen((v) => !v)}
className="flex w-full items-center justify-between border-t border-[var(--app-divider)] px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={characterOpen}>
<span className="text-[var(--app-fg)]">{props.t('settings.voice.character.promptTitle')}</span>
<span className="text-sm font-medium text-[var(--app-fg)]">{props.t('settings.voice.character.promptTitle')}</span>
<ChevronDownIcon className={`shrink-0 transition-transform ${characterOpen ? 'rotate-180' : ''}`} />
</button>
{characterOpen && (
@@ -178,7 +178,7 @@ export function VoicePersonaControls(props: {
<button type="button" onClick={() => setDeliveryOpen((v) => !v)}
className="flex w-full items-center justify-between border-t border-[var(--app-divider)] px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={deliveryOpen}>
<span className="flex items-center gap-2 text-[var(--app-fg)]">
<span className="flex items-center gap-2 text-sm font-medium text-[var(--app-fg)]">
{props.t('settings.voice.character.title')}
{!usingDefaults && (
<span className="rounded-full bg-[var(--app-link)]/15 px-2 py-0.5 text-[10px] text-[var(--app-link)]">
@@ -239,7 +239,7 @@ export function VoiceSoundsControls(props: {
<button type="button" onClick={() => setTuningOpen((v) => !v)}
className="flex w-full items-center justify-between border-t border-[var(--app-divider)] px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={tuningOpen}>
<span className="text-[var(--app-fg)]">{props.t('settings.voice.tuning.title')}</span>
<span className="text-sm font-medium text-[var(--app-fg)]">{props.t('settings.voice.tuning.title')}</span>
<ChevronDownIcon className={`shrink-0 transition-transform ${tuningOpen ? 'rotate-180' : ''}`} />
</button>
{tuningOpen && (
@@ -341,7 +341,7 @@ export function VoiceDiagnosticsControls(props: {
<button type="button" onClick={() => setFixturesOpen((v) => !v)}
className="flex w-full items-center justify-between border-t border-[var(--app-divider)] px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={fixturesOpen}>
<span className="text-[var(--app-fg)]">{props.t('settings.voice.fixtures.title')}</span>
<span className="text-sm font-medium text-[var(--app-fg)]">{props.t('settings.voice.fixtures.title')}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className={`shrink-0 transition-transform ${fixturesOpen ? 'rotate-180' : ''}`}>
+1
View File
@@ -606,6 +606,7 @@ export default {
'settings.display.typography': 'Typography',
'settings.display.sessions': 'Session list',
'settings.display.appearance': 'Appearance',
'settings.display.appearanceMode': 'Appearance mode',
'settings.display.appearance.system': 'Follow System',
'settings.display.appearance.dark': 'Dark',
'settings.display.appearance.oled': 'OLED Black',
+1
View File
@@ -610,6 +610,7 @@ export default {
'settings.display.typography': '字体',
'settings.display.sessions': '会话列表',
'settings.display.appearance': '外观',
'settings.display.appearanceMode': '显示模式',
'settings.display.appearance.system': '跟随系统',
'settings.display.appearance.dark': '深色',
'settings.display.appearance.oled': 'OLED 纯黑',
+1 -1
View File
@@ -5,7 +5,7 @@ import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/
export default function SettingsAboutPage() {
const { t } = useTranslation()
return (
<SettingsPageContent title={t('settings.about.title')} description={t('settings.about.description')}>
<SettingsPageContent description={t('settings.about.description')}>
<SettingsSection>
<SettingsRow label={t('settings.about.website')} trailing={
<a href="https://hapi.run" target="_blank" rel="noopener noreferrer" className="text-[var(--app-link)] hover:underline">hapi.run</a>
+3 -3
View File
@@ -10,7 +10,7 @@ import {
type ChatSurfaceColorPreference,
type ChatSurfaceColorPreset,
} from '@/hooks/useChatSurfaceColors'
import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives'
import { SettingsChoiceGroup, SettingsFieldLabel, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives'
import { ComposerToolbarLayoutControl } from '@/components/settings/ComposerToolbarLayoutControl'
function ChatSurfaceColorControl(props: {
@@ -23,7 +23,7 @@ function ChatSurfaceColorControl(props: {
const pickerValue = getChatSurfaceColorPickerValue(props.preference)
return (
<div className="px-3 py-3">
<div className="mb-2 text-[var(--app-fg)]">{props.label}</div>
<SettingsFieldLabel>{props.label}</SettingsFieldLabel>
<div role="radiogroup" aria-label={props.label} className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{getChatSurfaceColorPresetOptions().map((option) => {
const preference = toPresetChatSurfaceColorPreference(option.value)
@@ -50,7 +50,7 @@ export default function SettingsChatPage() {
const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode()
const { toolGroupBackground, userMessageBackground, setToolGroupBackground, setUserMessageBackground } = useChatSurfaceColors()
return (
<SettingsPageContent title={t('settings.chat.title')} description={t('settings.chat.description')}>
<SettingsPageContent description={t('settings.chat.description')}>
<SettingsSection title={t('settings.chat.input')}>
<SettingsChoiceGroup
label={t('settings.chat.enterBehavior')}
+5 -5
View File
@@ -8,7 +8,7 @@ import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hoo
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import { MAX_SESSION_PREVIEW_LIMIT, MIN_SESSION_PREVIEW_LIMIT, normalizeSessionPreviewLimit, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors'
import { SettingsChoiceGroup, SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives'
import { SettingsChoiceGroup, SettingsFieldLabel, SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives'
function MinusIcon() {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true"><path d="M5 12h14" /></svg>
@@ -24,7 +24,7 @@ function ColorThemePicker() {
return (
<div className="px-3 py-3">
<div className="mb-2 text-[var(--app-fg)]">{t('settings.display.colorTheme')}</div>
<SettingsFieldLabel>{t('settings.display.colorTheme')}</SettingsFieldLabel>
<div role="radiogroup" aria-label={t('settings.display.colorTheme')} className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{getColorThemeOptions().map((option) => (
<ColorThemeOption
@@ -105,7 +105,7 @@ function ThemeColorControls() {
<details open={hasAnyCustom} className="group">
<summary className="flex cursor-pointer list-none items-center justify-between px-3 py-3 text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]">
<span>
<span className="block">{t('settings.display.themeColors.title')}</span>
<span className="block text-sm font-medium">{t('settings.display.themeColors.title')}</span>
<span className="mt-0.5 block text-xs text-[var(--app-hint)]">{t('settings.display.themeColors.description')}</span>
</span>
<span className="ml-3 text-sm text-[var(--app-hint)]">{hasAnyCustom ? t('settings.voice.advanced.customizedBadge') : t('settings.display.themeColors.expand')}</span>
@@ -137,10 +137,10 @@ export default function SettingsDisplayPage() {
const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly()
return (
<SettingsPageContent title={t('settings.display.title')} description={t('settings.display.description')}>
<SettingsPageContent description={t('settings.display.description')}>
<SettingsSection title={t('settings.display.appearance')}>
<SettingsChoiceGroup
label={t('settings.display.appearance')}
label={t('settings.display.appearanceMode')}
value={appearance}
columns={4}
options={getAppearanceOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
+3 -3
View File
@@ -9,9 +9,9 @@ const locales: ReadonlyArray<{ value: Locale; label: string }> = [
export default function SettingsGeneralPage() {
const { t, locale, setLocale } = useTranslation()
return (
<SettingsPageContent title={t('settings.general.title')} description={t('settings.general.description')}>
<SettingsSection>
<SettingsChoiceGroup label={t('settings.language.label')} value={locale} options={locales} onChange={setLocale} />
<SettingsPageContent description={t('settings.general.description')}>
<SettingsSection title={t('settings.language.label')}>
<SettingsChoiceGroup hideLabel label={t('settings.language.label')} value={locale} options={locales} onChange={setLocale} />
</SettingsSection>
</SettingsPageContent>
)
+3 -3
View File
@@ -36,8 +36,8 @@ export default function SettingsLayout() {
<BackIcon />
</button>
<div className="min-w-0 flex-1 font-semibold">
<h1 className="truncate lg:hidden">{mobileTitle}</h1>
<span className="hidden lg:inline">{t('settings.title')}</span>
<h1 className="truncate text-lg lg:hidden">{mobileTitle}</h1>
<h1 className="hidden text-lg lg:block">{t('settings.title')}</h1>
</div>
</div>
</header>
@@ -47,7 +47,7 @@ export default function SettingsLayout() {
<aside className="hidden w-56 shrink-0 border-r border-[var(--app-border)] lg:block">
<SettingsNav activeId={category?.id ?? 'display'} />
</aside>
<main className="app-scroll-y min-w-0 flex-1">
<main className="app-scroll-y min-w-0 flex-1 lg:[scrollbar-gutter:stable_both-edges]">
<Outlet />
</main>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@ export default function SettingsVoiceAdvancedPage() {
const { t } = useTranslation()
const { backend } = useVoiceSettings()
return (
<SettingsPageContent title={t('settings.voice.advanced.title')} description={t('settings.voice.advanced.hint')}>
<SettingsPageContent description={t('settings.voice.advanced.hint')}>
<SettingsSection title={t('settings.voice.persona.title')}>
<VoicePersonaControls t={t} voiceBackend={backend} />
</SettingsSection>
+1 -1
View File
@@ -14,7 +14,7 @@ export default function SettingsVoiceVoicesPage() {
const { t } = useTranslation()
const voice = useVoiceSettings()
return (
<SettingsPageContent title={t('settings.voice.voice')} description={t('settings.voice.voices.description')}>
<SettingsPageContent description={t('settings.voice.voices.description')}>
<SettingsSection>
<div role="radiogroup" aria-label={t('settings.voice.voice')} className="divide-y divide-[var(--app-divider)]">
<button type="button" role="radio" aria-checked={voice.voiceId === null} onClick={() => voice.setVoice(null)} className={`flex min-h-12 w-full items-center justify-between px-3 py-3 text-left ${voice.voiceId === null ? 'bg-[var(--app-subtle-bg)] text-[var(--app-link)]' : 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}>
+2 -2
View File
@@ -22,7 +22,7 @@ export default function SettingsVoicePage() {
}
return (
<SettingsPageContent title={t('settings.voice.title')} description={t('settings.voice.description')}>
<SettingsPageContent description={t('settings.voice.description')}>
<SettingsSection title={t('settings.voice.connection.title')} description={t('settings.voice.group.hint')}>
{voice.configuredBackends.length > 1 && voice.backend ? (
<SettingsChoiceGroup
@@ -33,7 +33,7 @@ export default function SettingsVoicePage() {
/>
) : null}
<label className="flex min-h-12 items-center justify-between gap-3 px-3 py-3">
<span className="text-[var(--app-fg)]">{t('settings.voice.language')}</span>
<span className="text-sm font-medium text-[var(--app-fg)]">{t('settings.voice.language')}</span>
<select
value={voice.voiceLanguage ?? ''}
onChange={(event) => {