From 42ba8bf67691d264e161ac75d9891810092ae6a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 21 Apr 2026 13:57:20 +0900 Subject: [PATCH] =?UTF-8?q?docs(2026-04-19~21):=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20PRD/handoff/explain=20=EB=AC=B8=EC=84=9C=2010?= =?UTF-8?q?=EA=B1=B4=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backup/pre-sync-20260421에서 소실됐던 문서를 origin/main 위로 재파일링. - 2026-04-19: axNamingDictionary, gmailMockup - 2026-04-20: cmux/studio/persistPlugin PRD + handoff - 2026-04-21: finder vs featureFinder 설명, finder filter/FlatLayout handoff --- .../2026-04/2026-04-19/axNamingDictionary.md | 361 ++++++++++++ docs/2026/2026-04/2026-04-19/gmailMockup.md | 128 +++++ docs/2026/2026-04/2026-04-20/cmuxPrd.md | 349 ++++++++++++ .../handoffInspectorSourcePreviewScroll.md | 49 ++ .../2026-04-20/handoffPersistPlugin.md | 48 ++ .../2026-04/2026-04-20/persistPluginPrd.md | 353 ++++++++++++ docs/2026/2026-04/2026-04-20/studioPrd.md | 532 ++++++++++++++++++ .../explainFinderVsFeatureFinder.md | 175 ++++++ .../2026-04-21/handoffFinderFilterMddbExt.md | 47 ++ .../2026-04-21/handoffFlatLayoutOcpSrp.md | 104 ++++ 10 files changed, 2146 insertions(+) create mode 100644 docs/2026/2026-04/2026-04-19/axNamingDictionary.md create mode 100644 docs/2026/2026-04/2026-04-19/gmailMockup.md create mode 100644 docs/2026/2026-04/2026-04-20/cmuxPrd.md create mode 100644 docs/2026/2026-04/2026-04-20/handoffInspectorSourcePreviewScroll.md create mode 100644 docs/2026/2026-04/2026-04-20/handoffPersistPlugin.md create mode 100644 docs/2026/2026-04/2026-04-20/persistPluginPrd.md create mode 100644 docs/2026/2026-04/2026-04-20/studioPrd.md create mode 100644 docs/2026/2026-04/2026-04-21/explainFinderVsFeatureFinder.md create mode 100644 docs/2026/2026-04/2026-04-21/handoffFinderFilterMddbExt.md create mode 100644 docs/2026/2026-04/2026-04-21/handoffFlatLayoutOcpSrp.md diff --git a/docs/2026/2026-04/2026-04-19/axNamingDictionary.md b/docs/2026/2026-04/2026-04-19/axNamingDictionary.md new file mode 100644 index 000000000..8a350e105 --- /dev/null +++ b/docs/2026/2026-04/2026-04-19/axNamingDictionary.md @@ -0,0 +1,361 @@ +--- +id: axNamingDictionary +type: inbox +slug: axNamingDictionary +title: ax 네이밍 딕셔너리 — 전수 조사 +tags: [ax, naming, dictionary, design-system] +created: 2026-04-19 +updated: 2026-04-19 +status: open +layer: styles +summary: ax() 시스템의 모든 축·값·prefix·role×surface 조합을 코드에서 전수 추출한 단일 참조표. SSOT는 src/styles/axPublic.ts + axPrivate.ts + rolePreset.ts. +--- + +# ax 네이밍 딕셔너리 — 전수 조사 + +> **SSOT 파일** +> - `src/styles/axPublic.ts` — Public 12축 타입·AxPublic discriminated union +> - `src/styles/axPrivate.ts` — Private 7축 타입·`AX_PRIVATE_KEYS` +> - `src/styles/rolePreset.ts` — `rolePresetTable` · `textStylePresetTable` +> - `src/styles/ax.ts` — prefix map(`prefixes`) · `ax()` entry +> - `src/styles/axRaw.ts` — `ax.raw()` escape hatch + `PRIVATE_PREFIXES` + +## 1. 축 × Prefix 표 (19축) + +### Public (12축 — `ax()`에 직접 전달 가능) + +| 키 | prefix | 타입 | +|---|---|---| +| `role` | `rl` | `AxRole` | +| `surface` | `sf` | `AxSurface` | +| `tone` | `tn` | `AxTone` | +| `textStyle` | `ts` | `AxTextStyle` | +| `content` | `ct` | `AxContent` | +| `interactive` | `ia` | `AxInteractive` | +| `layout` | `ly` | `AxLayout` | +| `placement` | `pl` | `AxPlacement` | +| `width` | `w` | `AxWidth` | +| `flex` | `fx` | `AxFlex` | +| `clamp` | `cl` | `AxClamp` | +| `aspect` | `ar` | `AxAspect` | + +### Private (7축 — `ax.raw()` 또는 `rolePreset` 경유만 도달) + +| 키 | prefix | 타입 | +|---|---|---| +| `padding` | `pd` | `AxPadding` | +| `gap` | `g` | `AxGap` | +| `shape` | `sh` | `AxShape` | +| `border` | `bd` | `AxBorder` | +| `icon` | `ic` | `AxIcon` | +| `square` | `sq` | `AxSquare` | +| `motion` | `mo` | `AxMotion` | + +### @removed (과거 존재 → 현재 폐기) + +| 키 | 폐기 사유 (commit refs: §1 #4~#6, 2026-04-19 ax-textstyle-ssot-prd) | +|---|---| +| `scroll` | `AxLayout`의 `scroll`/`scroll-x`/`clip`로 흡수 | +| `cs` | `textStyle` 4-tuple(font-size·cs-h·cs-py·cs-px)이 SSOT | +| `text` | surface→text pairing을 CSS layer가 자동 파생 (Material on-*) | +| `weight` | surface→text pairing CSS가 SSOT | +| `opacity` | CSS layer 자동 파생 | +| `state` | Zone(surface+material+elevation) cascade가 결정 | + +## 2. 값 전수 enum (Public) + +### `AxRole` (10 브랜치) + +| 값 | 설명 | surface 필수? | +|---|---|---| +| `control` | 인터랙티브 컨트롤 (버튼·입력·탭) | ✅ (strict) | +| `control-group` | 컨트롤 묶음 컨테이너 (패널·툴바·그룹) | ❌ (silent) | +| `item` | 리스트/트리/탭 행 | ❌ (silent) | +| `cell` | grid 칸 컨테이너 + 내부 control 묶음 | ✅ (strict) | +| `badge` | 뱃지 | ✅ (strict) | +| `utility` | default 브랜치 (role 키 생략 시) — 레이아웃/타이포 전용 | ❌ | +| `tip` | 툴팁/오버레이 보조 표면 | ✅ (strict) | +| `metric` | 숫자 강조 표시 (신규) | ✅ (strict) | +| `signal` | 시스템→사용자 알림 (신규) | ✅ (strict) | +| `placeholder` | 로딩/Skeleton (신규) | ❌ (optional) | + +> **strictRoles** = `{ control, badge, tip, cell, metric, signal }` — surface 지정 + preset all-miss 시 `resolveRolePreset` throw (현재 Phase 1-a G-5 임시 warn). + +### `AxSurface` (role-별 subset 잠금, 10개 union) + +| role | 허용 surface | +|---|---| +| `control` | `action` · `ghost` · `input` · `placeholder` | +| `control-group` | `sunken` · `base` · `raised` · `overlay` · `ghost` | +| `item` | `ghost` · `display` | +| `cell` | `display` · `ghost` · `input` | +| `badge` | `display` · `ghost` · `overlay` · `placeholder` | +| `tip` | `inverted` · `overlay` | +| `metric` | `display` · `ghost` · `sunken` | +| `signal` | `display` · `overlay` · `ghost` | +| `placeholder` | `sunken` · `ghost` · `display` | + +> 전체 surface 어휘 union: `action · ghost · input · placeholder · display · overlay · inverted · sunken · base · raised` + +### `AxTone` (10값, 5색 × 2강도) + +``` +accent · danger · success · warning · neutral +accent-dim · danger-dim · success-dim · warning-dim · neutral-dim +``` + +### `AxTextStyle` (9값) + +``` +hero · display · page · section · label · body · caption · code · overline +``` + +### `AxContent` (4값) + +``` +text · code · bubble · icon +``` + +### `AxInteractive` (6값) + +``` +item · tab · check · cell · input · button +``` + +> 인터랙티브 아이템은 이 중 하나를 필수 선언. `surface: 'ghost'`는 독립 버튼/컨트롤 한정. + +### `AxLayout` (18값) + +``` +row · center · bar · spread · stack +scroll · scroll-x · clip +fill · row-fill · wrap +grid-2 · grid-3 · grid-4 · grid-5 · grid-7 · table +self-start · self-end · self-center +``` + +### `AxPlacement` (18값) + +``` +above · below · bottom · bottom-center · center +top-start · top-end · viewport · sticky +anchor-below · anchor-below-start · anchor-above · anchor-end · anchor-start +relative +float-top-start · float-top-center · float-bottom-center · float-bottom +``` + +### `AxWidth` (8값) + +``` +full · auto · fit · sm · md · lg · xl · prose +``` + +### `AxFlex` (3값) + +``` +none · auto · 1 +``` + +### `AxClamp` (5값) + +``` +1 · 2 · 3 · 4 · pre +``` + +### `AxAspect` (3값) + +``` +1 · video · card +``` + +## 3. 값 전수 enum (Private) + +### `AxPadding` (6값) + +``` +none · xs · sm · md · lg · xl +``` + +### `AxGap` (6값) + +``` +xs · sm · md · lg · xl · 2xl +``` + +### `AxShape` (9값) + +``` +none · 2xs · xs · sm · md · lg · xl · pill · island +``` + +> `island` = 큰 radius + overflow clip 번들. `surface:'raised'`와 페어링 시 "sunken 속 떠오른 섬" 시멘틱 (단순 radius 스케일 아님). + +### `AxBorder` (9값, full 5 + side 4) + +``` +# full +subtle · default · strong · dashed · ring +# side +bottom · top · start · end +``` + +### `AxIcon` (4값) + +``` +xs · sm · md · lg +``` + +### `AxSquare` (6값) + +``` +xs · sm · md · lg · xl · 2xl +``` + +### `AxMotion` (9값) + +``` +pulse · spin · fade-in · slide-up +fade-slide-in · slide-in · scale-in · blink · shimmer +``` + +## 4. `rolePresetTable` 전수 (cascade seed) + +**키 형식**: `role` · `role.surface` · `role.surface.interactive` · `role.surface.content` + +### control.* + +| key | 주입 Private | +|---|---| +| `control.action` | `{ shape: 'md', gap: 'xs' }` | +| `control.action.text` | `{}` | +| `control.action.icon` | `{}` | +| `control.action.button` | `{ gap: 'sm' }` | +| `control.ghost` | `{ shape: 'md' }` | +| `control.ghost.icon` | `{}` | +| `control.ghost.text` | `{ shape: 'sm' }` | +| `control.ghost.tab` | `{ shape: 'sm' }` | +| `control.input` | `{ shape: 'sm', border: 'default' }` | +| `control.input.text` | `{}` | +| `control.input.input` | `{ shape: 'md' }` | +| `control.placeholder` | `{ shape: 'md', motion: 'spin' }` | + +### control-group.* + +| key | 주입 Private | +|---|---| +| `control-group.overlay` | `{ gap: 'xs', shape: 'xl' }` | +| `control-group.raised` | `{ gap: 'xs', shape: 'island' }` | +| `control-group.sunken` | `{ gap: 'sm' }` | + +### item.* + +| key | 주입 Private | +|---|---| +| `item.placeholder` | `{ gap: 'sm', motion: 'shimmer' }` | + +### cell.* + +| key | 주입 Private | +|---|---| +| `cell.display` | `{ gap: 'xs' }` | +| `cell.ghost` | `{}` | +| `cell.input` | `{ shape: 'sm', border: 'default' }` | + +### badge.* + +| key | 주입 Private | +|---|---| +| `badge.display` | `{ shape: 'pill' }` | +| `badge.ghost` | `{}` | +| `badge.overlay` | `{ shape: 'md' }` | +| `badge.placeholder` | `{ shape: 'pill', motion: 'pulse' }` | + +### tip.* + +| key | 주입 Private | +|---|---| +| `tip.inverted` | `{ shape: 'sm', motion: 'fade-slide-in' }` | +| `tip.overlay` | `{ shape: 'sm', motion: 'fade-slide-in' }` | + +### metric.* + +| key | 주입 Private | +|---|---| +| `metric.display` | `{ shape: 'sm' }` | +| `metric.display.text` | `{}` | +| `metric.display.bubble` | `{ shape: 'md' }` | +| `metric.ghost` | `{}` | +| `metric.sunken` | `{ shape: 'sm' }` | + +### signal.* + +| key | 주입 Private | +|---|---| +| `signal.display` | `{ shape: 'md' }` | +| `signal.display.button` | `{ shape: 'md' }` | +| `signal.overlay` | `{ shape: 'md', motion: 'fade-slide-in' }` | +| `signal.ghost` | `{}` | + +### placeholder.* + +| key | 주입 Private | +|---|---| +| `placeholder.sunken` | `{ shape: 'sm', motion: 'shimmer' }` | +| `placeholder.ghost` | `{ motion: 'pulse' }` | +| `placeholder.display` | `{ shape: 'sm', motion: 'shimmer' }` | + +## 5. `textStylePresetTable` 전수 + +모든 9개 textStyle(`hero` · `display` · `page` · `section` · `label` · `body` · `caption` · `code` · `overline`) 엔트리는 현재 `{}`. surface→text pairing은 CSS layer(§4c)가 SSOT이므로 textStyle은 Private 주입 없음. 향후 textStyle-별 padding 등 등록 여지로만 존재. + +## 6. 호출 경로 분기 + +``` +input: Axes (Public discriminated union) + │ + ├─ ax(axes) ──► 1) Private 키 오염 검사 (현 Phase 1-a G-5: warn) + │ 2) resolveRolePreset(role, surface, content, interactive) + │ └─ cascade: role → role.surface → role.surface.interactive + │ → role.surface.content + │ └─ all-miss + strictRole + surface → warn (장차 throw) + │ 3) resolveTextStylePreset(textStyle) — 현재 전부 {} + │ 4) merge: textPreset ← rolePreset ← input (구체 override) + │ 5) className 합성: prefix-value 공백 구분 + │ + └─ ax.raw(privateOnly) ──► Private 7축 직접 주입 (유일 경로) + └─ non-private 키 입력 시 dev throw +``` + +## 7. 합성 규칙 요약 + +- **override 순서**: `textStylePreset` (일반) → `rolePresetTable` hit (구체) → `input` (Public 명시) +- **className 포맷**: `{prefix}-{value}` 공백 구분 단일 문자열 +- **Public/Private 키 교집합 공집합** 불변식 (이름 충돌 금지) +- **`ax.raw()`는 Private만** — Public 입력 시 dev throw +- **`role` 키 부재** → `utility` 브랜치로 default brand (1,701 role-less 호출 보호) + +## 8. 참조 체인 (변경 시 동반 수정 지점) + +``` +axPublic.ts (타입 SSOT) + ├─► ax.ts prefixes (Public 12 엔트리) + ├─► rolePreset.ts RolePresetKey 템플릿 + └─► ui/ AriaComponentProps 타입 + +axPrivate.ts (타입 SSOT + AX_PRIVATE_KEYS) + ├─► ax.ts PRIVATE_KEY_SET (런타임 가드) + ├─► axRaw.ts PRIVATE_PREFIXES (Private 7 엔트리) + ├─► guardOsPatterns.mjs (정적 검사) + └─► scanOsViolations.mjs (감사 스캐너) + +rolePresetTable (조합 SSOT) + └─► "조합 변경은 이 파일 수정만으로 완결" (§1 불변식 #4) +``` + +## 9. 주의 사항 + +- **Public 축 신설 전에** subset 확장 · 테마 override · 기본값 주입 시도 (`feedback_axis_minimum_via_subset_expansion`) +- **Public 축에 원리 종속축 해치 금지** — 의도축(role/surface/cs)만, override는 `ax.raw()` 단일 해치 (`feedback_public_axis_no_hatch`) +- **축/값 이름은 디자인-중립** — 미감 지시어 금지 (`feedback_naming_design_neutral`) +- **Private 키 ui/pages 직접 import 금지** — `guardCssAxes` 정적 검사 +- **Phase 1-a G-5**: Bundle D/E 마이그레이션 중 throw → warn 완화 상태. Bundle E 완료 후 throw 재승격 예정 (§1 #7, #9) diff --git a/docs/2026/2026-04/2026-04-19/gmailMockup.md b/docs/2026/2026-04/2026-04-19/gmailMockup.md new file mode 100644 index 000000000..28fa8a0ba --- /dev/null +++ b/docs/2026/2026-04/2026-04-19/gmailMockup.md @@ -0,0 +1,128 @@ +--- +id: gmailMockup +type: decision +slug: gmailMockup +title: Gmail — Mockup +tags: [mockup, ux, fidelity-ladder, gmail] +created: 2026-04-19 +updated: 2026-04-19 +status: open +layer: design +phase: 5 +--- + +# Gmail Mockup + +Reference: http://localhost:5173/showcase/gmail (existing 14-defect implementation) +Goal: Climb the fidelity ladder (Data → Importance → Low-fi → Mid-fi → Hi-fi) so defects are caught at cheap fidelity tiers before implementation. + +## Phase 1 — Data Inventory + +- [x] `src/pages/__mockup__/gmail/schema.ts` — MailEntry / ThreadMessage / AttachmentEntry / FolderEntry +- [x] `src/pages/__mockup__/gmail/fixtures.ts` — 18 mail rows (3 edge-crafted + 15 faker seed=42), 11 folders, 3 thread messages, 2 attachments +- [x] Edge cases: `edge-max` (99ch subject, 3 labels), `edge-loaded` (busy row), `edge-min` (3-char sender) +- [x] State fixtures: populated / empty / loading (8 skeleton) / error message +- [x] `DataInspector.tsx` at `/__mockup__/gmail/data` +- [x] Screenshot: `screenshots/mockup-gmail-data.png` +- [ ] User approval + +### LLM self-review (Phase 1 screenshot) + +- **Observed measured ranges**: + - from: 3–22 ch (typical 14) + - subject: 2–99 ch (typical 43) + - preview: 2–170 ch (typical 106) + - labels: 0–3 chips, each 4–10 ch + - unread: 7/18 · starred: 10/18 · hasAttachment: 9/18 +- **Defects in DataInspector itself** (not the future Gmail UI): + - Example column heavily truncated ("E..", "A..", "1..") because flex:'1' share is too narrow at the current viewport. Data is still captured in fixtures, but Phase 2+ should not repeat this mistake. + - AppShell icon rail leaks into the mockup route. Future Phase 3–5 screens should register routes outside AppShell or apply a shell-clean fidelity theme. + +## Phase 2 — Importance Matrix + +| Field | 1st | 2nd | 3rd | hidden on list | +|---|---|---|---|---| +| from | ● | | | | +| subject | | ● | | | +| unread | | ● | | (state modifier) | +| preview | | | ● | | +| date | | | ● | | +| hasAttachment | | | ● | | +| labels | | | ● | | +| starred | | | ● | | +| important | | | ● | | +| id | | | | ● | +| email | | | | ● (detail only) | + +Reasons: 1st = `from` (fastest triage signal, Gmail/Superhuman/Missive de facto). 2nd = `subject` + `unread` (unread is orthogonal state elevating subject). 3rd = metadata. + +User approved: yes + +## Phase 3 — Low-fi Wireframe + +- [x] `Wireframe.tsx` + `Wireframe.module.css` (grayscale filter + monospace) +- [x] Route `/__mockup__/gmail/low` registered **outside AppShell** (shell-clean) +- [x] Screenshot: `screenshots/mockup-gmail-low.png` +- [x] LLM self-review (2 defects found + fixed in same turn) +- [ ] User approval + +### LLM self-review (Phase 3) + +**Initial defects found and fixed:** +- TopBar cluster rail-stuck left → fixed: search wrapped in `flex:'1' layout:'center'` → middle-aligned +- 3-pane body not filling viewport width → fixed: added `width: 'full'` to page + body row + +**Remaining structural observations (not defects, acceptance notes):** +- Vertical viewport-fill is unachievable with current ax axes (no height axis, no `100vh`). Wireframe renders at natural height; area below is empty black. Phase 4+ inherit the same limit unless we accept module.css `height: 100vh` as an explicit last-mile exception or extend ax with a `size` axis. +- Shell-clean route works: no activity-bar rail leaking into the mockup. +- Boxes show intended layout: TopBar (7 slots with middle search), Sidebar (MAILBOX + LABELS groups with unread counts), MailList (tabs + toolbar + 12 rows + pagination), Detail (toolbar + subject + from + thread collapse + body + attachments + actions). + +### Phase 3 findings to carry forward + +- Field widths in list row: sender `width:'sm'` fits 14ch comfortably; subject gets no fixed width and will compete with preview at mid-fi. **Decision at Phase 4**: subject should be `width:'md'` or explicit flex share so preview doesn't eat its space. +- Chip is single placeholder; actual data has 0–3 chips per row. **Check at Phase 4** whether chips overflow horizontally at the narrowest list width. +- Detail subject box is currently equal-weight with other boxes. **Phase 5 will size it via `textStyle: 'page'`** (decision carried from Phase 2 matrix). + +## Phase 4 — Mid-fi + +- Screenshot: `screenshots/mockup-gmail-mid.png` +- Real fixtures rendered (18 mails, 11 folders, 3 thread, 2 attachments) +- Findings: text contrast weak, edge-max subject compresses row, date wraps vertically → carried to Phase 5 + +## Phase 5 — Hi-fi + +- Screenshot: `screenshots/mockup-gmail-hi.png` +- Full ax axis applied per Importance Matrix +- Remaining defects: read-row text muted too faintly, detail toolbar fades mid-row, Reply-all/Forward ghost nearly invisible → Visual Contract below + +## Visual Contract + +### List row +- **sender-no-truncation**: `.list-row` sender span width='sm' flex='none' → sender up to 14ch typical renders without ellipsis +- **unread-contrast**: unread row surface='display', read row surface='ghost' → Δ background luminance ≥ WCAG 4.5:1 +- **chip-flex-none**: `Badge` elements in list row must have computed flex-shrink = 0 +- **chip-fits-content**: chip width = content width at 2–10 char labels, no truncation +- **date-fixed-width**: date span width='sm' flex='none', no line wrap +- **star-visible-filled**: `` when starred=true → computed opacity > 0.7 + +### Hierarchy (monotonic) +- fontSize(textStyle='page') > fontSize('label') ≥ fontSize('body') > fontSize('caption') +- caption color luminance < body color luminance (dim tone enforced) + +### Detail +- **subject-page-style**: detail subject uses textStyle='page' +- **attachments-cell**: each attachment wrapped in role='cell' surface='display' +- **reply-primary**: Reply uses surface='action' tone='accent'; Reply all/Forward use surface='ghost' +- **toolbar-uniform**: all 6 toolbar actions (Archive/Delete/Spam/Move/Labels/Snooze) have identical textStyle='caption' with equal contrast — no fade pattern + +### TopBar +- **search-centered**: search bar centered between logo cluster and actions cluster +- **search-width-lg**: search input container width='lg' + +### Sidebar +- **compose-fab**: Compose uses surface='action' tone='accent', full-width bar +- **unread-badge**: folder with unreadCount uses Badge tone='accent-dim' +- **selected-folder**: current folder uses surface='display' (distinct from ghost default) + +### Theme +- **light-theme-contrast**: body text on light theme must meet WCAG 4.5:1 (this run shows fail → theme token fix required before /do) diff --git a/docs/2026/2026-04/2026-04-20/cmuxPrd.md b/docs/2026/2026-04/2026-04-20/cmuxPrd.md new file mode 100644 index 000000000..e7d25c087 --- /dev/null +++ b/docs/2026/2026-04/2026-04-20/cmuxPrd.md @@ -0,0 +1,349 @@ +--- +name: cmuxPrd +type: prd +layer: pages +project: cmux +status: draft +date: 2026-04-20 +tags: [cmux, chat, flatlayout, integration] +--- + +# cmux 통합 — PRD + +> **Discussion**: routes doubt 세션 2026-04-20 (대화) +> **산출물 유형**: 페이지 통합 + 기본 fill 정책 변경 +> **규모 추정**: 신규 1, 수정 6, 재사용 다수, 삭제 2 + +## §0 요구사항 (from doubt) + +- 해결책 ⑪: `/chat` + `/chat/entities` + `/cmux/preview` → **`/cmux` 단일 라우트**로 통합. **모든 분할 패널의 기본 fill = chat UI (SurfaceLeaf)**. +- 제약 ⑦: + - 기존 websocket chat 기능 손실 없음 (PageAgentChat이 본체) + - preview 시나리오 시뮬레이션(`?scenario=`) + entities inspector 기능 유지 (URL 쿼리로 뷰 전환) + - cmux layout 자체(sidebar + tabgroup)는 이미 FlatLayout 기반 → 구조 변경 최소 +- 보유 자산 ⑧: + - `PageAgentChat` — cmux 삼계층(Workspace/Surface/SplitPane) 이미 FlatLayout로 구현 + - `SurfaceLeafWidget` — tab content widget, 이미 chat pane 렌더 + - `CmuxPreviewScenarios`, `cmuxPreviewWidgets` — 선언 → 픽셀 시뮬 인프라 + - `PageChatEntities` — schema/live/fixtures/commands TreeGrid inspector + - `layoutCommands.splitHere` — 분할 시 새 pane 생성 API + +## §1 책임 분해 + +| # | 책임 | 파일 경로 | 레이어 | 기존/신규 | 의존 | +|---|------|----------|-------|----------|------| +| 1 | cmux 기본 레이아웃 (sidebar + tabgroup) | `src/pages/chat/PageAgentChat.tsx`의 `chatBaseLayout` | pages | 재사용 | — | +| 2 | 분할 시 기본 content = chat 정책 | `src/interactive-os/layout/layoutCommands.ts` (`splitHere`) | layout | 수정 | — | +| 3 | preview 시나리오 loader (URL `?preview=`) | `src/pages/cmux/cmuxPreviewLoader.ts` | pages | 신규(from cmux-preview/) | — | +| 4 | entities inspector 패널 widget | `src/pages/cmux/EntitiesInspectorWidget.tsx` | pages | 수정(from PageChatEntities) | — | +| 5 | cmux 페이지 컴포넌트 (URL 기반 view 분기) | `src/pages/cmux/PageCmux.tsx` | pages | 수정(from PageAgentChat) | 1, 3, 4 | +| 6 | router 갱신 — `/cmux` 단일 + 구 라우트 redirect | `src/router.tsx` | app | 수정 | 5 | +| 7 | ActivityBar — `/cmux` 단일 항목 | `src/ActivityBar.tsx` | app | 수정 | 6 | +| 8 | 삭제 — `pages/cmux-preview/` (loader로 이동 후) | `src/pages/cmux-preview/**` | pages | 삭제 | 3 | +| 9 | 삭제 — `pages/chat/` 폴더명 `pages/cmux/`로 rename + PageChatEntities 제거 | `src/pages/chat/` → `src/pages/cmux/` | pages | 이동 | 5 | + +### 탐색 증거 + +- `Read("pages/chat/PageAgentChat.tsx")` → 이미 FlatLayout + cmux 구조 완비. "기본 fill = chat"의 75%는 이미 SurfaceLeafWidget의 기본 contentType='chat'로 성립. +- `Read("pages/cmux-preview/PageCmuxPreview.tsx")` → 시나리오 기반 FlatLayout provider wrapper, 10~20줄 수준. 흡수 비용 낮음. +- `Read("pages/chat/PageChatEntities.tsx")` → SplitPane + TreeGrid 4개로 조립. widget 전환 가능. +- `Grep("splitHere\|splitTab")` → `interactive-os/layout/layoutCommands.ts`에서 분할 로직 소유 (§1.2 수정 지점). +- `CATALOG.md`: cmux preview/entities inspector widget 없음 → §1.3, §1.4 신규/수정 정당. + +**완성도**: 🟢 + +## §2 Contract + +### `src/pages/cmux/cmuxPreviewLoader.ts` (신규, from cmux-preview) + +```ts +import type { NormalizedData } from '@os/store/types' +import type { CmuxPreviewContext } from '../cmux-preview/cmuxPreviewContext' + +export interface CmuxPreviewScenario { + id: string + label: string + page: NormalizedData + context: CmuxPreviewContext +} + +/** URL `?preview=` 파싱. id 없거나 잘못되면 null. */ +export function parsePreviewQuery(search: string): string | null + +/** id → scenario. 없으면 null. */ +export function getScenario(id: string | null): CmuxPreviewScenario | null +``` + +### `src/pages/cmux/EntitiesInspectorWidget.tsx` (수정 from PageChatEntities) + +```tsx +/** + * Entities inspector — widget 형태로 FlatLayout tab 안에서 렌더. + * PageChatEntities.tsx의 SplitPane+TreeGrid 4개 조립을 그대로 widget으로 포장. + * URL `?view=entities`일 때 cmux canvas의 tab content로 교체된다. + */ +export function EntitiesInspectorWidget(): JSX.Element +``` + +### `src/pages/cmux/PageCmux.tsx` (수정 from PageAgentChat) + +```tsx +/** + * cmux 통합 페이지. + * - 기본: chat workspace (기존 PageAgentChat 동작) + * - ?preview=: FlatLayout data/context를 scenario로 교체 (시뮬 모드) + * - ?view=entities: 초기 tab의 content widget을 EntitiesInspector로 교체 + * + * 세 모드 모두 동일 cmux 뼈대(sidebar + tabgroup) 사용. + */ +export default function PageCmux(): JSX.Element +``` + +### `src/interactive-os/layout/layoutCommands.ts` (수정) + +```ts +/** + * splitHere: 분할 시 새로 생기는 tab의 기본 contentType/widget 정책. + * @change — 이전: 빈 placeholder / 이후: contentType='chat', widget='SurfaceLeaf' + * @invariant 기본값은 registry에 SurfaceLeaf가 등록된 경우에만 적용. 없으면 빈 placeholder로 폴백. + */ +export function splitHere(ctx: LayoutCommandCtx, axis: 'horizontal' | 'vertical'): void +``` + +**완성도**: 🟢 + +## §3 WHY + +1. **/chat이 이미 cmux 그 자체.** `PageAgentChat`의 `chatBaseLayout`은 FlatLayout으로 cmux 삼계층을 완성했다. `/cmux/preview`는 그 구조의 시뮬 버전, `/chat/entities`는 그 store의 inspector 버전. **본체-실험-inspector라는 3 역할이 같은 앱의 뷰 모드일 뿐이다.** +2. **기본 fill = chat은 cmux의 정체성.** 사용자가 Mod+D로 분할했을 때 비어있는 placeholder가 뜨면 "다음 뭐 꽂지"를 고민하게 된다. 분할 = 새 대화 시작이라는 cmux 본연의 UX에 맞추려면 기본 fill이 chat이어야 한다. +3. **URL query로 뷰 분기**는 pages 파일 수를 최소화하면서도 각 모드의 북마크/공유를 유지한다. PageCmuxPreview의 `?scenario=` 패턴을 확장해 `?preview=` / `?view=entities`로 통일. + +## §4 HOW + +```mermaid +flowchart TD + U[/cmux URL] --> Q{query parse} + Q -->|?preview=x| P[load scenario x] + Q -->|?view=entities| E[tab content = EntitiesInspector] + Q -->|none| C[default chat layout] + P --> FL[FlatLayout] + E --> FL + C --> FL + FL --> SB[sidebar widget] + FL --> TG[tabgroup] + TG -->|default contentType=chat| SL[SurfaceLeaf widget = ChatPane] + TG -.split.-> TG2[new tab contentType=chat] +``` + +## §5 WHAT (의존 순서) + +### W1. splitHere 기본값 변경 (§1.2) + +**의존**: — +**파일**: `src/interactive-os/layout/layoutCommands.ts` + +분할 시 새로 생성되는 tab의 `contentType`/`contentRef` 기본값을 `'chat'` / `''`로, content widget을 `'SurfaceLeaf'`로 설정. registry에 `SurfaceLeaf`가 없을 때만 빈 placeholder로 폴백. + +```ts +const DEFAULT_SPLIT_CONTENT = { contentType: 'chat', widget: 'SurfaceLeaf' } as const + +function hasDefaultWidget(registry: WidgetRegistry | undefined): boolean { + return !!registry?.has?.(DEFAULT_SPLIT_CONTENT.widget) +} + +export function splitHere(ctx: LayoutCommandCtx, axis: 'horizontal' | 'vertical'): void { + // ... 기존 분할 로직 ... + const newTab = hasDefaultWidget(ctx.registry) + ? { type: 'tab', label: 'Chat', contentType: 'chat', contentRef: '' } + : { type: 'tab', label: 'Untitled', contentType: 'widget', contentRef: '' } + // ... insert newTab ... +} +``` + +**검증**: vitest — `splitHere({registry: {has: () => true}}, 'horizontal')` → 새 tab의 `contentType === 'chat'`. registry 없을 때 `'widget'`. + +### W2. cmuxPreviewLoader (§1.3) + +**의존**: — +**파일**: `src/pages/cmux/cmuxPreviewLoader.ts` + +```ts +import { getScenario as getRaw } from '../cmux-preview/cmuxPreviewScenarios' + +export function parsePreviewQuery(search: string): string | null { + return new URLSearchParams(search).get('preview') +} + +export function getScenario(id: string | null) { + return id ? getRaw(id) : null +} + +// 이 파일 확정 후 cmux-preview/ 내부 파일들을 pages/cmux/로 이동 +// (cmuxPreviewScenarios.ts, cmuxPreviewWidgets.tsx, cmuxPreviewContext.ts) +``` + +**검증**: `parsePreviewQuery('?preview=split')` → `'split'`. `getScenario(null)` → `null`. + +### W3. EntitiesInspectorWidget (§1.4) + +**의존**: — +**파일**: `src/pages/cmux/EntitiesInspectorWidget.tsx` + +PageChatEntities.tsx의 JSX를 그대로 widget 함수로 포장. export 방식만 변경. + +**검증**: screen test — `/cmux?view=entities` → Schema/Live/Commands TreeGrid 렌더. + +### W4. PageCmux (§1.5) + +**의존**: W1, W2, W3 +**파일**: `src/pages/cmux/PageCmux.tsx` + +```tsx +import { useMemo } from 'react' +import { useLocation } from 'react-router-dom' +import { FlatLayout } from '@os/ui/FlatLayout' +import { defineLayout } from '@os/layout/flatLayout' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' +import { useActiveSession, useChatSessions } from './chatStore' +import { ChatProvider } from './chatContext' +import { WorkspaceSidebarWidget, SurfaceLeafWidget } from './chatWidgets' +import { ChatKeybindingsWidget } from './chatKeybindings' +import { parsePreviewQuery, getScenario } from './cmuxPreviewLoader' +import { EntitiesInspectorWidget } from './EntitiesInspectorWidget' +import { cmuxPreviewWidgets } from './cmuxPreviewWidgets' +import { CmuxPreviewProvider } from './cmuxPreviewContext' +import './PageAgentChat.css' + +const defaultCmuxLayout = defineLayout({ /* 기존 chatBaseLayout 그대로 */ }) + +function makeEntitiesLayout() { + return defineLayout({ + entities: { + ...defaultCmuxLayout.entities, + 't1-body': { data: { type: 'widget', widget: 'EntitiesInspector' } }, + }, + }) +} + +const cmuxWidgets = createWidgetRegistry({ + WorkspaceSidebar: WorkspaceSidebarWidget, + SurfaceLeaf: SurfaceLeafWidget, + EntitiesInspector: EntitiesInspectorWidget, +}) + +export default function PageCmux() { + const { search } = useLocation() + const previewId = parsePreviewQuery(search) + const view = new URLSearchParams(search).get('view') + + // Preview mode: scenario 기반 (chat store 무시) + if (previewId) { + const scenario = getScenario(previewId) + if (!scenario) return
Unknown scenario: {previewId}
+ return ( + + + + ) + } + + // Default / entities mode: 실제 chat store + const sessions = useChatSessions() + const activeSession = useActiveSession() + const chatCtx = useMemo(() => ({ + sessions, activeSessionId: activeSession?.id ?? null, + modifiedFiles: [], workspaces: [{ id: 'ws-1', label: 'Claude', status: 'idle' as const, unreadCount: 0 }], + activeWorkspaceId: 'ws-1', + }), [sessions, activeSession]) + + const layout = view === 'entities' ? makeEntitiesLayout() : defaultCmuxLayout + + return ( + + + + + + ) +} +``` + +**검증**: +- `/cmux` → 기존 `/chat` 동작 동일 +- `/cmux?view=entities` → 초기 tab이 EntitiesInspector +- `/cmux?preview=split` → scenario 렌더 +- Mod+D 분할 시 새 tab이 chat (SurfaceLeaf) + +### W5. router + redirect (§1.6) + +**의존**: W4 +**파일**: `src/router.tsx` + +```ts +{ path: '/cmux', lazy: () => import('./pages/cmux/PageCmux').then(m => ({ Component: m.default })) }, +// redirect for backward compat +{ path: '/chat', element: }, +{ path: '/chat/entities', element: }, +{ path: '/cmux/preview', element: }, +// /cmux/preview?scenario=x → /cmux?preview=x 는 redirect loader에서 처리하거나 수동 링크 갱신 +``` + +**검증**: 수동 — 구 URL 방문 시 redirect 작동. + +### W6. ActivityBar (§1.7) + +**의존**: W5 +**파일**: `src/ActivityBar.tsx` + +```ts +// 'chat' 항목의 path를 '/cmux'로 변경. id는 'cmux'로 rename 권장(navPaths 일치 유지). +{ id: 'cmux', label: 'cmux', icon: MessageSquare, path: '/cmux' }, +// 제거: cmux-preview (보조/진행중 섹션) +``` + +**검증**: 네비 클릭 → `/cmux` 진입. + +### W7. 파일 이동/삭제 (§1.8, §1.9) + +**의존**: W4, W5, W6 +**파일**: `git mv` + `rm -rf` + +1. `git mv src/pages/chat src/pages/cmux` +2. `git mv src/pages/cmux-preview/cmuxPreview*.ts{,x} src/pages/cmux/` +3. `rm src/pages/cmux-preview/PageCmuxPreview.tsx` → 디렉토리 빔 → `rmdir` +4. `rm src/pages/cmux/PageAgentChat.tsx` (PageCmux가 대체) +5. `rm src/pages/cmux/PageChatEntities.tsx` (EntitiesInspectorWidget이 대체) +6. PageAgentChat.css → `PageCmux.css`로 rename +7. 모든 `@/pages/chat/`, `@/pages/cmux-preview/` import 경로 일괄 치환 + +**검증**: `Grep("pages/chat|pages/cmux-preview")` 0건. typecheck pass. dev server에서 `/cmux`, `/cmux?view=entities`, `/cmux?preview=split` 모두 정상. + +## §6 원칙 감시자 결과 + +- ✅ 레이어 의존 순서: layout(W1) ← pages(W2~W4). 역방향 없음. +- ✅ 있는 걸로 먼저: chatBaseLayout, SurfaceLeafWidget, cmuxPreviewScenarios, PageChatEntities JSX 모두 재사용. +- ✅ 파일명 규칙: Page*, *Widget, *Loader — pages 관례 준수. +- ✅ FlatLayout 단일 레이아웃 엔진 사용, SplitPane 직접 조립 없음 (단 W3 이관 시 기존 SplitPane 사용은 위젯 내부라 수용 가능; 추후 FlatLayout sub-layout으로 리팩토링 후속). +- ⚠️ W1은 기존 sight unseen 구현을 확인해야 안전 — `layoutCommands.ts`의 실제 구조 확인 후 착수. +- ⚠️ W4 `useChatSessions`/`useActiveSession`을 preview 분기 이전에 호출하지 않도록 조건부 hook 순서 주의 (React rules of hooks). 필요 시 별도 컴포넌트로 split. + +--- + +**전체 완성도**: 🟢 (W1, W4의 주의점 해소 후 착수) + +## 착수 순서 요약 + +1. W1 `splitHere` 기본값 변경 (+ unit test) +2. W2 `cmuxPreviewLoader` 신설 +3. W3 `EntitiesInspectorWidget` 작성 (PageChatEntities 이관) +4. W4 `PageCmux` 작성 — hook 순서 안전하게 분기 +5. W7 파일 이동/삭제 (`git mv`) +6. W5 router redirect +7. W6 ActivityBar 갱신 +8. dev server 수동 3모드 검증 + typecheck + 커밋 + +## studio PRD와의 관계 + +- studio = **선언적 UI 런타임의 쇼케이스** (조립/스트리밍 example) +- cmux = **선언적 UI 런타임의 프로덕션 앱** (실사용 chat workspace) +- 둘 다 FlatLayout을 SSOT로 공유 → `useLayoutStream`(studio §1.2) 등의 primitives는 cmux에서도 재사용 가능 (예: AI가 대화 중 UI를 스트리밍으로 내려보내는 시나리오). diff --git a/docs/2026/2026-04/2026-04-20/handoffInspectorSourcePreviewScroll.md b/docs/2026/2026-04/2026-04-20/handoffInspectorSourcePreviewScroll.md new file mode 100644 index 000000000..9eef578cd --- /dev/null +++ b/docs/2026/2026-04/2026-04-20/handoffInspectorSourcePreviewScroll.md @@ -0,0 +1,49 @@ +--- +id: handoffInspectorSourcePreviewScroll +type: handoff +slug: handoffInspectorSourcePreviewScroll +title: "Handoff: Inspector SourcePreview 전체 파일 뷰 + 스크롤" +tags: [handoff, devtools, inspector] +created: 2026-04-20 +updated: 2026-04-20 +status: open +summary: "Debug Inspector lock 시 뜨는 SourcePreview를 720×560로 확장하고 파일 전체를 스크롤 가능하게 전환" +--- + +# Handoff: Inspector SourcePreview 전체 파일 뷰 + 스크롤 + +> ⇧⌘D Inspector에서 컴포넌트를 lock하면 뜨는 코드 미리보기가 ±2줄·480×140 고정에 스크롤 불가였던 것을, 720×560 + 파일 전체 + 자동 라인 정렬로 확장. + +## 완료 + +| 커밋 | 내용 | +|------|------| +| `ba317a21` | feat(inspector): SourcePreview lock 시 전체 파일 뷰 + 스크롤 | + +- `src/devtools/inspector/SourcePreview.tsx` + - `PREVIEW_WIDTH 480→720`, `PREVIEW_HEIGHT 140→560` + - `extractSnippet` 제거, 파일 원문 그대로 `CodePreview`에 전달 + - 내부 스크롤 컨테이너 `ref`로 `[data-line="N"]` 찾아 중앙 정렬 + - 외곽 `pointerEvents: 'auto'`로 휠 입력 수용 +- `.claude/hooks/guardOsPatterns.mjs` + - `INSPECTOR_OVERLAY_FILES`에 `SourcePreview` 추가 (기존 `InspectorOverlay`·`MarqueeSelect`와 동일한 overlay 성격) + +## 남은 것 + +### 미완료 +- 없음 — 기능 완결. + +### 이후 +- 기존부터 dirty 상태인 파일들은 이 세션과 무관: `.claude/skills`, `src/interactive-os/ui/Tooltip.tsx`, `src/styles/ax.css`, `src/interactive-os/ui/cells/{EnumCell,SearchableCell}.tsx` (TS2322 AxTone 에러 2건 기존), untracked `guardMockupFidelity.mjs`·`MockupBar.tsx`·`gmailContext.ts` 외 mockup 관련 파일들. 해당 세션에서 마무리 필요. + +## 컨텍스트 + +- **관련 파일**: `src/devtools/inspector/{SourcePreview,InspectorOverlay,ComponentInspector}.tsx`, `src/interactive-os/ui/CodePreview.tsx` (`data-line` 속성 생성자) +- **주의**: + - `InspectorOverlay` 루트가 `pointerEvents: 'none'`이라 SourcePreview에서 명시적으로 `'auto'`를 켜야 휠 이벤트가 잡힌다. + - `fileCache`(module-scope Map)가 파일 원문을 캐싱한다 — 같은 파일 재lock 시 재요청 없음. + - `Mod+O`의 `QuickLookModal` 전체 보기는 그대로 유지. + +## 이어받는 법 + +추가 작업 없음. 세션을 그대로 닫아도 된다. 검증은 `pnpm dev` → ⇧⌘D → 아무 컴포넌트 lock → 박스 스크롤·라인 중앙 정렬 확인. diff --git a/docs/2026/2026-04/2026-04-20/handoffPersistPlugin.md b/docs/2026/2026-04/2026-04-20/handoffPersistPlugin.md new file mode 100644 index 000000000..64a96f3b8 --- /dev/null +++ b/docs/2026/2026-04/2026-04-20/handoffPersistPlugin.md @@ -0,0 +1,48 @@ +--- +type: handoff +status: consumed +date: 2026-04-20 +project: persist-plugin +tags: [handoff, persist, localStorage, os] +--- + +# Handoff — Persist Plugin 구현 + 소급 적용 + +## 맥락 + +PRD: `docs/2026/2026-04/2026-04-20/persistPluginPrd.md` +설계 결정 (/conflict): `loadPersisted` 헬퍼 + `persist` writer plugin 2 export. EffectContext read-only 보전. + +## 완료 (9/10) + +- ✅ **W1** `src/interactive-os/plugins/persist.ts` — `loadPersisted` + `persist` + `writePersisted` 3 export +- ✅ **W2** `src/interactive-os/plugins/persist.test.ts` — 6/6 green +- ✅ **W5** `src/pages/book/bookNavStore.ts` — `createModuleStore` 치환 +- ✅ **W6** `src/pages/writer/writerChatBridge.ts` — `createModuleStore` 치환 +- ✅ **W8** `src/pages/finder/PageFinder.tsx` — 4 state `usePersistedState`(parse whitelist) 치환 +- ✅ **W9** `src/interactive-os/ui/QuickOpen.tsx` — `usePersistedState`(raw string parse/serialize) 치환 +- ✅ **W4** `src/pages/studio/PageStudio.tsx` — `loadPersisted` + `usePersistedState`(parse layout) 치환 +- ✅ **W7** `src/pages/creator/PageComponentCreator.tsx` — 동일 패턴 치환 +- ✅ **W10** `src/interactive-os/CATALOG.md` — persist 등록 + Persistence 3층 경계 문서화 +- ✅ 추가 확장: `usePersistedState`에 `{ parse?, serialize? }` 옵션 / `loadPersisted`에 `parse?` 옵션 / `writePersisted` 헬퍼 (FlatLayout·Map 기반 store용) + +## Defer (1/10) + +- ⬜ **W3** `src/pages/cmux/chatStore.ts` — **pre-existing `useSyncExternalStore` 때문에 hook guardOsPatterns이 파일 내 모든 편집 차단**. persist 흡수하려면 `createCommandEngine` 전환이 선행돼야 함. 별도 사이클 필요. + +## 검증 + +- `pnpm test src/interactive-os/plugins/persist.test.ts` — 6/6 pass +- `pnpm typecheck` — persist 관련 0 에러 (pre-existing 에러는 book/KeyHintBar/fixtures 무관) + +## 다음 사이클 후보 + +1. **chatStore createCommandEngine 전환** — 완료되면 W3 persist 흡수 가능 +2. CATALOG의 "금지: pages·hooks가 `localStorage.*`를 직접 호출" 조항 활성화 (W3 완료 후) + +## Lesson + +**"N곳 호출 패턴 같음"은 재사용 모듈화의 필요조건이지 충분조건이 아니다.** 실제 흡수 전에 다음을 선검증: +1. 저장 format 호환성 (envelope `{v,d}` vs raw) — 치환하면 기존 저장물 손실 +2. hook/lint 규약 차단 — 파일 전체 rescan으로 무관 pre-existing 위반도 걸림 +3. 소비처 패턴 (engine/FlatLayout/Map) — 플러그인 적용 가능 여부 diff --git a/docs/2026/2026-04/2026-04-20/persistPluginPrd.md b/docs/2026/2026-04/2026-04-20/persistPluginPrd.md new file mode 100644 index 000000000..8f8597722 --- /dev/null +++ b/docs/2026/2026-04/2026-04-20/persistPluginPrd.md @@ -0,0 +1,353 @@ +--- +type: prd +layer: engine +status: draft +date: 2026-04-20 +tags: [os, plugin, persist, localStorage, refactor] +--- + +# Persist Plugin + localStorage 수렴 — PRD + +> **Discussion**: 본 세션 `/discuss` — useState+localStorage 개념 과적 → os 모듈로 승격 +> **산출물 유형**: 엔진(plugin 신설) + 리팩토링(12곳 수렴) + 문서(CATALOG 경계 정의) +> **규모 추정**: 신규 1개(plugin), 확장 0개, 수정 8개(pages/hooks/ui), 문서 1개, 재사용 3개(createModuleStore/usePersistedState/definePlugin) + +## §0 요구사항 (from discuss) + +- **해결책 ⑪**: `plugins/persist.ts` 신설 — `loadPersisted()` 헬퍼 + `persist()` writer plugin의 2 export 네임스페이스. engine 생성 이전 동기 로드, 생성 후 debounced write. EffectContext read-only 계약 보전. 3층 자산(모듈 단일값=createModuleStore / 컴포넌트 로컬=usePersistedState / engine NormalizedData=persist 네임스페이스)으로 localStorage 직접 호출 12곳 수렴. (설계 결정: /conflict에서 EffectContext 확장 vs 2조각 분리 대립을 urlSync 3조각 선례 기반 네임스페이스 묶음으로 해소) +- **제약 ⑦**: plugin 합성 규칙 준수 / 브라우저 전용 / 쓰기 실패 swallow (createModuleStore 선례) +- **보유 자산 ⑧**: `store/createModuleStore`, `primitives/usePersistedState`, `plugins/urlSync`(대칭 레퍼런스), `plugins/definePlugin` +- **1차 스코프 제외**: 멀티탭 sync(storage 이벤트), 5MB quota 초과 대응, IndexedDB 어댑터 + +## §1 책임 분해 + +| # | 책임 | 파일 경로 | 레이어 | 기존/신규 | 의존 | +|---|------|----------|-------|----------|------| +| 1 | NormalizedData 일부 pick → localStorage 직렬화/역직렬화 + 버전 migrate + debounce write | `src/interactive-os/plugins/persist.ts` | plugins | 신규 | — | +| 2 | persist plugin 단위 테스트 (동기 로드 / debounce / version mismatch) | `src/interactive-os/plugins/persist.test.ts` | plugins | 신규 | 1 | +| 3 | cmux chatStore: 수동 localStorage 제거 → persist plugin 적용 | `src/pages/cmux/chatStore.ts` | pages | 수정 | 1 | +| 4 | studio PageStudio: 수동 localStorage 제거 → persist plugin 적용 | `src/pages/studio/PageStudio.tsx` | pages | 수정 | 1 | +| 5 | bookNavStore: 수동 localStorage → createModuleStore로 치환 | `src/pages/book/bookNavStore.ts` | pages | 수정 | — | +| 6 | writerChatBridge: 수동 localStorage → createModuleStore로 치환 | `src/pages/writer/writerChatBridge.ts` | pages | 수정 | — | +| 7 | PageComponentCreator: useState+useEffect+localStorage → usePersistedState | `src/pages/creator/PageComponentCreator.tsx` | pages | 수정 | — | +| 8 | PageFinder: 4개 view pref useState+useEffect+localStorage → usePersistedState×4 | `src/pages/finder/PageFinder.tsx` | pages | 수정 | — | +| 9 | QuickOpen: persistKey 내부 useState+useEffect → usePersistedState | `src/interactive-os/ui/QuickOpen.tsx` | ui | 수정 | — | +| 10 | CATALOG.md — 3자 경계 문서화(모듈-전역/컴포넌트-로컬/engine-연동) + persist plugin 등록 | `src/interactive-os/CATALOG.md` | — | 수정 | 1 | + +> **수렴 제외**: `hooks/useResizer.ts`는 이미 내부 hook이 localStorage를 캡슐화한 재사용 단위 — 외부로 날코딩이 새지 않음. 1차 스코프 제외. + +### 탐색 증거 + +- `Grep localStorage src/` → 12 파일 (비-테스트 8곳, 테스트 3곳, 자산 자체 2곳: createModuleStore/usePersistedState) +- `ls src/interactive-os/plugins/` → autoscroll, cellDragSelect, clipboard, combobox, crud, dnd, dragResize, edit, focusHistory, focusRecovery, form, history, rename, scope, scroll, search, spatial, typeahead, urlSync, workspaceStore, zodSchema — **persist 없음 확인** +- `CATALOG.md` plugins 섹션 — persist 누락 확인 +- `urlSync.ts` 읽음 → `definePlugin({ name, middleware })` + `useEffect` ctx 패턴이 대칭 레퍼런스 +- `definePlugin.ts` 읽음 → `useEffect(ctx)`가 engine init/subscribe hook, `middleware`가 command post-processing hook — persist의 load/write 두 갈래에 매핑됨 + +**완성도**: 🟢 + +## §2 Contract + +> **설계 결정 (from /conflict)**: `EffectContext`는 read-only 계약을 보전한다. persist는 **load 헬퍼 + writer plugin 2 export**를 같은 파일에 묶어 1급 시민 네임스페이스로 제시. urlSync(`getInitialFromUrl` + `urlSync()`) 선례와 대칭. + +### `src/interactive-os/plugins/persist.ts` + +```ts +import type { Plugin } from '../engine/types' +import type { NormalizedData } from '../store/types' + +export interface PersistAdapter { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface PersistBaseOptions { + /** localStorage key */ + key: string + /** 스키마 버전. 저장물과 다르면 migrate 호출. */ + version: number + /** old → current 변환. 실패 시 undefined 반환하면 저장물 폐기. */ + migrate?: (oldPicked: unknown, oldVersion: number) => Picked | undefined + /** 기본 localStorage. 테스트/대체 어댑터 주입용. */ + storage?: PersistAdapter +} + +export interface LoadPersistedOptions extends PersistBaseOptions {} + +export interface PersistOptions extends PersistBaseOptions { + /** 저장 대상 추출. 전체 store를 저장하지 않음. */ + pick: (store: NormalizedData) => Picked + /** 쓰기 debounce ms. 기본 200. */ + debounce?: number +} + +/** + * engine 생성 *이전* 동기 로드. 저장물이 없거나 version mismatch + migrate 실패 시 undefined. + * + * @invariant localStorage 미정의·JSON parse 실패·storage throw → undefined + * @invariant version 일치 → 저장된 picked 반환 + * @invariant version 불일치 → migrate 호출, 반환값(undefined 포함) 그대로 전달 + */ +export function loadPersisted(options: LoadPersistedOptions): Picked | undefined + +/** + * Plugin: command 실행 후 debounced write로 localStorage에 반영. + * + * @invariant EffectContext를 변경하지 않음 (read-only 계약 보전) + * @invariant command 실행 후 pick 결과가 이전 직렬화와 동일하면 write 스킵 + * @invariant write는 debounce ms 내 연타 시 마지막 값만 실제 반영 + * @invariant storage.setItem throw는 console.warn 후 swallow + */ +export function persist(options: PersistOptions): Plugin +``` + +**완성도**: 🟢 + +## §3 WHY + +`useState + localStorage` 는 FE에서 가장 흔한 날코딩 3종 세트(hydration race, JSON parse 실패, 무결성 없는 부분 복원)를 매 페이지가 재작성한다. 본 프로젝트는 "os 기반 개발"을 규약으로 가지므로, 이 개념은 pages가 아니라 os 자산 3층으로 흡수해야 한다 — 모듈 전역 단일값(`createModuleStore`), 컴포넌트 로컬(`usePersistedState`), **engine NormalizedData(`persist` plugin)**. 앞 둘은 이미 존재하고 engine-연동 층만 비어있어 pages가 직접 store를 subscribe + localStorage로 연결하는 ad-hoc 코드(cmux/studio)가 발생했다. `urlSync`가 plugin 계보에서 state→URL 자리를 차지한 것처럼, `persist`가 state↔localStorage 자리를 차지해야 대칭이 완성된다. + +책임을 10행으로 쪼갠 이유: ① plugin 본체와 테스트를 분리하여 단위 검증을 강제, ② pages 8곳은 각 파일이 독립 PR 단위로 merge 가능하도록 행 분리(파일 1개 = 에이전트 1개), ③ CATALOG 문서화를 별도 행으로 두어 "3자 경계"라는 지식 자산이 코드와 함께 이식되도록. + +## §4 HOW + +```mermaid +flowchart TD + A[definePlugin persist options] --> B[useEffect ctx.onInit] + B --> C[storage.getItem key] + C --> D{version match?} + D -- yes --> E[merge picked to store] + D -- no --> F{migrate?} + F -- ok --> E + F -- fail --> G[default store] + A --> H[middleware post-command] + H --> I[pick store] + I --> J{Object.is prev picked?} + J -- same --> K[skip] + J -- diff --> L[debounce 200ms] + L --> M[storage.setItem JSON.stringify version+picked] +``` + +## §5 WHAT (의존 순서) + +### W1. persist plugin 본체 (§1.1) + +**의존**: — +**파일**: `src/interactive-os/plugins/persist.ts` + +```ts +import type { Command, Plugin } from '../engine/types' +import type { NormalizedData } from '../store/types' +import { definePlugin } from './definePlugin' + +export interface PersistAdapter { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface PersistBaseOptions { + key: string + version: number + migrate?: (oldPicked: unknown, oldVersion: number) => Picked | undefined + storage?: PersistAdapter +} + +export type LoadPersistedOptions = PersistBaseOptions + +export interface PersistOptions extends PersistBaseOptions { + pick: (store: NormalizedData) => Picked + debounce?: number +} + +interface Envelope { v: number; d: unknown } + +function defaultStorage(): PersistAdapter | null { + if (typeof localStorage === 'undefined') return null + return { getItem: (k) => localStorage.getItem(k), setItem: (k, v) => localStorage.setItem(k, v) } +} + +export function loadPersisted

