# 2026-04-10 작업일지

## 오늘 작업

- `react-router-dom` 기반 앱 셸로 메인 화면을 재구성하고, 상단 메뉴와 사이드바 선택이 URL 경로와 직접 연결되도록 정리했습니다.
- `AppShell`, `MainLayout`, `routes.tsx`를 추가해 `Docs / APIs / Plans / Chat / Play`를 페이지 단위로 분리했습니다.
- `MainContent`는 공통 창 레이어와 콘텐츠 컨테이너 역할만 남기고, 실제 화면 렌더링은 각 페이지 컴포넌트로 이동시켰습니다.
- 저장 레이아웃 상세 화면은 `savedLayoutViewId` 전용 fit 모드로 보정해, 상단 바를 포함한 전체 구성을 한 화면 안에 맞춰 보이도록 수정했습니다.
- `Text Memo Widget`의 삭제 버튼이 실제로 동작하도록 `Modal.useModal()` 기반 확인 플로우를 연결했습니다.
- 오늘 증적용으로 `APIs / Widgets` 전체 화면 1장과 `Text Memo Widget` 부분 화면 1장을 `docs/assets/worklogs/2026-04-10/`에 저장했습니다.
- 오늘자 작업일지 문서를 새로 작성하고, 소스 탭에서 `전체소스 / diff` 전환에 대응할 수 있도록 파일별 raw diff 근거와 전체 변경 파일 목록을 정리했습니다.

## 이슈 및 해결

- 기존 `MainView` 한 파일에 메뉴 상태, 검색 옵션, 문서/플랜/채팅/플레이 렌더링이 모두 얽혀 있어 경로 기반 탐색과 확장이 어려웠습니다.
- `AppShell + MainLayout + pages/* + routes.tsx` 구조로 나누고, 라우팅 파생 상태는 `parseRoute()`와 `MainLayoutContext`에서만 계산하도록 정리했습니다.
- 저장 레이아웃 전용 화면은 내부 콘텐츠 높이와 스크롤 상태에 따라 미리보기가 잘리거나 빈 여백이 생겼습니다.
- `useLayoutEffect`, `ResizeObserver`, `MutationObserver`로 실제 렌더 크기를 다시 재고 `savedLayoutFitScale`을 계산해 fit 렌더를 안정화했습니다.
- 메모 위젯 삭제 버튼은 정적 모달 호출과 컨텍스트 미연결 상태 때문에 클릭해도 확인창이 뜨지 않는 문제가 있었습니다.
- `Modal.useModal()`을 도입하고 `modalContextHolder`를 위젯 루트에 넣어 저장 메모와 작성 중 초안 모두 삭제 가능하게 바꿨습니다.
- 이 작업 환경에서는 Git이 `dubious ownership`으로 차단되고 글로벌 `safe.directory` 등록도 실패했습니다.
- 글로벌 설정 대신 모든 Git 조회에 `git -c safe.directory=/workspace/auto_codex/repo ...`를 사용해 증적 수집을 계속 진행했습니다.

## 결정 사항

- 메인 앱 진입은 상태 기반 조건 분기보다 URL 라우팅을 우선 기준으로 유지합니다.
- 공통 레이아웃 상태는 `MainLayoutContext`로 전달하고, 페이지별 렌더링 책임은 `pages/*`로 분리합니다.
- 저장 레이아웃 상세 뷰는 스크롤보다 fit 우선 미리보기 규칙을 사용합니다.
- 메모 삭제는 즉시 삭제하지 않고 항상 확인 모달을 거치게 유지합니다.
- 오늘 대표 스크린샷은 `APIs / Widgets` 전체 화면, 부분 스크린샷은 `Text Memo Widget`으로 고정합니다.

## 상세 작업 내역

