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())); Object.values(apiKeys).some((value) => value.trim()));
return ( 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"> <header className="settings-section-head">
<span>Arbiter references</span> <span>Arbiter references</span>
<h3>MoA </h3> <h3>MoA </h3>

View File

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

View File

@@ -20,8 +20,15 @@ import {
updateFastMode, updateFastMode,
updateModels, updateModels,
} from './api'; } from './api';
import { LOCALES, languageNames, type Locale, type Messages } from './i18n'; import { type Locale, type Messages } from './i18n';
import { MoaSettingsPanel } from './MoaSettingsPanel'; import { MoaSettingsPanel } from './MoaSettingsPanel';
import {
GeneralSettings,
SettingsApplyCard,
SettingsNav,
SettingsSectionHeading,
type SettingsSectionId,
} from './SettingsPanelChrome';
type AccountProvider = 'claude' | 'codex'; type AccountProvider = 'claude' | 'codex';
@@ -31,18 +38,6 @@ interface AccountData {
codexCurrentIndex?: number; 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 { export interface SettingsPanelProps {
locale: Locale; locale: Locale;
nickname: string; nickname: string;
@@ -60,142 +55,51 @@ export function SettingsPanel({
onRestartStack, onRestartStack,
t, t,
}: SettingsPanelProps) { }: SettingsPanelProps) {
const [activeSection, setActiveSection] =
useState<SettingsSectionId>('settings-general');
return ( return (
<div className="settings-panel"> <div className="settings-panel">
<div className="settings-layout"> <div className="settings-layout">
<aside className="settings-sidebar" aria-label="설정 탐색과 적용"> <aside className="settings-sidebar" aria-label="설정 탐색과 적용">
<SettingsNav /> <SettingsNav
activeSection={activeSection}
onSelect={setActiveSection}
/>
<SettingsApplyCard onRestartStack={onRestartStack} /> <SettingsApplyCard onRestartStack={onRestartStack} />
</aside> </aside>
<main className="settings-content" aria-label="설정 항목"> <main className="settings-content" aria-label="설정 항목">
<section className="settings-section" id="settings-general"> <div hidden={activeSection !== 'settings-general'}>
<SettingsSectionHeading <GeneralSettings
detail="Dashboard identity" locale={locale}
title="일반" nickname={nickname}
description="브라우저에서 보이는 이름과 언어만 즉시 바뀝니다." onLocaleChange={onLocaleChange}
onNicknameChange={onNicknameChange}
t={t}
/> />
<div className="settings-form-grid"> </div>
<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>
<ModelSettings /> <div hidden={activeSection !== 'settings-models'}>
<ModelSettings />
</div>
<MoaSettingsPanel /> <div hidden={activeSection !== 'settings-moa'}>
<MoaSettingsPanel />
</div>
<section className="settings-section" id="settings-codex"> <div hidden={activeSection !== 'settings-codex'}>
<SettingsSectionHeading <CodexRuntimeSettings />
detail="Codex runtime" </div>
title="Codex 옵션"
description="빠른 응답과 실험 기능을 관리합니다. /goal은 여기에서 찾을 수 있습니다."
/>
<div className="settings-section-stack">
<FastModeSettings />
<CodexFeatureSettings />
</div>
</section>
<AccountSettings /> <div hidden={activeSection !== 'settings-accounts'}>
<AccountSettings />
</div>
</main> </main>
</div> </div>
</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() { function ModelSettings() {
const [config, setConfig] = useState<ModelConfigSnapshot | null>(null); const [config, setConfig] = useState<ModelConfigSnapshot | null>(null);
const [draft, setDraft] = useState<ModelConfigSnapshot | null>(null); const [draft, setDraft] = useState<ModelConfigSnapshot | null>(null);
@@ -261,7 +165,12 @@ function ModelSettings() {
JSON.stringify(draft) !== JSON.stringify(config); JSON.stringify(draft) !== JSON.stringify(config);
return ( return (
<section className="settings-section" id="settings-models"> <section
aria-labelledby="settings-models-tab"
className="settings-section"
id="settings-models"
role="tabpanel"
>
<SettingsSectionHeading <SettingsSectionHeading
detail="Agent routing" detail="Agent routing"
title="모델" 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() { function FastModeSettings() {
const [state, setState] = useState<FastModeSnapshot | null>(null); const [state, setState] = useState<FastModeSnapshot | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -611,7 +541,12 @@ function AccountSettings() {
} }
return ( return (
<section className="settings-section" id="settings-accounts"> <section
aria-labelledby="settings-accounts-tab"
className="settings-section"
id="settings-accounts"
role="tabpanel"
>
<SettingsSectionHeading <SettingsSectionHeading
detail="Credentials" detail="Credentials"
title="계정" 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 { .settings-nav button {
display: grid; display: grid;
gap: 2px; gap: 2px;
min-height: 52px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 12px; border-radius: 12px;
@@ -685,7 +686,8 @@ button:disabled {
} }
.settings-nav button:hover, .settings-nav button:hover,
.settings-nav button:focus-visible { .settings-nav button:focus-visible,
.settings-nav button[aria-selected='true'] {
transform: translateX(2px); transform: translateX(2px);
border-color: rgba(214, 130, 88, 0.24); border-color: rgba(214, 130, 88, 0.24);
background: rgba(214, 130, 88, 0.08); background: rgba(214, 130, 88, 0.08);
@@ -728,7 +730,7 @@ button:disabled {
.settings-row input, .settings-row input,
.settings-row select { .settings-row select {
min-height: 38px; min-height: 44px;
padding: 0 12px; padding: 0 12px;
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 10px; border-radius: 10px;
@@ -929,7 +931,7 @@ button:disabled {
.settings-save, .settings-save,
.settings-restart { .settings-restart {
min-height: 32px; min-height: 44px;
padding: 0 14px; padding: 0 14px;
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
@@ -1006,7 +1008,7 @@ button:disabled {
} }
.settings-secondary { .settings-secondary {
min-height: 28px; min-height: 36px;
padding: 0 10px; padding: 0 10px;
border: 1px solid var(--panel-border-strong); border: 1px solid var(--panel-border-strong);
border-radius: 999px; border-radius: 999px;
@@ -1050,7 +1052,7 @@ button:disabled {
.settings-moa-grid input, .settings-moa-grid input,
.settings-moa-grid select { .settings-moa-grid select {
min-height: 32px; min-height: 40px;
min-width: 0; min-width: 0;
padding: 0 10px; padding: 0 10px;
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
@@ -1141,7 +1143,7 @@ button:disabled {
} }
.settings-delete { .settings-delete {
min-height: 28px; min-height: 36px;
padding: 0 10px; padding: 0 10px;
border: 1px solid rgba(224, 100, 75, 0.32); border: 1px solid rgba(224, 100, 75, 0.32);
border-radius: 999px; border-radius: 999px;
@@ -1178,7 +1180,7 @@ button:disabled {
.settings-add-token button { .settings-add-token button {
align-self: end; align-self: end;
min-height: 36px; min-height: 44px;
padding: 0 14px; padding: 0 14px;
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
@@ -1204,8 +1206,7 @@ button:disabled {
} }
.settings-nav { .settings-nav {
grid-template-columns: repeat(5, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
overflow-x: auto;
} }
.settings-apply-card { .settings-apply-card {
@@ -1215,6 +1216,14 @@ button:disabled {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.settings-nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-nav button {
min-height: 58px;
}
.settings-apply-card { .settings-apply-card {
align-items: stretch; align-items: stretch;
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -67,6 +67,7 @@ async function main() {
baseUrl, baseUrl,
async (page, state) => { async (page, state) => {
await openSettings(page, baseUrl); await openSettings(page, baseUrl);
await openSettingsSection(page, 'settings-codex');
const goalToggle = page.getByRole('checkbox', { name: /\/goal/ }); const goalToggle = page.getByRole('checkbox', { name: /\/goal/ });
await assertVisible(goalToggle); 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']>) { async function assertVisible(locator: ReturnType<Page['locator']>) {
await locator.waitFor({ state: 'visible', timeout: 5_000 }); await locator.waitFor({ state: 'visible', timeout: 5_000 });
} }