(options: LoadPersistedOptions

): P | undefined { + const storage = options.storage ?? defaultStorage() + if (!storage) return undefined + try { + const raw = storage.getItem(options.key) + if (raw == null) return undefined + const env = JSON.parse(raw) as Envelope + if (env.v === options.version) return env.d as P + return options.migrate?.(env.d, env.v) + } catch { + return undefined + } +} + +export function persist

(options: PersistOptions

): Plugin { + const storage = options.storage ?? defaultStorage() + const debounceMs = options.debounce ?? 200 + let prevSerialized: string | null = null + let timer: ReturnType | null = null + + function scheduleWrite(picked: P) { + if (!storage) return + const envelope: Envelope = { v: options.version, d: picked } + const next = JSON.stringify(envelope) + if (next === prevSerialized) return + prevSerialized = next + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + try { storage.setItem(options.key, next) } + catch (e) { console.warn('[persist] setItem failed:', e) } + }, debounceMs) + } + + return definePlugin({ + name: 'persist', + middleware: (next: (cmd: Command) => void, getStore: () => NormalizedData) => (cmd: Command) => { + next(cmd) + scheduleWrite(options.pick(getStore())) + }, + }) +} +``` + +**검증**: W2에서 단위 테스트. + +**Note**: `EffectContext.setStore`가 없으면 W1 착수 전 `engine/types.ts` 확장이 선행되어야 한다 — §6 원칙 감시자에서 검출하여 장애물로 올림. + +### W2. persist plugin 단위 테스트 (§1.2) + +**의존**: W1 +**파일**: `src/interactive-os/plugins/persist.test.ts` + +```ts +import { describe, it, expect, vi } from 'vitest' +import { persist } from './persist' +import { createCommandEngine } from '../engine/createCommandEngine' + +function makeMemoryStorage() { + const m = new Map() + return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => { m.set(k, v) } } +} + +describe('persist plugin', () => { + it('restores picked state synchronously on engine init', () => { /* ... */ }) + it('writes picked state after command (debounced)', async () => { /* ... */ }) + it('skips write when picked is unchanged', async () => { /* ... */ }) + it('calls migrate when version mismatches', () => { /* ... */ }) + it('falls back to default when migrate returns undefined', () => { /* ... */ }) + it('swallows setItem exceptions', () => { /* ... */ }) +}) +``` + +**검증**: `pnpm test src/interactive-os/plugins/persist.test.ts` 전부 green. + +### W3. cmux chatStore persist 적용 (§1.3) + +**의존**: W1 +**파일**: `src/pages/cmux/chatStore.ts` + +변경: 파일 하단의 수동 subscribe + localStorage 블록 제거. +```ts +const picked = loadPersisted({ key: 'cmux.chatStore', version: 1 }) +const initial = picked ? mergeCmux(defaultCmux, picked) : defaultCmux +const engine = createCommandEngine(initial, { + plugins: [persist({ key: 'cmux.chatStore', version: 1, pick: s => ({ activeSessionId: s.entities.__session__?.activeSessionId, sessions: s.entities.sessions }) })], +}) +``` + +**검증**: dev server에서 채팅 세션 생성 → 새로고침 → 복원 확인. + +### W4. studio PageStudio persist 적용 (§1.4) + +**의존**: W1 +**파일**: `src/pages/studio/PageStudio.tsx` + +변경: `@useState-hatch` 블록 제거. `const picked = loadPersisted({ key: STUDIO_LAYOUT_KEY, version: 1 })`로 초기 store 결정, engine에 `persist({ key: STUDIO_LAYOUT_KEY, version: 1, pick: s => s })` 주입. + +**검증**: 레이아웃 변경 → 새로고침 → 복원. + +### W5. bookNavStore → createModuleStore (§1.5) + +**의존**: — +**파일**: `src/pages/book/bookNavStore.ts` + +변경: 수동 subscribe + localStorage read/write 제거. `createModuleStore({ initial, storageKey: STORAGE_KEY })`로 치환. + +**검증**: `pnpm typecheck` + 수동 탐색. + +### W6. writerChatBridge → createModuleStore (§1.6) + +**의존**: — +**파일**: `src/pages/writer/writerChatBridge.ts` + +변경: `readMap`/`writeMap` 함수 제거. `createModuleStore({ initial: {}, storageKey: WRITER_SESSIONS_KEY })` 로 치환, get/set API 교체. + +**검증**: writer 세션 생성 → 새로고침 → 복원. + +### W7. PageComponentCreator → usePersistedState (§1.7) + +**의존**: — +**파일**: `src/pages/creator/PageComponentCreator.tsx` + +변경: 초기 `localStorage.getItem` + `useEffect(() => localStorage.setItem)` 2블록 제거. `usePersistedState(STORAGE_KEY, defaultData)` 1줄로 치환. + +**검증**: 폼 입력 → 새로고침 → 복원. + +### W8. PageFinder × 4 useState → usePersistedState (§1.8) + +**의존**: — +**파일**: `src/pages/finder/PageFinder.tsx` + +변경: `viewMode`/`sortKey`/`sortDir`/`kindFilters` 4개 state를 각각 `usePersistedState(KEY, default)` 로 치환. 대응 `useEffect(() => localStorage.setItem)` 4개 제거. `sortKey`의 `null → removeItem` 특수 처리는 `usePersistedState`가 JSON.stringify(null)로 투영하므로 동등 동작 유지. + +**검증**: 각 prefs 변경 → 새로고침 → 복원. + +### W9. QuickOpen persistKey 내부화 (§1.9) + +**의존**: — +**파일**: `src/interactive-os/ui/QuickOpen.tsx` + +변경: `persistKey` 경로만 `usePersistedState(persistKey, '')` 사용, 미지정 시 `useState('')`. 현재 2줄(초기 getItem + useEffect setItem) 제거. + +**검증**: 기존 단위 테스트 green. + +### W10. CATALOG.md 3자 경계 문서화 (§1.10) + +**의존**: W1 +**파일**: `src/interactive-os/CATALOG.md` + +변경: ① `## plugins` 섹션 알파벳 목록에 `persist` 추가. ② 파일 말미에 `## Persistence 3층 경계` 섹션 신설: + +```md +## Persistence 3층 경계 + +| 스코프 | API | 언제 | +|---|---|---| +| 모듈 전역 단일값 | `store/createModuleStore({ storageKey })` | theme·locale·currentUser 같은 앱 전역 primitive | +| 컴포넌트 로컬값 | `primitives/usePersistedState(key, default)` | 페이지·컴포넌트 안에서만 쓰는 view 선호(viewMode, sort, filter, 쿼리) | +| engine NormalizedData | `plugins/persist`: `loadPersisted()` → `createCommandEngine(initial, [persist()])` | command 엔진이 관리하는 CRUD·FlatLayout·세션 데이터. load는 engine 생성 이전 동기 호출, writer plugin은 post-command debounced write. urlSync 3조각 선례와 대칭. | + +**금지**: pages·hooks가 `localStorage.*`를 직접 호출. 새 케이스는 위 3층 중 하나로 흡수. +``` + +**검증**: `pnpm lint` + grep `localStorage\.` → `createModuleStore`/`usePersistedState`/`persist`/테스트 외 0건. + +## §6 원칙 감시자 결과 + +- ✅ CLAUDE.md 레이어 의존: plugin→store/engine만 import, pages→plugin/primitives/store만 import. 역방향 없음. +- ✅ CATALOG.md 미확인 위반: §1 탐색 증거에 조회 기록. +- ✅ 있는 걸로 만든다: createModuleStore/usePersistedState 재사용, 신규는 persist 1개. +- ✅ render function is slot / ax semantic: UI 변경 아님, 해당 없음. +- ⚠️ **Placeholder 검출**: W2 테스트 본문이 `/* ... */` 스텁 — W2 착수 시 구현 필수. PRD 단계에선 시그니처만 고정, 본문은 테스트 작성자가 채운다(단, 케이스 이름 6개로 범위 고정됨). +- ⚠️ **장애물 1건**: `EffectContext.setStore` 가 현재 `engine/types.ts`에 없을 수 있음. W1 착수 시 ① 이미 있으면 그대로 사용, ② 없으면 `engine/types.ts`에 `setStore: (next: NormalizedData) => void` 추가가 W0으로 선행. /go dispatch 시 W1 에이전트가 먼저 타입 확인. +- ✅ 파일 1개 = 책임 1개: 10행 모두 단일 파일 단일 책임. + +**전체 완성도**: 🟢 (장애물 1건은 W1 내부에서 감지·해소) + +--- + +## 요약 (리뷰용) + +- **§1**: 10행 (신규 2 + 수정 7 + 문서 1). 잔존 localStorage 호출 12 중 재사용층(createModuleStore/usePersistedState)과 테스트·useResizer 제외 → 8 pages/ui 리팩터 대상. +- **§2**: 신규 export 1개 (`persist` 함수 + `PersistOptions`/`PersistAdapter` 타입). +- **§5**: WHAT 코드 블록 10개, 의존 순서(W1→W2/W3/W4/W10, W5~W9 독립). +- **원칙 감시자**: 0 위반, 1 장애물(EffectContext.setStore 확인) 검출 → W1 내부 처리. diff --git a/docs/2026/2026-04/2026-04-20/studioPrd.md b/docs/2026/2026-04/2026-04-20/studioPrd.md new file mode 100644 index 000000000..9773ff1c1 --- /dev/null +++ b/docs/2026/2026-04/2026-04-20/studioPrd.md @@ -0,0 +1,532 @@ +--- +name: studioPrd +type: prd +layer: pages +project: studio +status: draft +date: 2026-04-20 +tags: [studio, flatlayout, a2ui, playground, streaming] +--- + +# Studio 통합 — PRD + +> **Discussion**: [routes doubt 세션 2026-04-20 — conversation, no separate discuss md] +> **산출물 유형**: 페이지 통합 + 경계 어댑터 정리 +> **규모 추정**: 신규 2, 수정 4, 재사용 3, 삭제 3 + +## §0 요구사항 (from doubt) + +- 해결책 ⑪: `/a2ui` + `/playground` → `/studio` 단일 라우트. **FlatLayout이 선언적 UI 런타임**이며, A2UI 스트리밍은 studio의 **example 카테고리 중 하나**로 편입한다. +- 제약 ⑦: + - 내부 데이터 SSOT는 `NormalizedData` (FlatLayout) 유일 + - A2UIPayload는 **경계 어댑터**로만 존재 (외부 Google A2UI v0.9 envelope 호환) + - 기존 기능(스트리밍 시뮬레이션, preset 카탈로그, JSON 에디터) 손실 없음 +- 보유 자산 ⑧: + - `a2uiToNormalized` 어댑터 이미 존재 (`ui/a2uiAdapter.ts`) + - `FlatLayout` + `flatLayoutRegistry` + `layoutCommands` (store patch API) + - `PagePlayground` = FlatLayout 기반 canvas + tabgroup + picker 이미 구현 + - `A2UISurface` — v0.9 components를 normalized로 변환 후 렌더 (내부에서 adapter 호출) + +## §1 책임 분해 + +| # | 책임 | 파일 경로 | 레이어 | 기존/신규 | 의존 | +|---|------|----------|-------|----------|------| +| 1 | A2UI envelope → NormalizedData 변환 | `src/interactive-os/ui/a2uiAdapter.ts` | ui | 재사용 | — | +| 2 | component-by-component 스트리밍 주입 hook | `src/interactive-os/primitives/useLayoutStream.ts` | primitives | 신규 | 1 | +| 3 | A2UI preset 카탈로그 (studio example 데이터) | `src/pages/studio/studioA2UIPresets.ts` | pages | 수정(from `pages/a2ui/a2uiPresets.ts`) | — | +| 4 | studio example 카탈로그 (layout 프리셋 + A2UI 스트리밍 프리셋 통합) | `src/pages/studio/studioExamples.ts` | pages | 신규 | 3 | +| 5 | studio 초기 레이아웃 (playground canvas + example sidebar) | `src/pages/studio/studioLayout.ts` | pages | 수정(from `playgroundDefaults.ts`) | 4 | +| 6 | studio 전용 widgets (ExampleSidebar, StreamControl, JsonInspector) | `src/pages/studio/studioWidgets.tsx` | pages | 신규 | 2, 4 | +| 7 | studio 페이지 컴포넌트 | `src/pages/studio/PageStudio.tsx` | pages | 수정(from `PagePlayground.tsx`) | 5, 6 | +| 8 | router 갱신 — `/studio` 추가, `/a2ui`·`/playground` 제거 | `src/router.tsx` | app | 수정 | 7 | +| 9 | ActivityBar 갱신 — `/studio` 단일 항목 | `src/ActivityBar.tsx` | app | 수정 | 8 | +| 10 | 삭제 — `pages/a2ui/` 폴더 전체 | `src/pages/a2ui/**` | pages | 삭제 | 3, 8 | +| 11 | 삭제 — `pages/playground/` 폴더 전체 (이전 후) | `src/pages/playground/**` | pages | 삭제 | 5, 6, 7 | +| 12 | A2UISurface 정리 판정 | `src/interactive-os/ui/A2UISurface.tsx` | ui | 유지(재사용) | 1 | + +### 탐색 증거 + +- `Glob("src/pages/a2ui/*")` → `PageA2UI.tsx`, `a2uiPresets.ts`, `PageA2UI.module.css` (3 파일) +- `Glob("src/pages/playground/*")` → 9 파일 (Page + defaults + widgets + keybindings + catalog + tools) +- `Glob("src/interactive-os/ui/A2UI*")` → `A2UISurface.tsx`, `A2UISurface.demo.tsx`, `a2uiAdapter.ts`, `a2uiProtocol.ts`, `a2uiFunctions.ts` +- `Read("a2uiAdapter.ts")` → `a2uiToNormalized(payload: A2UIPayload): NormalizedData` 이미 구현. α의 데이터 통합은 80% 완성 상태. +- `Read("PagePlayground.tsx")` → FlatLayout + flatLayoutRegistry + localStorage persistence 이미 구현. +- `Read("PageA2UI.tsx")` → `useComponentStream` hook이 컴포넌트 내부에 있어 재사용 불가 → primitives로 승격 필요(§1.2). +- `CATALOG.md`: FlatLayout 스트리밍 관련 primitives 없음 → `useLayoutStream` 신규 정당. + +**완성도**: 🟢 + +## §2 Contract + +### `src/interactive-os/primitives/useLayoutStream.ts` (신규) + +```ts +import type { NormalizedData } from '@os/store/types' + +export interface LayoutStreamState { + streaming: boolean + /** 0..100 */ + progress: number + /** 스트리밍 중 누적된 부분 상태. 완료/미시작 시 null. */ + partialData: NormalizedData | null +} + +export interface LayoutStreamControls { + start: (full: NormalizedData, order: string[]) => void + stop: () => void +} + +/** + * NormalizedData를 node 단위로 증분 주입하는 스트리밍 시뮬레이터. + * + * @param onUpdate 매 tick마다 부분 상태 콜백 (store.applyPatch 연결 지점) + * @param tickMs 기본 150 + jitter 200 + * @invariant order의 모든 id는 full.entities에 존재해야 함 + * @invariant stop() 호출 후에는 timer 잔존 없음 + */ +export function useLayoutStream( + onUpdate: (partial: NormalizedData) => void, + tickMs?: { base: number; jitter: number } +): LayoutStreamState & LayoutStreamControls +``` + +### `src/pages/studio/studioExamples.ts` (신규) + +```ts +import type { NormalizedData } from '@os/store/types' +import type { A2UIv09Envelope } from './studioA2UIPresets' + +export type StudioExampleKind = 'layout' | 'a2ui-stream' + +export interface StudioExample { + id: string + kind: StudioExampleKind + label: string + category: string + /** layout: 즉시 적용 스냅샷. a2ui-stream: envelope (스트리밍 변환). */ + data: NormalizedData | A2UIv09Envelope +} + +export const STUDIO_EXAMPLES: StudioExample[] = [ /* ... */ ] + +/** category 별 그룹핑. 시작 시 ExampleSidebar에서 소비. */ +export function groupExamples(xs: StudioExample[]): Record +``` + +### `src/pages/studio/studioWidgets.tsx` (신규) + +```tsx +import type { WidgetRegistry } from '@os/layout/widgetRegistry' + +/** + * studio 전용 widget registry. playgroundWidgets를 상속 확장한다. + * - ExampleSidebar: STUDIO_EXAMPLES를 listbox로 렌더, 선택 시 canvas에 적용/스트리밍 + * - StreamControl: 현재 선택된 a2ui example에 대해 Simulate/Stop 버튼 + * - JsonInspector: 현재 canvas의 NormalizedData를 read-only JSON으로 표시 + */ +export const studioWidgets: WidgetRegistry +``` + +### `src/pages/studio/studioLayout.ts` (수정 from playgroundDefaults) + +```ts +import { defineLayout } from '@os/layout/flatLayout' + +export const STUDIO_CANVAS_ID = 'studio-canvas' + +/** + * playground 초기 레이아웃 + 왼쪽 ExampleSidebar + 우측 상단 StreamControl. + * - root: split horizontal [sidebar, canvas] + * - sidebar: ExampleSidebar widget + * - canvas: tabgroup (기존 playground 구조 그대로) + */ +export const STUDIO_INITIAL: NormalizedData +``` + +### `src/pages/studio/PageStudio.tsx` (수정 from PagePlayground) + +```tsx +/** + * Studio — 선언적 FlatLayout 런타임의 조립/스트리밍 스튜디오. + * - 사용자 조립: cmux 분할 단축키 (Mod+D, Mod+Shift+D, Mod+T, Mod+W) + * - AI 스트리밍: A2UI envelope example 선택 → useLayoutStream → canvas에 증분 주입 + */ +export default function PageStudio(): JSX.Element +``` + +**완성도**: 🟢 + +## §3 WHY + +1. **FlatLayout이 이미 충분한 선언적 UI 런타임.** A2UI에만 별도 표면(`A2UISurface`) + 별도 페이지(`PageA2UI`)를 두는 건 축의 중복. 데이터 경로(`a2uiToNormalized`)는 이미 존재하므로 표면만 통합하면 본질이 드러난다. +2. **"사람 조립 vs AI 스트리밍"은 주체의 차이일 뿐 데이터 모델은 동일해야 한다.** 두 입력이 같은 NormalizedData로 수렴하면 혼합 시나리오(사람이 조립하다가 AI가 patch를 흘리는 경우)가 자연스럽게 표현 가능. +3. **책임 분해의 정당성**: §1.2 `useLayoutStream`이 현재 `PageA2UI` 안에 박혀있는 `useComponentStream`을 승격한 것. 이 승격이 없으면 studio 밖에서도 스트리밍이 필요할 때(예: chat 모듈의 Gen UI 블록) 중복 구현이 생긴다. + +## §4 HOW + +```mermaid +flowchart TD + U[User] -->|조립: Mod+D 분할| FL[FlatLayout canvas] + E[ExampleSidebar] -->|layout 선택| FL + E -->|a2ui-stream 선택| ADP[a2uiToNormalized] + ADP --> LS[useLayoutStream] + LS -->|tick| PATCH[NormalizedData patch] + PATCH --> FL + FL --> R[rendered widgets] +``` + +핵심: **모든 경로가 NormalizedData로 수렴**. A2UI는 `ADP` 어댑터로 경계 변환된 뒤 동일 경로로 진입. + +## §5 WHAT (의존 순서) + +### W1. useLayoutStream (§1.2) + +**의존**: a2uiAdapter (재사용) +**파일**: `src/interactive-os/primitives/useLayoutStream.ts` + +```ts +import { useState, useRef, useCallback, useEffect } from 'react' +import type { NormalizedData } from '@os/store/types' + +export interface LayoutStreamState { + streaming: boolean + progress: number + partialData: NormalizedData | null +} + +export function useLayoutStream( + onUpdate: (partial: NormalizedData) => void, + tickMs: { base: number; jitter: number } = { base: 150, jitter: 200 } +) { + const [state, setState] = useState({ streaming: false, progress: 0, partialData: null }) + const timerRef = useRef | null>(null) + const fullRef = useRef(null) + const orderRef = useRef([]) + const idxRef = useRef(0) + + const stop = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = null + setState(s => ({ ...s, streaming: false })) + }, []) + + const start = useCallback((full: NormalizedData, order: string[]) => { + stop() + fullRef.current = full + orderRef.current = order + idxRef.current = 0 + const seed: NormalizedData = { entities: {}, relationships: {} } + setState({ streaming: true, progress: 0, partialData: seed }) + onUpdate(seed) + + const tick = () => { + const full = fullRef.current! + const order = orderRef.current + const i = idxRef.current++ + const id = order[i] + const entities = { ...state.partialData!.entities, [id]: full.entities[id] } + const partial: NormalizedData = { entities, relationships: full.relationships } + const progress = Math.round(((i + 1) / order.length) * 100) + + onUpdate(partial) + + if (i + 1 < order.length) { + setState({ streaming: true, progress, partialData: partial }) + timerRef.current = setTimeout(tick, tickMs.base + Math.random() * tickMs.jitter) + } else { + setState({ streaming: false, progress: 100, partialData: partial }) + timerRef.current = null + } + } + + timerRef.current = setTimeout(tick, tickMs.base + Math.random() * tickMs.jitter) + }, [onUpdate, stop, tickMs.base, tickMs.jitter]) + + useEffect(() => () => stop(), [stop]) + + return { ...state, start, stop } +} +``` + +**검증**: vitest unit — `order=['a','b','c']` 주입 후 tick 3회 → `partialData.entities`에 a/b/c 순차 추가. `stop()` 후 `timerRef.current === null`. + +### W2. studioA2UIPresets (§1.3, rename) + +**의존**: — +**파일**: `src/pages/studio/studioA2UIPresets.ts` + +```ts +// pages/a2ui/a2uiPresets.ts 를 이동. export name 유지. +export type { A2UIv09Envelope } from './a2uiEnvelope' +export { categories } from './a2uiPresets' +``` + +**검증**: `git mv src/pages/a2ui/a2uiPresets.ts src/pages/studio/studioA2UIPresets.ts` + import 경로 치환. grep으로 모든 import 경로 갱신 확인. + +### W3. studioExamples (§1.4) + +**의존**: W2 +**파일**: `src/pages/studio/studioExamples.ts` + +```ts +import type { NormalizedData } from '@os/store/types' +import { categories, type A2UIv09Envelope } from './studioA2UIPresets' + +export type StudioExampleKind = 'layout' | 'a2ui-stream' + +export interface StudioExample { + id: string + kind: StudioExampleKind + label: string + category: string + data: NormalizedData | A2UIv09Envelope +} + +const a2uiExamples: StudioExample[] = categories.flatMap(cat => + Object.entries(cat.presets).map(([name, envelope]) => ({ + id: `a2ui-${cat.label}-${name}`, + kind: 'a2ui-stream' as const, + label: name, + category: `A2UI · ${cat.label}`, + data: envelope, + })) +) + +// 기존 playground layout preset (PLAYGROUND_INITIAL)도 하나의 example로 편입 +// 필요 시 layoutPresets.ts 신설해 여러 layout 추가 +export const STUDIO_EXAMPLES: StudioExample[] = [...a2uiExamples] + +export function groupExamples(xs: StudioExample[]): Record { + const out: Record = {} + for (const x of xs) (out[x.category] ??= []).push(x) + return out +} +``` + +**검증**: `STUDIO_EXAMPLES.length > 0` 및 각 항목의 `category` 유일성. vitest 1건. + +### W4. studioLayout (§1.5, from playgroundDefaults) + +**의존**: W3 +**파일**: `src/pages/studio/studioLayout.ts` + +```ts +import { defineLayout } from '@os/layout/flatLayout' +import { FOCUS_STATE_ID } from '@os/layout/layoutCommands' + +export const STUDIO_CANVAS_ID = 'studio-canvas' +export const PICKER_STATE_ID = '__picker' + +export const STUDIO_INITIAL = defineLayout({ + entities: { + root: { data: { type: 'split', direction: 'horizontal', sizes: ['240px', 'flex'], resizable: true }, children: ['sidebar', 'canvas'] }, + sidebar: { data: { type: 'widget', widget: 'ExampleSidebar' } }, + canvas: { data: { type: 'tabgroup', activeTabId: 't1' }, children: ['t1'] }, + t1: { data: { type: 'tab', label: 'Untitled', contentType: 'widget', contentRef: '' }, children: ['t1-body'] }, + 't1-body': { data: { type: 'widget', widget: 'PlaygroundSurface' } }, + 'stream-ctrl':{ data: { type: 'floating', anchor: 'float-top-end' }, children: ['stream-ctrl-w'] }, + 'stream-ctrl-w':{ data: { type: 'widget', widget: 'StreamControl' } }, + [FOCUS_STATE_ID]: { data: { type: 'state', focusedTabgroupId: 'canvas', focusedTabId: 't1' } }, + [PICKER_STATE_ID]: { data: { type: 'state', targetTabId: null } }, + }, +}) +``` + +**검증**: 브라우저 수동 — `/studio` 진입 시 sidebar + canvas 2분할 + 우측 상단 StreamControl 플로팅. + +### W5. studioWidgets (§1.6) + +**의존**: W1, W3, W4 +**파일**: `src/pages/studio/studioWidgets.tsx` + +```tsx +import { useCallback } from 'react' +import { createWidgetRegistry } from '@os/layout/widgetRegistry' +import { playgroundWidgets } from '../playground/playgroundWidgets' +import { ListBox } from '@os/ui/ListBox' +import { Button } from '@os/ui/Button' +import { ax } from '@styles/ax' +import { useLayoutStream } from '@os/primitives/useLayoutStream' +import { STUDIO_EXAMPLES, groupExamples, type StudioExample } from './studioExamples' +import { a2uiToNormalized } from '@os/ui/a2uiAdapter' +import { getFlatLayoutActions } from '@os/primitives/flatLayoutRegistry' +import { STUDIO_CANVAS_ID } from './studioLayout' + +function applyExample(ex: StudioExample) { + const actions = getFlatLayoutActions(STUDIO_CANVAS_ID) + if (!actions) return + if (ex.kind === 'layout') { + actions.setStore(ex.data as never) + } else { + const normalized = a2uiToNormalized({ components: (ex.data as any).updateComponents.components }) + actions.setStore(normalized) + } +} + +function ExampleSidebar() { + const groups = groupExamples(STUDIO_EXAMPLES) + return ( +

