# Editor Docking System Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a binary split tree docking system to voltex_editor with resizable panels and tab switching. **Architecture:** Binary tree of Split/Leaf nodes. Split nodes divide space by ratio, Leaf nodes hold tabs. DockTree owns the tree, caches layout results, handles resize drag and tab clicks internally. No unsafe code — borrow issues resolved by collecting mutation targets before applying. **Tech Stack:** Rust, existing voltex_editor IMGUI (UiContext, DrawList, FontAtlas, LayoutState) **Spec:** `docs/superpowers/specs/2026-03-26-editor-docking-design.md` --- ### Task 1: Data model + layout algorithm **Files:** - Create: `crates/voltex_editor/src/dock.rs` - Modify: `crates/voltex_editor/src/lib.rs` - [ ] **Step 1: Write failing tests for Rect and layout** Add to `crates/voltex_editor/src/dock.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_rect_contains() { let r = Rect { x: 10.0, y: 20.0, w: 100.0, h: 50.0 }; assert!(r.contains(50.0, 40.0)); assert!(!r.contains(5.0, 40.0)); assert!(!r.contains(50.0, 80.0)); } #[test] fn test_layout_single_leaf() { let mut dock = DockTree::new( DockNode::leaf(vec![0]), vec!["Panel0"], ); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); assert_eq!(areas.len(), 1); assert_eq!(areas[0].0, 0); let r = &areas[0].1; assert!((r.y - 20.0).abs() < 1e-3); assert!((r.h - 280.0).abs() < 1e-3); } #[test] fn test_layout_horizontal_split() { let mut dock = DockTree::new( DockNode::split(Axis::Horizontal, 0.25, DockNode::leaf(vec![0]), DockNode::leaf(vec![1])), vec!["Left", "Right"], ); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); assert_eq!(areas.len(), 2); let left = areas.iter().find(|(id, _)| *id == 0).unwrap(); assert!((left.1.w - 100.0).abs() < 1e-3); let right = areas.iter().find(|(id, _)| *id == 1).unwrap(); assert!((right.1.w - 300.0).abs() < 1e-3); } #[test] fn test_layout_vertical_split() { let mut dock = DockTree::new( DockNode::split(Axis::Vertical, 0.5, DockNode::leaf(vec![0]), DockNode::leaf(vec![1])), vec!["Top", "Bottom"], ); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); let top = areas.iter().find(|(id, _)| *id == 0).unwrap(); assert!((top.1.h - 130.0).abs() < 1e-3); // 150 - 20 tab bar } #[test] fn test_layout_nested_split() { let mut dock = DockTree::new( DockNode::split( Axis::Horizontal, 0.25, DockNode::leaf(vec![0]), DockNode::split(Axis::Vertical, 0.5, DockNode::leaf(vec![1]), DockNode::leaf(vec![2])), ), vec!["A", "B", "C"], ); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); assert_eq!(areas.len(), 3); } #[test] fn test_layout_active_tab_only() { let mut dock = DockTree::new( DockNode::Leaf { tabs: vec![0, 1, 2], active: 1 }, vec!["A", "B", "C"], ); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); assert_eq!(areas[0].0, 1); } #[test] fn test_active_clamped_if_out_of_bounds() { let mut dock = DockTree::new( DockNode::Leaf { tabs: vec![0], active: 5 }, vec!["A"], ); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); assert_eq!(areas[0].0, 0); } #[test] #[should_panic] fn test_empty_tabs_panics() { let mut dock = DockTree::new( DockNode::Leaf { tabs: vec![], active: 0 }, vec![], ); dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test -p voltex_editor --lib dock -- --nocapture` Expected: compilation errors (types don't exist) - [ ] **Step 3: Implement data model and layout** Write the implementation in `crates/voltex_editor/src/dock.rs`. Key design decisions: - `layout_recursive` is a **free function** taking `&DockNode`, `Rect`, and `&mut Vec` / `&mut Vec` — avoids borrow issues with `&mut self` vs `&self.root`. - No `unsafe` code. No raw pointers. - `debug_assert!(!tabs.is_empty())` in `layout_recursive` for the empty tabs invariant. - `LeafLayout` includes `leaf_index: usize` for mutation targeting. ```rust const TAB_BAR_HEIGHT: f32 = 20.0; const MIN_RATIO: f32 = 0.1; const MAX_RATIO: f32 = 0.9; const RESIZE_HANDLE_HALF: f32 = 3.0; const GLYPH_W: f32 = 8.0; const TAB_PADDING: f32 = 8.0; #[derive(Clone, Copy, Debug)] pub struct Rect { pub x: f32, pub y: f32, pub w: f32, pub h: f32, } impl Rect { pub fn contains(&self, px: f32, py: f32) -> bool { px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h } } #[derive(Clone, Copy, Debug, PartialEq)] pub enum Axis { Horizontal, Vertical } pub enum DockNode { Leaf { tabs: Vec, active: usize }, Split { axis: Axis, ratio: f32, children: [Box; 2] }, } impl DockNode { pub fn leaf(tabs: Vec) -> Self { DockNode::Leaf { tabs, active: 0 } } pub fn split(axis: Axis, ratio: f32, a: DockNode, b: DockNode) -> Self { DockNode::Split { axis, ratio: ratio.clamp(MIN_RATIO, MAX_RATIO), children: [Box::new(a), Box::new(b)] } } } pub struct LeafLayout { pub leaf_index: usize, pub tabs: Vec, pub active: usize, pub tab_bar_rect: Rect, pub content_rect: Rect, } struct SplitLayout { rect: Rect, axis: Axis, boundary: f32, path: Vec, } struct ResizeState { path: Vec, axis: Axis, origin: f32, size: f32, } pub struct DockTree { root: DockNode, names: Vec<&'static str>, cached_leaves: Vec, cached_splits: Vec, resizing: Option, prev_mouse_down: bool, } fn layout_recursive( node: &DockNode, rect: Rect, path: &mut Vec, leaf_counter: &mut usize, leaves: &mut Vec, splits: &mut Vec, ) { match node { DockNode::Leaf { tabs, active } => { debug_assert!(!tabs.is_empty(), "DockNode::Leaf must have at least one tab"); let idx = *leaf_counter; *leaf_counter += 1; leaves.push(LeafLayout { leaf_index: idx, tabs: tabs.clone(), active: (*active).min(tabs.len() - 1), tab_bar_rect: Rect { x: rect.x, y: rect.y, w: rect.w, h: TAB_BAR_HEIGHT }, content_rect: Rect { x: rect.x, y: rect.y + TAB_BAR_HEIGHT, w: rect.w, h: (rect.h - TAB_BAR_HEIGHT).max(0.0) }, }); } DockNode::Split { axis, ratio, children } => { let (r1, r2, boundary) = match axis { Axis::Horizontal => { let w1 = rect.w * ratio; let b = rect.x + w1; (Rect { x: rect.x, y: rect.y, w: w1, h: rect.h }, Rect { x: b, y: rect.y, w: rect.w - w1, h: rect.h }, b) } Axis::Vertical => { let h1 = rect.h * ratio; let b = rect.y + h1; (Rect { x: rect.x, y: rect.y, w: rect.w, h: h1 }, Rect { x: rect.x, y: b, w: rect.w, h: rect.h - h1 }, b) } }; splits.push(SplitLayout { rect, axis: *axis, boundary, path: path.clone() }); path.push(0); layout_recursive(&children[0], r1, path, leaf_counter, leaves, splits); path.pop(); path.push(1); layout_recursive(&children[1], r2, path, leaf_counter, leaves, splits); path.pop(); } } } impl DockTree { pub fn new(root: DockNode, names: Vec<&'static str>) -> Self { DockTree { root, names, cached_leaves: Vec::new(), cached_splits: Vec::new(), resizing: None, prev_mouse_down: false } } pub fn layout(&mut self, rect: Rect) -> Vec<(u32, Rect)> { self.cached_leaves.clear(); self.cached_splits.clear(); let mut path = Vec::new(); let mut counter = 0; layout_recursive(&self.root, rect, &mut path, &mut counter, &mut self.cached_leaves, &mut self.cached_splits); self.cached_leaves.iter().map(|l| { let active = l.active.min(l.tabs.len().saturating_sub(1)); (l.tabs[active], l.content_rect) }).collect() } } ``` - [ ] **Step 4: Add module to lib.rs** ```rust pub mod dock; pub use dock::{DockTree, DockNode, Axis, Rect, LeafLayout}; ``` - [ ] **Step 5: Run tests to verify they pass** Run: `cargo test -p voltex_editor --lib dock -- --nocapture` Expected: all 8 tests PASS - [ ] **Step 6: Commit** ```bash git add crates/voltex_editor/src/dock.rs crates/voltex_editor/src/lib.rs git commit -m "feat(editor): add dock tree data model and layout algorithm" ``` --- ### Task 2: Tab click + resize drag (update method) **Files:** - Modify: `crates/voltex_editor/src/dock.rs` - [ ] **Step 1: Write failing tests** ```rust #[test] fn test_tab_click_switches_active() { let mut dock = DockTree::new( DockNode::Leaf { tabs: vec![0, 1, 2], active: 0 }, vec!["A", "B", "C"], ); dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); // Tab "A": w = 1*8+8 = 16, tab "B" starts at x=16. Click at x=20 (inside "B"). dock.update(20.0, 10.0, true); // just_clicked detected here dock.update(20.0, 10.0, false); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); assert_eq!(areas[0].0, 1); } #[test] fn test_tab_click_no_change_on_single_tab() { let mut dock = DockTree::new(DockNode::leaf(vec![0]), vec!["Only"]); dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); dock.update(10.0, 10.0, true); dock.update(10.0, 10.0, false); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); assert_eq!(areas[0].0, 0); } #[test] fn test_resize_horizontal_drag() { let mut dock = DockTree::new( DockNode::split(Axis::Horizontal, 0.5, DockNode::leaf(vec![0]), DockNode::leaf(vec![1])), vec!["L", "R"], ); dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); // Boundary at x=200. Click on it, drag to x=120. dock.update(200.0, 150.0, true); dock.update(120.0, 150.0, true); dock.update(120.0, 150.0, false); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); let left = areas.iter().find(|(id, _)| *id == 0).unwrap(); assert!((left.1.w - 120.0).abs() < 5.0, "left w={}", left.1.w); } #[test] fn test_resize_clamps_ratio() { let mut dock = DockTree::new( DockNode::split(Axis::Horizontal, 0.5, DockNode::leaf(vec![0]), DockNode::leaf(vec![1])), vec!["L", "R"], ); dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); dock.update(200.0, 150.0, true); dock.update(5.0, 150.0, true); dock.update(5.0, 150.0, false); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); let left = areas.iter().find(|(id, _)| *id == 0).unwrap(); assert!((left.1.w - 40.0).abs() < 1e-3, "left w={}", left.1.w); // 400*0.1=40 } #[test] fn test_resize_priority_over_tab_click() { // Resize handle should take priority when overlapping with tab bar let mut dock = DockTree::new( DockNode::split(Axis::Horizontal, 0.5, DockNode::Leaf { tabs: vec![0, 1], active: 0 }, DockNode::leaf(vec![2]), ), vec!["A", "B", "C"], ); dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); // Click right at the boundary (x=200) within tab bar height (y=10) dock.update(200.0, 10.0, true); // If resize took priority, resizing should be Some // Drag to verify ratio changes dock.update(180.0, 10.0, true); dock.update(180.0, 10.0, false); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); let left = areas.iter().find(|(id, _)| *id == 0 || *id == 1).unwrap(); assert!((left.1.w - 180.0).abs() < 5.0, "resize should have priority, w={}", left.1.w); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test -p voltex_editor --lib dock::tests::test_tab_click -- --nocapture` Expected: FAIL (update doesn't exist) - [ ] **Step 3: Implement update method** Key design: collect mutation target into a local variable, then apply outside the loop to avoid borrow conflicts. ```rust /// Possible mutation from update enum UpdateAction { None, StartResize(ResizeState), SetActiveTab { leaf_index: usize, new_active: usize }, } impl DockTree { pub fn update(&mut self, mouse_x: f32, mouse_y: f32, mouse_down: bool) { let just_clicked = mouse_down && !self.prev_mouse_down; self.prev_mouse_down = mouse_down; // Active resize in progress if let Some(ref state) = self.resizing { if mouse_down { let new_ratio = match state.axis { Axis::Horizontal => (mouse_x - state.origin) / state.size, Axis::Vertical => (mouse_y - state.origin) / state.size, }; let clamped = new_ratio.clamp(MIN_RATIO, MAX_RATIO); let path = state.path.clone(); Self::set_ratio_at_path(&mut self.root, &path, clamped); } else { self.resizing = None; } return; } if !just_clicked { return; } // Determine action by reading cached layouts (immutable) let action = self.find_click_action(mouse_x, mouse_y); // Apply action (mutable) match action { UpdateAction::StartResize(state) => { self.resizing = Some(state); } UpdateAction::SetActiveTab { leaf_index, new_active } => { Self::set_active_nth(&mut self.root, leaf_index, new_active, &mut 0); } UpdateAction::None => {} } } fn find_click_action(&self, mx: f32, my: f32) -> UpdateAction { // Check resize handles first (priority) for split in &self.cached_splits { let hit = match split.axis { Axis::Horizontal => mx >= split.boundary - RESIZE_HANDLE_HALF && mx <= split.boundary + RESIZE_HANDLE_HALF && my >= split.rect.y && my < split.rect.y + split.rect.h, Axis::Vertical => my >= split.boundary - RESIZE_HANDLE_HALF && my <= split.boundary + RESIZE_HANDLE_HALF && mx >= split.rect.x && mx < split.rect.x + split.rect.w, }; if hit { let (origin, size) = match split.axis { Axis::Horizontal => (split.rect.x, split.rect.w), Axis::Vertical => (split.rect.y, split.rect.h), }; return UpdateAction::StartResize(ResizeState { path: split.path.clone(), axis: split.axis, origin, size }); } } // Check tab bar clicks for leaf in &self.cached_leaves { if leaf.tabs.len() <= 1 { continue; } if !leaf.tab_bar_rect.contains(mx, my) { continue; } let mut tx = leaf.tab_bar_rect.x; for (i, &panel_id) in leaf.tabs.iter().enumerate() { let name = self.names.get(panel_id as usize).map(|n| n.len()).unwrap_or(1); let tab_w = name as f32 * GLYPH_W + TAB_PADDING; if mx >= tx && mx < tx + tab_w { return UpdateAction::SetActiveTab { leaf_index: leaf.leaf_index, new_active: i }; } tx += tab_w; } } UpdateAction::None } fn set_ratio_at_path(node: &mut DockNode, path: &[usize], ratio: f32) { if path.is_empty() { if let DockNode::Split { ratio: r, .. } = node { *r = ratio; } return; } if let DockNode::Split { children, .. } = node { Self::set_ratio_at_path(&mut children[path[0]], &path[1..], ratio); } } fn set_active_nth(node: &mut DockNode, target: usize, new_active: usize, count: &mut usize) { match node { DockNode::Leaf { active, .. } => { if *count == target { *active = new_active; } *count += 1; } DockNode::Split { children, .. } => { Self::set_active_nth(&mut children[0], target, new_active, count); Self::set_active_nth(&mut children[1], target, new_active, count); } } } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cargo test -p voltex_editor --lib dock -- --nocapture` Expected: all tests PASS - [ ] **Step 5: Commit** ```bash git add crates/voltex_editor/src/dock.rs git commit -m "feat(editor): add update method with tab click and resize handling" ``` --- ### Task 3: draw_chrome — tab bars and split lines **Files:** - Modify: `crates/voltex_editor/src/dock.rs` - [ ] **Step 1: Write failing tests** ```rust #[test] fn test_draw_chrome_produces_draw_commands() { let mut dock = DockTree::new( DockNode::split(Axis::Horizontal, 0.5, DockNode::Leaf { tabs: vec![0, 1], active: 0 }, DockNode::leaf(vec![2]), ), vec!["A", "B", "C"], ); let mut ui = UiContext::new(800.0, 600.0); ui.begin_frame(0.0, 0.0, false); dock.layout(Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 }); dock.draw_chrome(&mut ui); assert!(ui.draw_list.commands.len() >= 5); } #[test] fn test_draw_chrome_active_tab_color() { let mut dock = DockTree::new( DockNode::Leaf { tabs: vec![0, 1], active: 1 }, vec!["AA", "BB"], ); let mut ui = UiContext::new(800.0, 600.0); ui.begin_frame(0.0, 0.0, false); dock.layout(Rect { x: 0.0, y: 0.0, w: 400.0, h: 300.0 }); dock.draw_chrome(&mut ui); // First tab bg (inactive): color [40, 40, 40, 255] let first_bg = &ui.draw_list.vertices[0]; assert_eq!(first_bg.color, [40, 40, 40, 255]); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test -p voltex_editor --lib dock::tests::test_draw_chrome -- --nocapture` Expected: FAIL (draw_chrome doesn't exist) - [ ] **Step 3: Implement draw_chrome** Inline glyph rendering to avoid `&mut ui.draw_list` + `&ui.font` borrow conflict (same pattern as existing `widgets.rs`): ```rust impl DockTree { pub fn draw_chrome(&self, ui: &mut UiContext) { let glyph_w = ui.font.glyph_width as f32; let glyph_h = ui.font.glyph_height as f32; const COLOR_TAB_ACTIVE: [u8; 4] = [60, 60, 60, 255]; const COLOR_TAB_INACTIVE: [u8; 4] = [40, 40, 40, 255]; const COLOR_TEXT: [u8; 4] = [0xEE, 0xEE, 0xEE, 0xFF]; const COLOR_SPLIT: [u8; 4] = [30, 30, 30, 255]; const COLOR_SPLIT_ACTIVE: [u8; 4] = [100, 100, 200, 255]; const COLOR_SEPARATOR: [u8; 4] = [50, 50, 50, 255]; // Draw tab bars for leaf in &self.cached_leaves { let bar = &leaf.tab_bar_rect; let active = leaf.active.min(leaf.tabs.len().saturating_sub(1)); let mut tx = bar.x; for (i, &panel_id) in leaf.tabs.iter().enumerate() { let name = self.names.get(panel_id as usize).copied().unwrap_or("?"); let tab_w = name.len() as f32 * glyph_w + TAB_PADDING; let bg = if i == active { COLOR_TAB_ACTIVE } else { COLOR_TAB_INACTIVE }; ui.draw_list.add_rect(tx, bar.y, tab_w, bar.h, bg); // Inline text rendering let text_x = tx + TAB_PADDING * 0.5; let text_y = bar.y + (bar.h - glyph_h) * 0.5; let mut cx = text_x; for ch in name.chars() { let (u0, v0, u1, v1) = ui.font.glyph_uv(ch); ui.draw_list.add_rect_uv(cx, text_y, glyph_w, glyph_h, u0, v0, u1, v1, COLOR_TEXT); cx += glyph_w; } tx += tab_w; } // Tab bar separator ui.draw_list.add_rect(bar.x, bar.y + bar.h - 1.0, bar.w, 1.0, COLOR_SEPARATOR); } // Draw split lines for split in &self.cached_splits { let color = if self.resizing.as_ref().map(|r| &r.path) == Some(&split.path) { COLOR_SPLIT_ACTIVE } else { COLOR_SPLIT }; match split.axis { Axis::Horizontal => ui.draw_list.add_rect(split.boundary - 0.5, split.rect.y, 1.0, split.rect.h, color), Axis::Vertical => ui.draw_list.add_rect(split.rect.x, split.boundary - 0.5, split.rect.w, 1.0, color), } } } } ``` - [ ] **Step 4: Run all tests** Run: `cargo test -p voltex_editor --lib dock -- --nocapture` Expected: all tests PASS - [ ] **Step 5: Commit** ```bash git add crates/voltex_editor/src/dock.rs git commit -m "feat(editor): add draw_chrome for tab bars and split lines" ``` --- ### Task 4: Integration test + update editor_demo **Files:** - Modify: `crates/voltex_editor/src/dock.rs` - Modify: `examples/editor_demo/src/main.rs` - [ ] **Step 1: Write integration test** ```rust #[test] fn test_full_frame_cycle() { let mut dock = DockTree::new( DockNode::split(Axis::Horizontal, 0.3, DockNode::Leaf { tabs: vec![0, 1], active: 0 }, DockNode::split(Axis::Vertical, 0.6, DockNode::leaf(vec![2]), DockNode::leaf(vec![3])), ), vec!["Hierarchy", "Inspector", "Viewport", "Console"], ); let mut ui = UiContext::new(1280.0, 720.0); for _ in 0..3 { ui.begin_frame(100.0, 100.0, false); let areas = dock.layout(Rect { x: 0.0, y: 0.0, w: 1280.0, h: 720.0 }); dock.update(100.0, 100.0, false); dock.draw_chrome(&mut ui); assert_eq!(areas.len(), 4); for (_, r) in &areas { assert!(r.w > 0.0); assert!(r.h > 0.0); } ui.end_frame(); } } ``` - [ ] **Step 2: Run test** Run: `cargo test -p voltex_editor --lib dock::tests::test_full_frame -- --nocapture` Expected: PASS - [ ] **Step 3: Update editor_demo to use docking** Read `examples/editor_demo/src/main.rs`. Replace the single `begin_panel`/`end_panel` block with a docked layout: - Add `dock: DockTree` to the app state struct - Initialize with 4-panel layout (Debug, Viewport, Properties, Console) - In render loop: call `dock.layout()`, `dock.update()`, `dock.draw_chrome()` - Draw panel contents by matching `panel_id` and setting `ui.layout = LayoutState::new(rect.x + 4.0, rect.y + 4.0)` - [ ] **Step 4: Build** Run: `cargo build --example editor_demo` Expected: compiles - [ ] **Step 5: Run all tests** Run: `cargo test -p voltex_editor -- --nocapture` Expected: all tests PASS - [ ] **Step 6: Commit** ```bash git add crates/voltex_editor/src/dock.rs examples/editor_demo/src/main.rs git commit -m "feat(editor): integrate docking into editor_demo" ``` --- ### Task 5: Update docs **Files:** - Modify: `docs/STATUS.md` - Modify: `docs/DEFERRED.md` - [ ] **Step 1: Update STATUS.md** Add to Phase 8-4 section: ``` - voltex_editor: DockTree (binary split layout, resize, tabs) ``` Update test count. - [ ] **Step 2: Update DEFERRED.md** ``` - ~~**도킹, 탭, 윈도우 드래그**~~ ✅ DockTree (이진 분할, 리사이즈, 탭 전환) 완료. 플로팅 윈도우/탭 드래그 이동 미구현. ``` - [ ] **Step 3: Commit** ```bash git add docs/STATUS.md docs/DEFERRED.md git commit -m "docs: update STATUS.md and DEFERRED.md with docking system" ```