- 오늘 Git 이력은 `WindowUI` 최소화/헤더 정리, 메모 삭제 플로우 복구, 저장 레이아웃 fit 모드 보정, 앱 셸 라우팅 분리 순서로 누적됐고 최종적으로 `release -> main` 동기화까지 진행됐습니다.
- 라우팅 개편은 `MainView`에 모여 있던 메뉴/문서/플랜/채팅/플레이 조건 분기를 걷어내고, `AppShell` 아래에서 `MainLayout`이 공통 상태를 제공한 뒤 각 페이지가 자기 화면만 렌더링하도록 재조정한 작업입니다.
- 검색 옵션 빌드도 같은 라우팅 구조를 따라가도록 재작성해서, 검색 결과가 단순 상태 변경이 아니라 실제 경로 전환과 포커스 이동을 함께 수행하도록 맞췄습니다.
- 저장 레이아웃 보정은 단순 CSS 스케일링이 아니라 실제 콘텐츠 크기를 측정해 viewport 안에 맞추는 방식으로 바꿨고, 관련 overflow 규칙도 같이 조정했습니다.
- 메모 위젯은 삭제 버튼 활성화와 확인 모달 흐름을 분리해, 저장된 메모와 작성 중 초안을 같은 UX 안에서 처리하도록 정리했습니다.
- 오늘 작업일지 작성 단계에서는 Git 로그, diff, 빌드 결과, 실제 Playwright 캡처를 함께 확인해서 문서와 스크린샷 링크를 최신 상태로 맞췄습니다.

## 스크린샷

![feature-apis-widgets-full](../assets/worklogs/2026-04-10/feature-apis-widgets-full.png)

- `APIs / Widgets` 전체 화면 캡처입니다. 라우팅 기반 앱 셸 아래에서 위젯 갤러리 전체 내역이 한 번에 보이도록 full-page로 저장했습니다.

![widget-text-memo](../assets/worklogs/2026-04-10/widget-text-memo.png)

- `Text Memo Widget` 부분 캡처입니다. 오늘 복구한 메모 입력/삭제 흐름을 위젯 단위로 바로 확인할 수 있도록 별도로 남겼습니다.

## 소스

### 파일 1: `src/app/main/AppShell.tsx`

- 라우터 엔트리와 페이지 분리의 기준점입니다.

```diff
+import { Navigate, Route, Routes } from 'react-router-dom';
+import { MainLayout } from './layout/MainLayout';
+import { ApisPage } from './pages/ApisPage';
+import { ChatPage } from './pages/ChatPage';
+import { DocsPage } from './pages/DocsPage';
+import { PlansPage } from './pages/PlansPage';
+import { PlayPage } from './pages/PlayPage';
+<Route path="/" element={<MainLayout />}>
+  <Route path="docs/:folder" element={<DocsPage />} />
+  <Route path="apis/:section" element={<ApisPage />} />
+  <Route path="plans/:section" element={<PlansPage />} />
+  <Route path="chat/:section" element={<ChatPage />} />
+  <Route path="play/layout-record/:layoutId" element={<PlayPage />} />
+</Route>
```

### 파일 2: `src/app/main/layout/MainLayout.tsx`

- 기존 `MainView`의 거대한 상태 조합을 라우팅 기반 공통 레이아웃으로 옮겼습니다.

```diff
+import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
+function parseRoute(pathname: string) {
+  if (top === 'docs') { ... }
+  if (top === 'apis' && (first === 'components' || first === 'widgets')) { ... }
+  if (top === 'play' && first === 'layout-record' && second) { ... }
+}
+const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
+const layoutData = useMainLayoutData();
+<MainLayoutContextProvider value={...}>
+  <MainHeader ... />
+  <MainSidebar ... />
+  <MainContent contentExpanded={contentExpanded} onToggleContentExpanded={...}>
+    <Outlet />
+  </MainContent>
+</MainLayoutContextProvider>
```

### 파일 3: `src/app/main/routes.tsx`