+ ) +} + +function StreamControl() { + /* useLayoutStream + 현재 선택된 a2ui example 추적 — studio context state로 관리 */ + return
+} + +export const studioWidgets = createWidgetRegistry({ + ...playgroundWidgets, + ExampleSidebar, + StreamControl, +}) +``` + +**검증**: `/studio`에서 sidebar 항목 클릭 → canvas 교체. a2ui-stream 항목은 StreamControl 활성화. + +### W6. PageStudio (§1.7) + +**의존**: W4, W5 +**파일**: `src/pages/studio/PageStudio.tsx` + +```tsx +// @useState-hatch +import { useEffect, useState } from 'react' +import { FlatLayout } from '@os/ui/FlatLayout' +import type { NormalizedData } from '@os/schema' +import { getFlatLayoutActions, subscribeFlatLayoutRegistry } from '@os/primitives/flatLayoutRegistry' +import { STUDIO_INITIAL, STUDIO_CANVAS_ID } from './studioLayout' +import { studioWidgets } from './studioWidgets' +import { PlaygroundKeybindingsWidget } from '../playground/playgroundKeybindings' +import { PickerRootWidget } from '../playground/playgroundWidgets' + +const STUDIO_LAYOUT_KEY = 'studio-layout' + +function load(): NormalizedData { + try { + const raw = localStorage.getItem(STUDIO_LAYOUT_KEY) + if (raw) { + const parsed = JSON.parse(raw) as NormalizedData + if (parsed?.entities && parsed?.relationships) return parsed + } + } catch { /* ignore */ } + return STUDIO_INITIAL +} + +export default function PageStudio() { + const [initialData] = useState(load) + + useEffect(() => { + let innerUnsub: (() => void) | null = null + let timer: ReturnType | null = null + const persist = () => { + const actions = getFlatLayoutActions(STUDIO_CANVAS_ID) + if (!actions) return + try { localStorage.setItem(STUDIO_LAYOUT_KEY, JSON.stringify(actions.getStore())) } + catch { /* quota */ } + } + const attach = () => { + if (innerUnsub) return + const actions = getFlatLayoutActions(STUDIO_CANVAS_ID) + if (!actions) return + innerUnsub = actions.subscribe(() => { + if (timer) return + timer = setTimeout(() => { timer = null; persist() }, 500) + }) + } + const unsubRegistry = subscribeFlatLayoutRegistry(attach) + attach() + return () => { unsubRegistry(); innerUnsub?.(); if (timer) clearTimeout(timer) } + }, []) + + return ( + + + + + ) +} +``` + +**검증**: screen test — `/studio` 진입 → ExampleSidebar 렌더 + canvas tabgroup 렌더 + Mod+D 분할 작동. + +### W7. router + ActivityBar (§1.8, §1.9) + +**의존**: W6 +**파일**: `src/router.tsx`, `src/ActivityBar.tsx` + +```ts +// router.tsx +{ path: '/studio', lazy: () => import('./pages/studio/PageStudio').then(m => ({ Component: m.default })) }, +// 제거: /a2ui, /playground +``` + +```ts +// ActivityBar.tsx — appNavItems +{ id: 'studio', label: 'Studio', icon: FlaskConical, path: '/studio' }, +// 제거: playground, a2ui +``` + +**검증**: dev server 수동 — `/studio` 정상 로드 + `/a2ui`, `/playground`은 `/` redirect. + +### W8. 삭제 (§1.10, §1.11) + +**의존**: W7 +**파일**: `src/pages/a2ui/`, `src/pages/playground/` (단 playground는 studio가 import하는 widgets/keybindings는 studio로 이동 후 삭제) + +순서: +1. `pages/playground/playgroundWidgets.tsx`, `playgroundKeybindings.tsx`, `playgroundCatalog.ts`, `parseFlatLayoutBlocks.ts`, `layoutTools.ts`, `playgroundChatWidgets.tsx`, `playgroundChatWidgets.module.css` → `pages/studio/`로 `git mv` +2. `pages/playground/PagePlayground.tsx`, `playgroundDefaults.ts` → 삭제 (PageStudio와 studioLayout이 대체) +3. `pages/a2ui/PageA2UI.tsx`, `PageA2UI.module.css` → 삭제 +4. `pages/a2ui/a2uiPresets.ts` → W2에서 이미 이동됨 + +**검증**: `Grep("pages/playground|pages/a2ui")` 0건. typecheck pass. + +### W9. A2UISurface 판정 (§1.12) + +**의존**: W8 +**조사 포인트**: `A2UISurface.tsx`는 내부에서 `a2uiToNormalized`를 호출한 뒤 어떤 표면으로 렌더하는가? +- 만약 **FlatLayout 내부 widget으로 감싸기만** 하면 → 폐기 후 `studioExamples.applyExample`에서 직접 `a2uiToNormalized` 호출 +- 만약 **별도 렌더 로직이 있다면** → 유지하되 studio에서는 사용하지 않음 (/chat 등 다른 소비자 있는지 확인) + +**검증**: `Grep("A2UISurface")` → 사용처 열거. 사용처가 `PageA2UI`뿐이면 삭제. 아니면 유지. + +**완성도**: 🟢 (단 W9는 조사 후 확정) + +## §6 원칙 감시자 결과 + +- ✅ 레이어 의존 순서: primitives(W1) ← pages(W3~W7). 역방향 없음. +- ✅ 있는 걸로 먼저: `a2uiAdapter`, `FlatLayout`, `flatLayoutRegistry`, `playgroundWidgets` 모두 재사용. +- ✅ 파일명 규칙: `use*`, `Page*`, `*Widgets.tsx`, `*Layout.ts`, `*Examples.ts` — pages 네이밍 관례 준수. +- ✅ ax() 사용, style={} 없음. +- ⚠️ W5 `applyExample`의 `as any` 타입 캐스트 1건 — envelope → payload 변환 지점. W2에서 envelope 타입 정리 시 제거 가능. +- ⚠️ W9 A2UISurface 판정은 조사 후 확정 — Placeholder 수준 아님, 실행 시 Grep 1회로 해결. + +--- + +**전체 완성도**: 🟢 (W9 조사 후 착수 가능) + +## 착수 순서 요약 + +1. W1 `useLayoutStream` primitives 신설 +2. W2 presets 이동 (`git mv`) +3. W3 `studioExamples` 작성 +4. W4 `studioLayout` 작성 +5. W5 `studioWidgets` 작성 +6. W6 `PageStudio` 작성 +7. W9 A2UISurface 사용처 조사 → 폐기 여부 확정 +8. W7 router + ActivityBar 갱신 +9. W8 기존 폴더 삭제 (`/a2ui`, `/playground`) +10. dev server 수동 검증 + typecheck + 커밋 diff --git a/docs/2026/2026-04/2026-04-21/explainFinderVsFeatureFinder.md b/docs/2026/2026-04/2026-04-21/explainFinderVsFeatureFinder.md new file mode 100644 index 000000000..9e152e96c --- /dev/null +++ b/docs/2026/2026-04/2026-04-21/explainFinderVsFeatureFinder.md @@ -0,0 +1,175 @@ +--- +type: explain +tags: [explain, finder, feature-finder, defineFeature, architecture] +date: 2026-04-21 +--- + +# Finder vs FeatureFinder — 같은 목적, 다른 레이어 + +> 작성일: 2026-04-21 +> 맥락: `/finder` = production 앱, `/feature-finder` = `defineFeature`/`defineApp` 런타임 조립 실증 + +> - `/finder`는 381줄 PageFinder가 FlatLayout·URL·QuickOpen·mddb·HMR을 한 파일에 묶은 **세로형 통합체**다 +> - `/feature-finder`는 20줄 `FinderApp = defineApp({baseline, features[]})`가 전부인 **가로형 조립체**다 +> - 두 페이지는 같은 "파일 탐색" 목적이지만 축이 직교한다 — 비교해야 할 gap은 "기능 수"가 아니라 "조립 vs 소유"다 +> - **즉답: /feature-finder는 viewMode·sidebar·dataSource 3축의 마켓플레이스 MVP일 뿐, /finder의 사용자 기능 8종은 아직 Feature로 분해되지 않았다** + +--- + +## Why — 두 페이지는 다른 문제를 푼다 + +`/finder`는 **제품**이고, `/feature-finder`는 **아키텍처 증명**이다. + +```mermaid +flowchart LR + subgraph F["/finder (제품)"] + PF["PageFinder.tsx 381L"] --> FL["FlatLayout"] + PF --> URL["useUrlSync"] + PF --> QO["QuickOpen"] + PF --> MDB["mddb-index"] + PF --> SORT["sort/filter"] + PF --> HMR["HMR tree-update"] + end + subgraph B["/feature-finder (조립 증명)"] + FA["FinderApp = defineApp"] --> BL["BaselineFinder 깡통"] + FA --> Fs["FsFeature"] + FA --> MI["MillerFeature"] + FA --> BK["BookFeature"] + FA --> FV["FavoritesFeature"] + end +``` + +| 축 | /finder | /feature-finder | +|---|---|---| +| 정의 코드 | 381줄 단일 page | 20줄 `defineApp` + 4 feature | +| 레이아웃 | FlatLayout + widgetRegistry | BaselineFinderApp의 `ax({layout:'row'})` 직접 조립 | +| 기능 추가 방식 | PageFinder에 useState·useEffect 추가 | `features: [...]`에 push | +| Settings 토글 | ❌ | ✅ 런타임 install/uninstall | +| 1차 청자 | 사용자 | 마켓플레이스 설계 | + +→ **시사점**: "/finder에 있는 것이 /feature-finder에 없다"를 gap으로 세면 방향을 잘못 본다. 진짜 질문은 "/finder 기능을 어떤 기여 슬롯(contribution slot)으로 분해해야 Feature가 되는가". + +--- + +## Gap 1 — 데이터 파이프라인: sort/filter/knowledge는 Feature 슬롯이 없다 + +BaselineFinder가 선언한 슬롯은 6개(`sidebar/toolbar/mainHeader/treeContent/previewContent/overlay`)뿐이고, `AppDefinition` 기여 타입은 `dataSource/viewMode/sidebar` 3종뿐이다. `/finder`의 파생 파이프라인은 넣을 슬롯이 없다. + +```mermaid +flowchart TD + IS["initialStore (fetchTree)"] --> FLT["filterStore (kind + ext)"] + FLT --> SRT["sortStore (name/kind/date/loc × asc/desc)"] + SRT --> LS["listStore"] + + IS -.->|Knowledge 클릭| MDB["fetchMddbIndex → indexToTree(groupBy)"] + MDB --> IS2["가상 트리 (tag/type/status 그룹)"] + + style MDB fill:#fff3e0,stroke:#e65100 +``` + +`/finder`에서 실질 구현 270줄 중: +- **sort**: `finderSort.ts` 58L + handler 10L + usePersistedState 2개 +- **filter**: `finderFilter.ts` 84L + kindFilters UI + usePersistedState +- **knowledge**: `knowledgeFetch.ts` 40L + `knowledgeTransform.ts` 91L + sidebar 분기 + +BaselineFinderApp은 raw `data`를 그대로 `ViewRender`에 흘린다. sort/filter/knowledge가 feature가 되려면 **새 기여 타입**(예: `dataTransform`, `virtualSource`)이 필요하고, baseline의 load→transform→render 파이프라인도 확장돼야 한다. + +→ **시사점**: 다음 설계 과제는 *feature 추가*가 아니라 **기여 슬롯 확장** — `dataTransform` 1종을 추가하면 sort/filter가 자동으로 feature화된다. + +--- + +## Gap 2 — 크로스커팅 기능은 host에 박혀있다 + +URL sync, QuickOpen, HMR은 Feature 어디에도 속하지 않고 `/finder`가 직접 소유한다. + +```mermaid +flowchart LR + subgraph H["/finder host만 가진 것"] + U["useUrlSync + pathParser\n— 파일 선택이 URL과 동기"] + Q["QuickOpen Meta+P\n— 전역 검색 overlay"] + HM["import.meta.hot\n— fs:tree-update 수신, EXPANDED/FOCUS 보존"] + K["ArrowLeft/Right\nBook 키맵"] + end + H -.->|아직 연결 못함| FB["BaselineFinderApp"] +``` + +각각의 상태: +- **URL sync** — `useUrlSync({parser, onUrlChange})` 패턴으로 `usePlugin` 하나만 주입하면 되지만 BaselineFinderApp에 없음 +- **QuickOpen** — `overlay` 슬롯이 선언돼 있지만 아무 feature도 기여 안 함 +- **HMR refresh** — `FsFeature.dataSource.load`가 1회성. 재로드 훅 없음 +- **Book 키맵** — `BookFeature.keymap`에 선언은 있지만 주석: *"engine 통합 단계에서 BaselineFinderApp이 activeView keymap을 useEngine plugin으로 주입하면 자동 활성"* — **선언만 있고 소비 측이 없음** + +→ **시사점**: 가장 급한 1개는 **keymap plugin 주입**이다. 이미 선언/어댑터(`featureRegistryToPlugin`)는 존재하므로 BaselineFinderApp의 `useEngine` 경로 한 줄이 해금시킨다. + +--- + +## Gap 3 — 지속성과 HMR: 세션 경계가 없다 + +`/finder`의 `usePersistedState` 4개(viewMode / sortKey / sortDir / kindFilters)와 HMR 핸들러가 BaselineFinderApp에는 전무하다. 새로고침 한 번이면 모든 설정이 증발한다. + +```mermaid +flowchart TD + subgraph P["/finder: 세션 유지"] + V["viewMode → localStorage"] + SK["sortKey → localStorage"] + SD["sortDir → localStorage"] + KF["kindFilters → localStorage"] + E1["EXPANDED_ID state → HMR 후에도 보존"] + F1["FOCUS_ID state → HMR 후에도 보존"] + end + subgraph B["/feature-finder: 항상 초기화"] + U1["useState × 6 (전부 휘발)"] + end +``` + +BaselineFinderApp의 `useState`로 관리되는 것: `enabled / showSettings / rootPath / data / viewModeId / sizes` 6개 — **모두 메모리 전용**. + +→ **시사점**: `usePersistedState`는 이미 범용 primitive이므로 host에서 바로 치환 가능. 설치된 feature 집합(`enabled`)의 persistence가 가장 중요 — 마켓플레이스의 본질이 "내가 설치한 것들"이다. + +--- + +## Gap 4 — 레이아웃 엔진: FlatLayout이 깡통에 없다 + +`/finder`는 `FlatLayout(data, registry)`로 5 widget(Sidebar/Toolbar/TreeGrid/Preview/Miller)을 선언적으로 배치하고 `updateEntityData`로 `hidden` 토글한다. BaselineFinderApp은 `ax({layout:'stack'/'row'})` + 조건부 JSX로 직접 조립한다. + +| 관점 | /finder | /feature-finder | +|---|---|---| +| 배치 선언 | `baseLayout` entity tree + widgetRegistry | JSX 중첩 + `sizes` SplitPane 1회 | +| 토글 방식 | `hidden` field 업데이트 | `hasSidebar && !hideSidebar` 삼항 | +| Resize | FlatLayout이 소유 | `useState` 1곳만 | +| 확장성 | widget 등록 → registry | JSX 가지치기 증가 | + +→ **시사점**: 이건 **아직 gap이 아님** — BaselineFinderApp은 의도적으로 최소 조립을 유지해 `defineFeature` 계약을 검증한다. FlatLayout 채택은 *깡통이 toolbar/mainHeader/overlay 슬롯을 소비할 때* 자연스럽게 들어가야 한다. 현재 그 슬롯들은 선언만 있고 소비 코드가 없다. + +--- + +## 종합 — Gap을 어떤 순서로 닫을 것인가 + +관찰을 모으면 3 계층이 보인다. + +```mermaid +flowchart TD + L1["L1: 이미 선언·어댑터 있음 (소비만)"] --> K["Book keymap → useEngine plugin 주입"] + L1 --> O["overlay 슬롯 → QuickOpen feature화"] + L1 --> T["toolbar 슬롯 → 사용처 찾기"] + + L2["L2: 기여 슬롯 추가 필요"] --> DT["dataTransform (sort/filter)"] + L2 --> VS["virtualSource (Knowledge)"] + L2 --> PRV["previewRenderer (파일 타입별)"] + + L3["L3: host primitive 이식"] --> UP["usePersistedState × 5"] + L3 --> US["useUrlSync"] + L3 --> HM["HMR fs:tree-update"] + + style L1 fill:#e8f5e9,stroke:#2e7d32 + style L2 fill:#fff3e0,stroke:#e65100 + style L3 fill:#e3f2fd,stroke:#1565c0 +``` + +| 우선순위 | 이유 | +|---|---| +| **L1 (소비 연결)** | 작업량 최소, 이미 만든 계약 검증 완료 | +| **L2 (슬롯 확장)** | `defineFeature`의 표현력 한계를 실제로 드러냄. `/finder` 포팅의 본게임 | +| **L3 (primitive 이식)** | feature 경계 없이도 host 업그레이드로 해결 가능, 병렬 진행 가능 | + +→ **시사점**: "`/finder` 기능 = Feature" 1:1 맵이 아니다. 기능마다 (a) 슬롯 선언 필요 여부, (b) host primitive 이식 여부, (c) 단순 소비 연결 여부를 분리 판단해야 한다. 현재 `/feature-finder`는 **dataSource + viewMode + sidebar 3축의 증명을 완주했고**, 다음 마일스톤은 `dataTransform` 슬롯 추가로 sort/filter를 feature화하는 것이다. diff --git a/docs/2026/2026-04/2026-04-21/handoffFinderFilterMddbExt.md b/docs/2026/2026-04/2026-04-21/handoffFinderFilterMddbExt.md new file mode 100644 index 000000000..da3aa79e9 --- /dev/null +++ b/docs/2026/2026-04/2026-04-21/handoffFinderFilterMddbExt.md @@ -0,0 +1,47 @@ +--- +id: handoffFinderFilterMddbExt +type: handoff +slug: handoffFinderFilterMddbExt +title: "Handoff: finder kind filter가 mddb title 치환으로 오작동하던 문제 수정" +tags: [handoff, finder, filter, mddb] +created: 2026-04-21 +updated: 2026-04-21 +status: closed +summary: "kindFilters=[code,doc,config] 활성 시 docs 하위 폴더/파일이 대거 누락되던 버그 수정. filter가 display name 대신 path에서 확장자 추출하도록 변경." +pr: "https://github.com/developer-1px/interactive-os-2/pull/8" +merge_commit: "3f5ddb1ae794e50e5187e99f8ca61326f11accbd" +--- + +# Handoff: finder kind filter × mddb title 확장자 소실 + +> Finder의 `/` 사이드바로 docs를 열고 필터(Code/Doc/Config)를 켜면 2026-03 하위 14개 날짜 폴더 중 1개(2026-03-27)만 노출되던 버그. mddb frontmatter `title`이 파일 display name을 대체하면서 확장자가 사라져 filter의 `getExt(name)`이 빈 문자열을 리턴하는 게 원인. + +## 완료 + +| 커밋 | 내용 | +|------|------| +| `3f5ddb1a` (PR #8) | fix(finder): mddb title 치환 시 확장자 소실로 filter 오작동 | + +- PR: https://github.com/developer-1px/interactive-os-2/pull/8 (MERGED) +- Merge strategy: squash (--admin 사용자 인가) + +## 남은 것 + +### 미완료 (다음 세션 첫 작업) +없음. + +### 이후 (backlog) +- 다른 세션의 uncommitted 변경이 많음 (layout refactor, persist plugin, FavoritesFeature 등). 이 세션 책임 아님 — 해당 세션들이 스스로 정리. + +## 컨텍스트 + +- **변경 파일**: `src/pages/finder/finderFilter.ts:39-49` +- **원인**: `src/pages/finder/treeTransform.ts:14` — `titleMap?.get(node.id) ?? node.name`가 mddb title로 display name을 대체하며 확장자가 사라짐. title 예: "interactive-os — Architecture Map". +- **수정 전략**: filter matches()가 `entity.data.path`(원본 id=fullpath)의 basename에서 확장자 추출. path 없으면 name 폴백. +- **재현 시나리오**: URL `/finder/docs/2026/2026-03/2026-03-27/node-editing.story.yaml`, localStorage `finder-kind-filters='["config","code","doc"]'`, viewmode=columns. +- **주의**: name에도 period가 있을 수 있으나(`.v2`, `node-editing.story.yaml`) path 우선이 더 안전. 테스트 케이스로 mddb title 친 .md 파일 + yaml 파일 공존 폴더를 사용. + +## 이어받는 법 + +세션 교체 시 새 세션이 `/handoff`를 치면 Step B가 이 파일을 집어간다. +구체적 첫 행동: **PR #8를 GitHub UI에서 수동 squash merge**. 그 후 `docs/2026/2026-04/2026-04-21/handoffFinderFilterMddbExt.md`의 `merge_commit` 필드를 실제 merge hash로 업데이트. diff --git a/docs/2026/2026-04/2026-04-21/handoffFlatLayoutOcpSrp.md b/docs/2026/2026-04/2026-04-21/handoffFlatLayoutOcpSrp.md new file mode 100644 index 000000000..7a6e46b73 --- /dev/null +++ b/docs/2026/2026-04/2026-04-21/handoffFlatLayoutOcpSrp.md @@ -0,0 +1,104 @@ +--- +id: handoffFlatLayoutOcpSrp +type: handoff +slug: handoffFlatLayoutOcpSrp +title: "Handoff: FlatLayout OCP+SRP 리팩토링" +tags: [handoff, flatlayout, ocp, srp, refactor] +created: 2026-04-21 +updated: 2026-04-21 +status: open +summary: "FlatLayout.tsx 566→111 LOC, slidesWidgets.tsx 653→307 LOC. 로컬 branch refactor/flatlayout-ocp-srp에 2커밋 대기 — origin/main 동기화 후 PR 생성 필요." +pr: "" +merge_commit: "" +--- + +# Handoff: FlatLayout OCP+SRP 리팩토링 + +> FlatLayout의 거대 layoutRenderers 맵을 nodes/.tsx로 분산 + slidesWidgets.tsx를 도메인별 4파일로 분리. 로컬 브랜치에 2커밋 대기. + +## 완료 (로컬 브랜치) + +브랜치: `refactor/flatlayout-ocp-srp` (로컬 only, origin/main 기준) + +| 커밋 | 내용 | +|------|------| +| `de855b2e` | refactor(layout): FlatLayout 노드 타입별 분산 + defineLayoutNode registry | +| `9fc1fc25` | refactor(slides): slidesWidgets 653→307 LOC, 도메인별 4파일로 분산 | + +### 산출물 + +**FlatLayout OCP+SRP** (`de855b2e`): +- 신규: `src/interactive-os/layout/defineLayoutNode.ts` (registry + descriptor 타입) +- 신규: `src/interactive-os/layout/defineLayout.ts` (팩토리, flatLayout.ts에서 분리) +- 신규: `src/interactive-os/layout/nodes/` — 12 타입 파일 + `_shared/` 3개 + - `nodes/index.ts` — side-effect import + `_AssertAllRegistered` 타입 커버리지 체크 + - 각 타입 파일: `defineLayoutNode(type, { render, isAppRoot?, fillsChildren?, labelFrom? })` +- 신규: `src/interactive-os/ui/useFlatLayoutSurface.ts` (Context + hook 분리) +- 변경: `FlatLayout.tsx` 566→111 LOC (렌더러·화이트리스트·헬퍼 전부 분산) +- 변경: `flatLayout.ts` 순수 타입 파일로 환원 +- 변경: 19개 pages/inspector의 `@os/layout/flatLayout` → `@os/layout/defineLayout` import 이관 + +**slides SRP** (`9fc1fc25`): +- 신규: `slidesTransform.ts` — SlideRow/computeSlideRows/slidesToNormalizedData +- 신규: `slidesDeckWidgets.tsx` — 5 위젯 (Header/Search/Filter/Canvas/Outline) +- 신규: `slidesChatWidgets.tsx` — 2 위젯 (SuggestionChips/PromptComposer) +- 신규: `slidesOverlayWidgets.tsx` — 1 위젯 (CommentThread) +- 변경: `slidesWidgets.tsx` 653→307 LOC (registry 허브 + 잔류 4개 위젯) +- os 위반 3건 해소: ChatFeed renderItem identifier화, SlideSorter `