feat(ux): NoteCard chip affordance 강화 + 헤더 사이드바 토글 + default visible + 창 크기

dogfood 발견 사항 묶음:

- **NotebookChip** 시각 강화 — 청색 배경 + 📓 아이콘 + ▾ caret + dropdown
  헤더 '이동할 노트북'. 클릭 시 다른 노트북 dropdown 명확히 발견 가능.
  다른 노트북 없으면 disabled state.
- **헤더 좌측 ☰ 햄버거 버튼** — 마우스로 사이드바 토글 (Cmd/Ctrl+B 와 동일).
- **사이드바 default visible** — settings.getSidebarVisible 의 default false→true,
  store init 도 동일. 기존 사용자가 명시적으로 false 저장했다면 그 값 유지.
- **inboxWindow 기본 크기 확장** — 900×720 → 1200×800. 사이드바 240px 가
  default 가시화되므로 main 영역 확보.

851 tests pass + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-15 14:22:57 +09:00
parent da6d296b77
commit d40880de5b
7 changed files with 38 additions and 15 deletions

View File

@@ -186,7 +186,7 @@ export class SettingsService {
// v0.4 Task 15 — sidebar 레이아웃 영속화. // v0.4 Task 15 — sidebar 레이아웃 영속화.
async getSidebarVisible(): Promise<boolean> { async getSidebarVisible(): Promise<boolean> {
const s = await this.load(); const s = await this.load();
return s.sidebar_visible ?? false; return s.sidebar_visible ?? true;
} }
async setSidebarVisible(v: boolean): Promise<void> { async setSidebarVisible(v: boolean): Promise<void> {

View File

@@ -22,8 +22,8 @@ export function createInboxWindow(opts: { visible?: boolean } = {}): BrowserWind
} }
inboxWindow = new BrowserWindow({ inboxWindow = new BrowserWindow({
width: 900, width: 1200,
height: 720, height: 800,
show: false, show: false,
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),

View File

@@ -118,6 +118,17 @@ export function App(): React.ReactElement {
<Sidebar /> <Sidebar />
<main style={{ flex: 1, overflowY: 'auto' }}> <main style={{ flex: 1, overflowY: 'auto' }}>
<div className="header"> <div className="header">
<button
onClick={() => useInbox.getState().toggleSidebar()}
aria-label="사이드바 토글"
title="사이드바 토글 (Cmd/Ctrl+B)"
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 18, padding: '0 8px 0 0', color: '#444', lineHeight: 1
}}
>
</button>
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1> <h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}> <div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
{( {(

View File

@@ -115,29 +115,38 @@ function NotebookChip({ current, notebooks, onMove }: {
onMove: (id: string) => Promise<void>; onMove: (id: string) => Promise<void>;
}): React.ReactElement { }): React.ReactElement {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const others = notebooks.filter((nb) => nb.id !== current.id);
const hasOthers = others.length > 0;
return ( return (
<span style={{ position: 'relative', display: 'inline-block' }}> <span style={{ position: 'relative', display: 'inline-block' }}>
<button <button
onClick={() => setOpen(!open)} onClick={() => hasOthers && setOpen(!open)}
title="다른 노트북으로 이동" title={hasOthers ? '다른 노트북으로 이동' : '현재 노트북'}
disabled={!hasOthers}
style={{ style={{
display: 'inline-flex', alignItems: 'center', gap: 4, display: 'inline-flex', alignItems: 'center', gap: 4,
background: '#f0f0f0', border: 'none', borderRadius: 10, background: '#eaf3ff', color: '#0a4b80',
padding: '2px 8px', fontSize: 11, cursor: 'pointer' border: '1px solid #cfe0f5', borderRadius: 10,
padding: '2px 8px', fontSize: 11, cursor: hasOthers ? 'pointer' : 'default'
}} }}
> >
<span style={{ width: 6, height: 6, borderRadius: '50%', background: current.color ?? '#bbb', display: 'inline-block' }} /> <span style={{ width: 6, height: 6, borderRadius: '50%', background: current.color ?? '#bbb', display: 'inline-block' }} />
{current.name} 📓 {current.name}
{hasOthers && <span style={{ fontSize: 9, opacity: 0.6 }}></span>}
</button> </button>
{open && ( {open && hasOthers && (
<div <div
style={{ style={{
position: 'absolute', top: '100%', left: 0, position: 'absolute', top: '100%', left: 0,
background: '#fff', border: '1px solid #ccc', borderRadius: 4, background: '#fff', border: '1px solid #ccc', borderRadius: 4,
zIndex: 50, minWidth: 120, boxShadow: '0 2px 6px rgba(0,0,0,0.15)' zIndex: 50, minWidth: 140, boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
marginTop: 2
}} }}
> >
{notebooks.filter((nb) => nb.id !== current.id).map((nb) => ( <div style={{ fontSize: 10, color: '#888', padding: '4px 10px', borderBottom: '1px solid #eee' }}>
</div>
{others.map((nb) => (
<button <button
key={nb.id} key={nb.id}
onClick={async () => { await onMove(nb.id); setOpen(false); }} onClick={async () => { await onMove(nb.id); setOpen(false); }}

View File

@@ -129,7 +129,7 @@ export const useInbox = create<InboxState>((set, get) => ({
reviewData: null, reviewData: null,
notebooks: [], notebooks: [],
selectedNotebookId: null, selectedNotebookId: null,
sidebarVisible: false, sidebarVisible: true,
sidebarWidth: 240, sidebarWidth: 240,
promotionCandidates: [], promotionCandidates: [],
async loadInitial() { async loadInitial() {
@@ -156,7 +156,7 @@ export const useInbox = create<InboxState>((set, get) => ({
notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates,
failedCount, recallCandidate, counts, failedCount, recallCandidate, counts,
ai_enabled: settings.ai_enabled ?? true, ai_enabled: settings.ai_enabled ?? true,
sidebarVisible: settings.sidebar_visible ?? false, sidebarVisible: settings.sidebar_visible ?? true,
sidebarWidth: settings.sidebar_width ?? 240, sidebarWidth: settings.sidebar_width ?? 240,
loading: false loading: false
}); });

View File

@@ -166,9 +166,11 @@ describe('App header — 3 tabs (v0.4)', () => {
}); });
it('Cmd+B 키 이벤트가 toggleSidebar 호출', async () => { it('Cmd+B 키 이벤트가 toggleSidebar 호출', async () => {
const initialVisible = useInbox.getState().sidebarVisible; // loadInitial 의 getSettings hydrate 후 state 가 정해진 시점 기준으로 토글 검증.
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true, sidebar_visible: false });
render(<App />); render(<App />);
await screen.findByRole('tab', { name: /Inbox/ }); await screen.findByRole('tab', { name: /Inbox/ });
const initialVisible = useInbox.getState().sidebarVisible;
// jsdom 에서 navigator.platform = '' → isMac=false → ctrlKey 로 판단. // jsdom 에서 navigator.platform = '' → isMac=false → ctrlKey 로 판단.
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true })); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true }));
// toggleSidebar 가 호출되면 sidebarVisible 이 반전됨. // toggleSidebar 가 호출되면 sidebarVisible 이 반전됨.

View File

@@ -224,7 +224,8 @@ describe('NoteCard — notebook chip (Task 17)', () => {
fireEvent.click(screen.getByTitle('다른 노트북으로 이동')); fireEvent.click(screen.getByTitle('다른 노트북으로 이동'));
// 현재 nb-1('회사') 는 제외, nb-2('개인') 만 보임. // 현재 nb-1('회사') 는 제외, nb-2('개인') 만 보임.
expect(screen.getByText('개인')).toBeInTheDocument(); expect(screen.getByText('개인')).toBeInTheDocument();
expect(screen.queryAllByText('회사').length).toBe(1); // chip 버튼 안에만 존재 // chip 자체 text 는 "📓 회사 ▾" 이라 정확 매칭 X → regex 로 chip 안에만 '회사' 존재 확인.
expect(screen.queryAllByText(/회사/).length).toBe(1);
}); });
it('dropdown 의 notebook 클릭 → store.moveNoteToNotebook 호출', async () => { it('dropdown 의 notebook 클릭 → store.moveNoteToNotebook 호출', async () => {