- 메뉴 라벨, 경로 빌더, 사이드바 공개 타입을 한곳에 모았습니다.

```diff
+export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
+export type PlanSectionKey = PlanFilterStatus | 'release' | 'charts' | 'history';
+export const DOCS_DEFAULT_FOLDER = 'worklogs';
+export function buildDocsPath(folder = DOCS_DEFAULT_FOLDER) {
+  return `/docs/${folder}`;
+}
+export function buildPlansPath(section: PlanSectionKey = 'all') {
+  return `/plans/${section}`;
+}
+export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: string }>): MenuProps['items'] {
+  return [{ key: 'play-group', children: [...] }];
+}
```

### 파일 4: `src/app/main/MainContent.tsx`

- 공통 창 레이어만 남기고 화면별 조건 분기를 페이지 컴포넌트로 밀어냈습니다.

```diff
-import { Button, Card, Layout, Modal, Space, Typography } from 'antd';
+import { Button, Layout, Modal, Space, Typography } from 'antd';
-import { MarkdownPreviewCard } from '../../components/markdownPreview';
+import { useMainLayoutContext } from './layout/MainLayoutContext';
-export function MainContent({ activeTopMenu, selectedApiMenu, selectedDocsMenu, ... }: MainContentProps) {
+export function MainContent({ contentExpanded, onToggleContentExpanded, children }: MainContentProps) {
+  const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, initialSelectedPlanId, initialSelectedWorkId } =
+    useMainLayoutContext();
-  <div className={activeTopMenu === 'chat' ? 'app-main-layout app-main-layout--single' : 'app-main-layout'}>
-    {activeTopMenu === 'docs' ? ... : activeTopMenu === 'plans' ? ... : ...}
-  </div>
+  <div className="app-main-layout">{children}</div>
```

### 파일 5: `src/app/main/MainSidebar.tsx`

- 소개 영역과 API 메뉴 선택 타입을 공통 라우팅 구조에 맞게 정리했습니다.

```diff
+  introColor,
+  introTag,
+  introDescription,
-  const activeTagColor = isDocsGroup ? 'gold' : activeTopMenu === 'play' ? 'cyan' : 'green';
-  <Tag color={activeTagColor}>...</Tag>
+  <Tag color={introColor}>{introTag}</Tag>
+  <Text type="secondary">{introDescription}</Text>
-  onSelectApiMenu(key);
+  onSelectApiMenu(key as MainSidebarProps['selectedApiMenu']);
```

### 파일 6: `src/app/main/layout/MainLayoutContext.ts`

- 페이지와 공통 레이어가 같은 상태를 공유하도록 컨텍스트를 신설했습니다.

```diff
+export type MainLayoutContextValue = {
+  topMenu: TopMenuKey;
+  selectedDocsMenu: string;
+  selectedApiMenu: ApiSectionKey;
+  selectedPlanMenu: PlanSectionKey;
+  selectedChatMenu: ChatSectionKey;
+  selectedPlayMenu: PlaySidebarKey;
+  searchOptions: SearchKeywordOption[];
+};
+const MainLayoutContext = createContext<MainLayoutContextValue | null>(null);
+export function useMainLayoutContext() { ... }
```

### 파일 7: `src/app/main/layout/buildSearchOptions.ts`

- 검색 결과가 상태 변경이 아니라 실제 라우팅 전환을 수행하도록 재작성했습니다.

```diff
+import { buildApisPath, buildChatPath, buildDocsPath, buildPlansPath, buildPlayPath } from '../routes';
+onSelect: () => {
+  requestPlanQuickFilter(null);
+  navigateTo(buildApisPath('widgets'));
+  setFocusedComponentId(null);
+},
+onSelect: () => {
+  requestPlanQuickFilter('release-pending-main');
+  navigateTo(buildPlansPath('release'));
+  setFocusedComponentId(null);
+},
+onSelect: () => {
+  navigateTo(buildDocsPath(document.folder));
+  setFocusedComponentId(`doc:${document.id}`);
+  scrollToElement(`document-preview-${document.id}`);
+},
```

