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:
@@ -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> {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
{(
|
{(
|
||||||
|
|||||||
@@ -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); }}
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 이 반전됨.
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user