Refine settings IA with tabbed sections

This commit is contained in:
ejclaw
2026-05-04 01:37:56 +09:00
parent b8d8f10332
commit 1576bb67df
6 changed files with 246 additions and 150 deletions

View File

@@ -113,7 +113,12 @@ export function MoaSettingsPanel() {
Object.values(apiKeys).some((value) => value.trim()));
return (
<section className="settings-section" id="settings-moa">
<section
aria-labelledby="settings-moa-tab"
className="settings-section"
id="settings-moa"
role="tabpanel"
>
<header className="settings-section-head">
<span>Arbiter references</span>
<h3>MoA </h3>

View File

@@ -34,21 +34,20 @@ describe('SettingsPanel', () => {
it('renders model, MoA, fast mode, and account controls', () => {
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('모델');
expect(html).toContain('MoA 참조 모델');
expect(html).toContain('패스트 모드');
expect(html).toContain('Codex 실험 기능');
expect(html).toContain('role="tablist"');
expect(html).toContain('role="tabpanel"');
expect(html).toContain('aria-selected="true"');
expect(html).toContain('data-settings-target="settings-models"');
expect(html).toContain('data-settings-target="settings-moa"');
expect(html).toContain('data-settings-target="settings-codex"');
expect(html).toContain('data-settings-target="settings-accounts"');
expect(html).toContain('aria-controls="settings-codex"');
expect(html).not.toContain('href="#settings-codex"');
expect(html).toContain('/goal');
expect(html).toContain('settings-apply-card');
expect(html).not.toContain('settings-apply-bar');
expect(html).toContain('저장 후 재시작');
expect(html).toContain('불러오는 중');
expect(html).toContain('Claude');
expect(html).toContain('계정');
expect(html).toContain('전체 갱신');
expect(html).toContain('스택 재시작');
expect(html.match(/class="settings-restart"/g)).toHaveLength(1);
});

View File

@@ -20,8 +20,15 @@ import {
updateFastMode,
updateModels,
} from './api';
import { LOCALES, languageNames, type Locale, type Messages } from './i18n';
import { type Locale, type Messages } from './i18n';
import { MoaSettingsPanel } from './MoaSettingsPanel';
import {
GeneralSettings,
SettingsApplyCard,
SettingsNav,
SettingsSectionHeading,
type SettingsSectionId,
} from './SettingsPanelChrome';
type AccountProvider = 'claude' | 'codex';
@@ -31,18 +38,6 @@ interface AccountData {
codexCurrentIndex?: number;
}
const SETTINGS_NAV_ITEMS = [
{ targetId: 'settings-general', title: '일반', detail: '표시 · 언어' },
{
targetId: 'settings-models',
title: '모델',
detail: 'owner · reviewer · arbiter',
},
{ targetId: 'settings-moa', title: 'MoA', detail: '참조 모델 · 연결 테스트' },
{ targetId: 'settings-codex', title: 'Codex', detail: 'fast mode · /goal' },
{ targetId: 'settings-accounts', title: '계정', detail: 'Claude · Codex' },
] as const;
export interface SettingsPanelProps {
locale: Locale;
nickname: string;
@@ -60,142 +55,51 @@ export function SettingsPanel({
onRestartStack,
t,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] =
useState<SettingsSectionId>('settings-general');
return (
<div className="settings-panel">
<div className="settings-layout">
<aside className="settings-sidebar" aria-label="설정 탐색과 적용">
<SettingsNav />
<SettingsNav
activeSection={activeSection}
onSelect={setActiveSection}
/>
<SettingsApplyCard onRestartStack={onRestartStack} />
</aside>
<main className="settings-content" aria-label="설정 항목">
<section className="settings-section" id="settings-general">
<SettingsSectionHeading
detail="Dashboard identity"
title="일반"
description="브라우저에서 보이는 이름과 언어만 즉시 바뀝니다."
<div hidden={activeSection !== 'settings-general'}>
<GeneralSettings
locale={locale}
nickname={nickname}
onLocaleChange={onLocaleChange}
onNicknameChange={onNicknameChange}
t={t}
/>
<div className="settings-form-grid">
<label className="settings-row">
<span className="settings-label">
{t.settings.nicknameLabel}
</span>
<input
maxLength={32}
onChange={(event) => onNicknameChange(event.target.value)}
placeholder={t.settings.nicknamePlaceholder}
type="text"
value={nickname}
/>
<small className="settings-hint">
{t.settings.nicknameHelp}
</small>
</label>
<label className="settings-row">
<span className="settings-label">
{t.settings.languageLabel}
</span>
<select
aria-label={t.settings.languageLabel}
onChange={(event) =>
onLocaleChange(event.target.value as Locale)
}
value={locale}
>
{LOCALES.map((item) => (
<option key={item} value={item}>
{languageNames[item]}
</option>
))}
</select>
</label>
</div>
</section>
</div>
<ModelSettings />
<div hidden={activeSection !== 'settings-models'}>
<ModelSettings />
</div>
<MoaSettingsPanel />
<div hidden={activeSection !== 'settings-moa'}>
<MoaSettingsPanel />
</div>
<section className="settings-section" id="settings-codex">
<SettingsSectionHeading
detail="Codex runtime"
title="Codex 옵션"
description="빠른 응답과 실험 기능을 관리합니다. /goal은 여기에서 찾을 수 있습니다."
/>
<div className="settings-section-stack">
<FastModeSettings />
<CodexFeatureSettings />
</div>
</section>
<div hidden={activeSection !== 'settings-codex'}>
<CodexRuntimeSettings />
</div>
<AccountSettings />
<div hidden={activeSection !== 'settings-accounts'}>
<AccountSettings />
</div>
</main>
</div>
</div>
);
}
function SettingsNav() {
const scrollToSection = (targetId: string) => {
document
.getElementById(targetId)
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<aside className="settings-nav" aria-label="설정 섹션">
{SETTINGS_NAV_ITEMS.map((item) => (
<button
aria-controls={item.targetId}
data-settings-target={item.targetId}
key={item.targetId}
onClick={() => scrollToSection(item.targetId)}
type="button"
>
<strong>{item.title}</strong>
<small>{item.detail}</small>
</button>
))}
</aside>
);
}
function SettingsSectionHeading({
detail,
title,
description,
}: {
detail: string;
title: string;
description: string;
}) {
return (
<header className="settings-section-head">
<span>{detail}</span>
<h3>{title}</h3>
<p>{description}</p>
</header>
);
}
function SettingsApplyCard({ onRestartStack }: { onRestartStack: () => void }) {
return (
<section className="settings-apply-card" aria-label="변경 적용">
<div>
<span className="settings-kicker">Apply</span>
<strong> </strong>
<small>, MoA, Codex, .</small>
</div>
<button
className="settings-restart"
onClick={onRestartStack}
type="button"
>
</button>
</section>
);
}
function ModelSettings() {
const [config, setConfig] = useState<ModelConfigSnapshot | null>(null);
const [draft, setDraft] = useState<ModelConfigSnapshot | null>(null);
@@ -261,7 +165,12 @@ function ModelSettings() {
JSON.stringify(draft) !== JSON.stringify(config);
return (
<section className="settings-section" id="settings-models">
<section
aria-labelledby="settings-models-tab"
className="settings-section"
id="settings-models"
role="tabpanel"
>
<SettingsSectionHeading
detail="Agent routing"
title="모델"
@@ -315,6 +224,27 @@ function ModelSettings() {
);
}
function CodexRuntimeSettings() {
return (
<section
aria-labelledby="settings-codex-tab"
className="settings-section"
id="settings-codex"
role="tabpanel"
>
<SettingsSectionHeading
detail="Codex runtime"
title="Codex 옵션"
description="빠른 응답과 실험 기능을 관리합니다. /goal은 여기에서 찾을 수 있습니다."
/>
<div className="settings-section-stack">
<FastModeSettings />
<CodexFeatureSettings />
</div>
</section>
);
}
function FastModeSettings() {
const [state, setState] = useState<FastModeSnapshot | null>(null);
const [busy, setBusy] = useState(false);
@@ -611,7 +541,12 @@ function AccountSettings() {
}
return (
<section className="settings-section" id="settings-accounts">
<section
aria-labelledby="settings-accounts-tab"
className="settings-section"
id="settings-accounts"
role="tabpanel"
>
<SettingsSectionHeading
detail="Credentials"
title="계정"

View File

@@ -0,0 +1,140 @@
import { LOCALES, languageNames, type Locale, type Messages } from './i18n';
export const SETTINGS_NAV_ITEMS = [
{ targetId: 'settings-general', title: '일반', detail: '표시 · 언어' },
{
targetId: 'settings-models',
title: '모델',
detail: 'owner · reviewer · arbiter',
},
{ targetId: 'settings-moa', title: 'MoA', detail: '참조 모델 · 연결 테스트' },
{ targetId: 'settings-codex', title: 'Codex', detail: 'fast mode · /goal' },
{ targetId: 'settings-accounts', title: '계정', detail: 'Claude · Codex' },
] as const;
export type SettingsSectionId = (typeof SETTINGS_NAV_ITEMS)[number]['targetId'];
export function SettingsNav({
activeSection,
onSelect,
}: {
activeSection: SettingsSectionId;
onSelect: (section: SettingsSectionId) => void;
}) {
return (
<div className="settings-nav" aria-label="설정 섹션" role="tablist">
{SETTINGS_NAV_ITEMS.map((item) => (
<button
aria-controls={item.targetId}
aria-selected={activeSection === item.targetId}
data-settings-target={item.targetId}
id={`${item.targetId}-tab`}
key={item.targetId}
onClick={() => onSelect(item.targetId)}
role="tab"
type="button"
>
<strong>{item.title}</strong>
<small>{item.detail}</small>
</button>
))}
</div>
);
}
export function GeneralSettings({
locale,
nickname,
onLocaleChange,
onNicknameChange,
t,
}: {
locale: Locale;
nickname: string;
onLocaleChange: (locale: Locale) => void;
onNicknameChange: (next: string) => void;
t: Messages;
}) {
return (
<section
aria-labelledby="settings-general-tab"
className="settings-section"
id="settings-general"
role="tabpanel"
>
<SettingsSectionHeading
detail="Dashboard identity"
title="일반"
description="브라우저에서 보이는 이름과 언어만 즉시 바뀝니다."
/>
<div className="settings-form-grid">
<label className="settings-row">
<span className="settings-label">{t.settings.nicknameLabel}</span>
<input
maxLength={32}
onChange={(event) => onNicknameChange(event.target.value)}
placeholder={t.settings.nicknamePlaceholder}
type="text"
value={nickname}
/>
<small className="settings-hint">{t.settings.nicknameHelp}</small>
</label>
<label className="settings-row">
<span className="settings-label">{t.settings.languageLabel}</span>
<select
aria-label={t.settings.languageLabel}
onChange={(event) => onLocaleChange(event.target.value as Locale)}
value={locale}
>
{LOCALES.map((item) => (
<option key={item} value={item}>
{languageNames[item]}
</option>
))}
</select>
</label>
</div>
</section>
);
}
export function SettingsSectionHeading({
detail,
title,
description,
}: {
detail: string;
title: string;
description: string;
}) {
return (
<header className="settings-section-head">
<span>{detail}</span>
<h3>{title}</h3>
<p>{description}</p>
</header>
);
}
export function SettingsApplyCard({
onRestartStack,
}: {
onRestartStack: () => void;
}) {
return (
<section className="settings-apply-card" aria-label="변경 적용">
<div>
<span className="settings-kicker">Apply</span>
<strong> </strong>
<small>, MoA, Codex, .</small>
</div>
<button
className="settings-restart"
onClick={onRestartStack}
type="button"
>
</button>
</section>
);
}

View File

@@ -669,6 +669,7 @@ button:disabled {
.settings-nav button {
display: grid;
gap: 2px;
min-height: 52px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 12px;
@@ -685,7 +686,8 @@ button:disabled {
}
.settings-nav button:hover,
.settings-nav button:focus-visible {
.settings-nav button:focus-visible,
.settings-nav button[aria-selected='true'] {
transform: translateX(2px);
border-color: rgba(214, 130, 88, 0.24);
background: rgba(214, 130, 88, 0.08);
@@ -728,7 +730,7 @@ button:disabled {
.settings-row input,
.settings-row select {
min-height: 38px;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--panel-border);
border-radius: 10px;
@@ -929,7 +931,7 @@ button:disabled {
.settings-save,
.settings-restart {
min-height: 32px;
min-height: 44px;
padding: 0 14px;
border: 0;
border-radius: 999px;
@@ -1006,7 +1008,7 @@ button:disabled {
}
.settings-secondary {
min-height: 28px;
min-height: 36px;
padding: 0 10px;
border: 1px solid var(--panel-border-strong);
border-radius: 999px;
@@ -1050,7 +1052,7 @@ button:disabled {
.settings-moa-grid input,
.settings-moa-grid select {
min-height: 32px;
min-height: 40px;
min-width: 0;
padding: 0 10px;
border: 1px solid var(--panel-border);
@@ -1141,7 +1143,7 @@ button:disabled {
}
.settings-delete {
min-height: 28px;
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(224, 100, 75, 0.32);
border-radius: 999px;
@@ -1178,7 +1180,7 @@ button:disabled {
.settings-add-token button {
align-self: end;
min-height: 36px;
min-height: 44px;
padding: 0 14px;
border: 0;
border-radius: 999px;
@@ -1204,8 +1206,7 @@ button:disabled {
}
.settings-nav {
grid-template-columns: repeat(5, minmax(120px, 1fr));
overflow-x: auto;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
}
.settings-apply-card {
@@ -1215,6 +1216,14 @@ button:disabled {
}
@media (max-width: 720px) {
.settings-nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-nav button {
min-height: 58px;
}
.settings-apply-card {
align-items: stretch;
grid-template-columns: 1fr;

View File

@@ -67,6 +67,7 @@ async function main() {
baseUrl,
async (page, state) => {
await openSettings(page, baseUrl);
await openSettingsSection(page, 'settings-codex');
const goalToggle = page.getByRole('checkbox', { name: /\/goal/ });
await assertVisible(goalToggle);
@@ -236,6 +237,13 @@ async function openSettings(page: Page, baseUrl: string) {
);
}
async function openSettingsSection(page: Page, targetId: string) {
await page
.locator(`.settings-nav button[data-settings-target="${targetId}"]`)
.click();
await assertVisible(page.locator(`#${targetId}`));
}
async function assertVisible(locator: ReturnType<Page['locator']>) {
await locator.waitFor({ state: 'visible', timeout: 5_000 });
}