### 파일 8: `src/app/main/pages/ApisPage.tsx`

- APIs 화면 렌더링 책임을 페이지 단위로 분리했습니다.

```diff
+export function ApisPage() {
+  const { selectedApiMenu, componentSampleEntries, widgetSampleEntries } = useMainLayoutContext();
+  return (
+    <div className="app-main-panel">
+      <Card title={selectedApiMenu === 'components' ? 'APIs / Components' : 'APIs / Widgets'} className="app-main-card" bordered={false}>
+        {selectedApiMenu === 'components' ? (
+          <ComponentSamplesLayout entries={componentSampleEntries} excludeComponentIds={HIDDEN_COMPONENT_IDS} />
+        ) : (
+          <SampleWidgetsLayout entries={widgetSampleEntries} />
+        )}
+      </Card>
+    </div>
+  );
+}
```

### 파일 9: `src/views/play/LayoutPlaygroundView.tsx`

- 저장 레이아웃 전용 fit 모드와 실제 크기 측정 로직을 추가했습니다.

```diff
-import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
+import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
+const isSavedLayoutFitMode = Boolean(savedLayoutViewId);
+const [savedLayoutFitScale, setSavedLayoutFitScale] = useState(1);
+const [savedLayoutFitSize, setSavedLayoutFitSize] = useState<{ width: number; height: number } | null>(null);
+const savedLayoutFitViewportRef = useRef<HTMLDivElement | null>(null);
+const savedLayoutFitContentRef = useRef<HTMLDivElement | null>(null);
+useLayoutEffect(() => {
+  const nextScale = Math.min(1, availableWidth / width, availableHeight / height);
+  setSavedLayoutFitScale(...);
+}, [savedLayoutViewId, selectedSavedLayoutRecord, sampleEntries.length]);
+style={{ transform: `scale(${savedLayoutFitScale})`, width: `${savedLayoutFitSize.width}px` }}
+previewSurface: isSavedLayoutFitMode,
```

### 파일 10: `src/styles.css`

- fit 모드에서 overflow와 스크롤바를 억제하는 전용 스타일을 추가했습니다.

```diff
+.layout-playground__fullscreen-shell--saved-fit {
+  min-height: 0;
+  overflow: hidden;
+}
+.layout-playground__saved-fit-viewport {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+.layout-playground__saved-detail--fit * {
+  scrollbar-width: none;
+}
+.layout-playground__saved-detail--fit *::-webkit-scrollbar {
+  width: 0;
+  height: 0;
+}
```

### 파일 11: `src/widgets/text-memo-widget/TextMemoWidget.tsx`

- 삭제 버튼 비활성 상태를 해제하고, 확인 모달을 거쳐 실제 삭제되도록 바꿨습니다.

```diff
-import { Button, Empty, Input, message } from 'antd';
+import { Button, Empty, Input, Modal, message } from 'antd';
+const [modalApi, modalContextHolder] = Modal.useModal();
-const handleDelete = () => {
-  void messageApi.info('삭제 기능은 현재 비활성화되어 있습니다.');
-};
+const handleDelete = () => {
+  if (!selectedNote && !hasDraft) {
+    return;
+  }
+  void modalApi.confirm({
+    title: isDraftOnly ? '작성 중인 메모를 삭제할까요?' : '선택한 메모를 삭제할까요?',
+    onOk: () => { ... },
+  });
+};
+{modalContextHolder}
-disabled
+disabled={!selectedNote && !hasDraft}
```

### 파일 12: `src/App.tsx`, `src/main.tsx`, `src/app/main/index.ts`

- 앱 진입점을 라우터 기반 셸로 연결했습니다.

```diff
-import { MainView } from './app/main';
+import { BrowserRouter } from 'react-router-dom';
+import { MainView } from './app/main';
+<BrowserRouter>
+  <MainView />
+</BrowserRouter>
-export { MainView } from './MainView';
+export { AppShell } from './AppShell';
+export { MainView } from './MainView';
-export function MainView() { ...legacy layout... }
+export function MainView() {
+  return <AppShell />;
+}
```

## 실행 커맨드

```bash
find docs/worklogs docs/assets/worklogs -type f | sort
grep -RIn "작업일지\|스크린샷\|소스 탭\|실행 커맨드\|상세형" docs/worklogs --include='*.md'
git -c safe.directory=/workspace/auto_codex/repo status --short --branch
sed -n '1,260p' docs/worklogs/2026-04-09.md
sed -n '1,260p' docs/templates/worklog-template.md
find scripts -maxdepth 1 -type f | sort
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-10 00:00:00' --until='2026-04-10 23:59:59' --stat --oneline
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-10 00:00:00' --until='2026-04-10 23:59:59' --name-only --pretty=format: | sed '/^$/d' | sort | uniq
git -c safe.directory=/workspace/auto_codex/repo diff --stat 0b105f8..HEAD -- src/app/main/MainView.tsx src/app/main/layout/MainLayout.tsx src/app/main/routes.tsx src/app/main/MainContent.tsx src/widgets/text-memo-widget/TextMemoWidget.tsx src/components/window/WindowUI.tsx src/styles.css src/views/play/LayoutPlaygroundView.tsx
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/MainView.tsx
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/layout/MainLayout.tsx
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/widgets/text-memo-widget/TextMemoWidget.tsx
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/routes.tsx
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/MainContent.tsx
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/views/play/LayoutPlaygroundView.tsx
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/AppShell.tsx src/app/main/MainSidebar.tsx src/app/main/layout/MainLayoutContext.ts src/app/main/layout/buildSearchOptions.ts src/app/main/layout/useMainLayoutData.ts src/app/main/pages/ApisPage.tsx src/app/main/pages/ChatPage.tsx src/app/main/pages/DocsPage.tsx src/app/main/pages/PlansPage.tsx src/main.tsx
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/components/window/WindowUI.tsx src/components/window/WindowUI.css src/widgets/text-memo-widget/TextMemoWidget.css src/styles.css
git -c safe.directory=/workspace/auto_codex/repo diff --name-status 0b105f8..HEAD
npm run build:app
PORT=4173 node scripts/serve-app-dist.mjs
node --input-type=module <<'EOF'
import { chromium } from 'playwright';
// APIs / Widgets full-page screenshot and text memo widget crop capture
EOF
```

## 변경/신규 파일

- A docs/worklogs/2026-04-10.md
- A docs/assets/worklogs/2026-04-10/feature-apis-widgets-full.png
- A docs/assets/worklogs/2026-04-10/widget-text-memo.png
- M package-lock.json
- M package.json
- M src/App.tsx
- A src/app/main/AppShell.tsx
- M src/app/main/MainContent.tsx
- M src/app/main/MainSidebar.tsx
- M src/app/main/MainView.tsx
- M src/app/main/index.ts
- A src/app/main/layout/MainLayout.tsx
- A src/app/main/layout/MainLayoutContext.ts
- A src/app/main/layout/buildSearchOptions.ts
- A src/app/main/layout/useMainLayoutData.ts
- A src/app/main/pages/ApisPage.tsx
- A src/app/main/pages/ChatPage.tsx
- A src/app/main/pages/DocsPage.tsx
- A src/app/main/pages/PlansPage.tsx
- A src/app/main/pages/PlayPage.tsx
- A src/app/main/routes.tsx
- M src/app/main/types.ts
- M src/main.tsx
- M src/styles.css
- M src/views/play/LayoutPlaygroundView.tsx
- M src/widgets/text-memo-widget/TextMemoWidget.tsx
