# V0.3 — Real App Core 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 `overflow: hidden` clipping, `on_scroll` event dispatch, a `Scroll` viewport widget, a `TextFieldState` data type, and a `TextInput` widget — enabling the first real form-like UI in Lemon.

**Architecture:** `Overflow::Hidden` is a paint-only concern: the layout pass (Taffy) is unaffected; the paint pass wraps children in a Vello clip layer when the flag is set. `Scroll` is a pure builder in `lemon::widget` — the user owns a `Signal<f64>` offset and passes it in; `Scroll` emits the viewport Box_ with `on_scroll` and the inner content Box_ with negative `margin_top`. `TextInput` is also a pure builder that takes `Signal<TextFieldState>` plus `Signal<bool>` for focus tracking; keyboard routing uses the V0.2 `FocusManager`. Click-to-focus (setting `FocusManager.focused` on click of a focusable node) is wired in this milestone.

**Tech Stack:** Rust, winit 0.30 (MouseWheel events), Taffy 0.7 (margin_top for scroll offset), Vello 0.9 (push_layer/pop_layer for clipping), lemon::widget crate (Scroll, TextInput builders).

**Pre-requisite:** V0.2 complete. The following are already in place:
- `EventHandlers`: `on_click`, `on_key_down`, `on_key_up`, `on_hover_enter`, `on_hover_leave`
- `BoxElement.handlers: EventHandlers`
- `StyleProps`: `focusable: bool`, `cursor: Cursor`
- `FocusManager` with `focused: Option<NodeId>`, `cycle(tree, forward)`, `focus_by_id(id, tree)`
- Container builders: `.on_click()`, `.on_key_down()`, `.on_key_up()`, `.on_hover_enter()`, `.on_hover_leave()`, `.focusable()`
- `KeyEvent`, `LemonKey`, `NamedKey`, `Modifiers` exported from `lemon`
- `AppState`: `focus_manager: FocusManager`, `hovered_node_id: Option<NodeId>`
- Platform dispatches keyboard to `focus_manager.focused` node

---

## File Map

| Action | Path | Responsibility |
|--------|------|----------------|
| Modify | `src/src/element/style.rs` | Add `Overflow` enum, `overflow` field to `StyleProps` |
| Modify | `src/src/element/builders.rs` | Add `.overflow()`, `.margin_top()`, `.on_scroll()` to container builders |
| Modify | `src/src/retained/mod.rs` | Add `on_scroll` to `EventHandlers` |
| Modify | `src/src/paint/mod.rs` | Wrap children in clip layer when `overflow == Overflow::Hidden` |
| Modify | `src/src/platform/hit_test.rs` | Add `hit_test_scroll`, `find_node_by_taffy_id` (if not already in V0.2) |
| Modify | `src/src/platform/mod.rs` | Handle `MouseWheel` event; wire click-to-focus in `event_pass_click` |
| Modify | `src/src/lib.rs` | Re-export `Overflow` |
| Create | `src/widget/src/scroll.rs` | `Scroll` builder widget |
| Create | `src/widget/src/text_field_state.rs` | `TextFieldState` with keyboard editing logic |
| Create | `src/widget/src/text_input.rs` | `TextInput` builder widget |
| Modify | `src/widget/src/lib.rs` | Export `Scroll`, `TextFieldState`, `TextInput` |
| Create | `examples/form/Cargo.toml` | Form example manifest |
| Create | `examples/form/src/main.rs` | Demo: two TextInput + Scroll list + Submit |
| Modify | `Cargo.toml` (root) | Add `examples/form` to workspace members |

---

## Task 1: `Overflow` enum and `.overflow()` builder

**Files:**
- Modify: `src/src/element/style.rs`
- Modify: `src/src/element/builders.rs`
- Modify: `src/src/lib.rs`

- [ ] **Step 1.1: Write the failing test**

In `src/src/element/style.rs`, at the bottom of the `#[cfg(test)]` block (or create one), add:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn overflow_defaults_to_visible() {
        assert_eq!(StyleProps::default().overflow, Overflow::Visible);
    }
}
```

Then in `src/src/element/builders.rs`, add to the existing `#[cfg(test)]` block:

```rust
#[test]
fn box_overflow_hidden_propagates() {
    use crate::element::style::Overflow;
    let Element::Box_(el) = Box_::new().overflow(Overflow::Hidden).into_element() else {
        panic!("expected Box_ element");
    };
    assert_eq!(el.style.overflow, Overflow::Hidden);
}
```

- [ ] **Step 1.2: Run the tests to verify they fail**

```bash
cargo test -p lemon overflow 2>&1 | tail -10
```

Expected: compile error — `Overflow` does not exist yet.

- [ ] **Step 1.3: Add `Overflow` enum and `overflow` field to `StyleProps`**

In `src/src/element/style.rs`, add before the `StyleProps` struct:

```rust
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Overflow {
    #[default]
    Visible,
    Hidden,
}
```

Then add the field to `StyleProps`:

```rust
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StyleProps {
    pub width: Option<Dimension>,
    pub height: Option<Dimension>,
    pub padding: Option<Edges<f32>>,
    pub margin: Option<Edges<f32>>,
    pub gap: Option<f32>,
    pub flex_grow: Option<f32>,
    pub flex_shrink: Option<f32>,
    pub align_items: Option<Align>,
    pub justify_content: Option<Justify>,
    pub align_self: Option<Align>,
    pub focusable: bool,
    pub cursor: crate::element::events::Cursor,
    pub overflow: Overflow,
}
```

- [ ] **Step 1.4: Add `.overflow()` builder to the `container_builder!` macro**

In `src/src/element/builders.rs`, inside the macro body after the existing methods (before `into_element`), add:

```rust
            pub fn overflow(mut self, v: crate::element::style::Overflow) -> Self {
                self.0.style.overflow = v;
                self
            }
```

Also add at the top of the file, in the existing use statement, add `Overflow` to the style imports:

```rust
use crate::element::{
    content::TextContent,
    style::{Color, ColorSource, CornerRadii, Edges, Overflow, PaintProps, StyleProps, TextStyle},
    types::{BoxElement, ComponentElement, ComponentFn, Key, TextElement},
    Element,
};
```

- [ ] **Step 1.5: Re-export `Overflow` from `src/src/lib.rs`**

Add to the existing re-exports:

```rust
pub use element::style::Overflow;
```

- [ ] **Step 1.6: Run the tests to verify they pass**

```bash
cargo test -p lemon overflow
```

Expected: 2 tests pass (`overflow_defaults_to_visible`, `box_overflow_hidden_propagates`).

- [ ] **Step 1.7: Run all workspace tests to verify nothing broke**

```bash
cargo test --workspace
```

Expected: all existing tests still pass.

- [ ] **Step 1.8: Commit**

```bash
git add src/src/element/style.rs \
        src/src/element/builders.rs \
        src/src/lib.rs
git commit -m "feat: add Overflow enum and overflow field to StyleProps"
```

---

## Task 2: Clipping in the paint pass

**Files:**
- Modify: `src/src/paint/mod.rs`

- [ ] **Step 2.1: Write the failing test**

In `src/src/paint/mod.rs`, add to the existing `#[cfg(test)]` block:

```rust
#[test]
fn overflow_hidden_emits_extra_push_pop_layer() {
    use crate::element::style::Overflow;

    // Without overflow:hidden — one root push_layer from paint_pass itself.
    let mut tree_no_clip = RetainedTree::mount(
        Column::new()
            .width(100.0)
            .height(80.0)
            .background(Color::rgb8(40, 80, 120))
            .child(Text::new("inner").font_size(12.0))
            .into_element(),
    )
    .unwrap();
    let stats_no_clip = layout_and_paint(&mut tree_no_clip, 1.0);

    // With overflow:hidden — paint_node wraps children in an extra layer.
    // We can't inspect Vello scene layers directly, but we verify the paint
    // completes without panic and produces the same fill count.
    let mut tree_clip = RetainedTree::mount(
        Column::new()
            .width(100.0)
            .height(80.0)
            .background(Color::rgb8(40, 80, 120))
            .overflow(Overflow::Hidden)
            .child(Text::new("inner").font_size(12.0))
            .into_element(),
    )
    .unwrap();
    let stats_clip = layout_and_paint(&mut tree_clip, 1.0);

    // Same fill count — clipping doesn't change how many fills are drawn.
    assert_eq!(stats_no_clip.fills, stats_clip.fills);
    // Both produce glyph runs for the text child.
    assert!(stats_clip.glyph_runs > 0);
}
```

- [ ] **Step 2.2: Run the test — it must fail**

```bash
cargo test -p lemon overflow_hidden_emits 2>&1 | tail -5
```

Expected: compile error — `Overflow` not in scope for paint tests, or the test panics due to missing clip logic.

- [ ] **Step 2.3: Add clip layer in `paint_node`**

In `src/src/paint/mod.rs`, add the import at the top:

```rust
use crate::element::style::Overflow;
```

Then change `paint_node` so that children of a node with `overflow: Hidden` are wrapped in a Vello clip layer. Replace:

```rust
    paint_container(node, rect, scene, ctx, stats);
    paint_text(node, rect, scene, ctx, stats);

    for child in &node.children {
        paint_node(child, layout, scene, ctx, stats);
    }
```

with:

```rust
    paint_container(node, rect, scene, ctx, stats);
    paint_text(node, rect, scene, ctx, stats);

    if node.style.overflow == Overflow::Hidden {
        let clip = Rect::new(
            f64::from(rect.x),
            f64::from(rect.y),
            f64::from(rect.x + rect.width),
            f64::from(rect.y + rect.height),
        );
        scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, ctx.base, &clip);
        for child in &node.children {
            paint_node(child, layout, scene, ctx, stats);
        }
        scene.pop_layer();
    } else {
        for child in &node.children {
            paint_node(child, layout, scene, ctx, stats);
        }
    }
```

`Rect` is already imported from `vello::kurbo`. `Fill` and `BlendMode` are already imported.

- [ ] **Step 2.4: Run the test to verify it passes**

```bash
cargo test -p lemon overflow_hidden_emits
```

Expected: `overflow_hidden_emits_extra_push_pop_layer ... ok`.

- [ ] **Step 2.5: Run all tests**

```bash
cargo test --workspace
```

Expected: all tests pass.

- [ ] **Step 2.6: Commit**

```bash
git add src/src/paint/mod.rs
git commit -m "feat: clip children with Vello push_layer when overflow is Hidden"
```

---

## Task 3: `on_scroll`, `margin_top`, and MouseWheel dispatch

**Files:**
- Modify: `src/src/retained/mod.rs`
- Modify: `src/src/element/builders.rs`
- Modify: `src/src/platform/hit_test.rs`
- Modify: `src/src/platform/mod.rs`

- [ ] **Step 3.1: Write the failing test for `on_scroll`**

In `src/src/retained/mod.rs`, add to the `#[cfg(test)]` block:

```rust
#[test]
fn box_on_scroll_handler_is_stored_in_retained_node() {
    use std::cell::Cell;
    let delta_received = Rc::new(Cell::new(0.0f64));
    let d = delta_received.clone();

    let tree = RetainedTree::mount(
        Box_::new()
            .width(200.0)
            .height(150.0)
            .on_scroll(move |delta| d.set(delta))
            .into_element(),
    )
    .unwrap();

    let root = tree.root.as_ref().unwrap();
    assert!(root.handlers.on_scroll.is_some());
    root.handlers.on_scroll.as_ref().unwrap()(42.0);
    assert_eq!(delta_received.get(), 42.0);
}
```

- [ ] **Step 3.2: Run the test to verify it fails**

```bash
cargo test -p lemon box_on_scroll 2>&1 | tail -5
```

Expected: compile error — `on_scroll` does not exist on builder or `EventHandlers`.

- [ ] **Step 3.3: Add `on_scroll` to `EventHandlers`**

In `src/src/retained/mod.rs`, update `EventHandlers`:

```rust
#[derive(Clone, Default)]
pub struct EventHandlers {
    pub on_click: Option<Rc<dyn Fn()>>,
    pub on_key_down: Option<Rc<dyn Fn(crate::element::events::KeyEvent)>>,
    pub on_key_up: Option<Rc<dyn Fn(crate::element::events::KeyEvent)>>,
    pub on_hover_enter: Option<Rc<dyn Fn()>>,
    pub on_hover_leave: Option<Rc<dyn Fn()>>,
    pub on_scroll: Option<Rc<dyn Fn(f64)>>,
}

impl std::fmt::Debug for EventHandlers {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EventHandlers")
            .field("on_click", &self.on_click.as_ref().map(|_| "Rc<dyn Fn()>"))
            .field("on_key_down", &self.on_key_down.as_ref().map(|_| "Rc<dyn Fn(KeyEvent)>"))
            .field("on_key_up", &self.on_key_up.as_ref().map(|_| "Rc<dyn Fn(KeyEvent)>"))
            .field("on_hover_enter", &self.on_hover_enter.as_ref().map(|_| "Rc<dyn Fn()>"))
            .field("on_hover_leave", &self.on_hover_leave.as_ref().map(|_| "Rc<dyn Fn()>"))
            .field("on_scroll", &self.on_scroll.as_ref().map(|_| "Rc<dyn Fn(f64)>"))
            .finish()
    }
}
```

- [ ] **Step 3.4: Add `.on_scroll()` and `.margin_top()` to the `container_builder!` macro**

In `src/src/element/builders.rs`, inside the macro body, add after the existing handler methods:

```rust
            pub fn on_scroll(mut self, f: impl Fn(f64) + 'static) -> Self {
                self.0.handlers.on_scroll = Some(Rc::new(f));
                self
            }
            pub fn margin_top(mut self, v: f32) -> Self {
                let mut edges = self.0.style.margin.take().unwrap_or_default();
                edges.top = v;
                self.0.style.margin = Some(edges);
                self
            }
```

- [ ] **Step 3.5: Run the `on_scroll` test to verify it passes**

```bash
cargo test -p lemon box_on_scroll
```

Expected: `box_on_scroll_handler_is_stored_in_retained_node ... ok`.

- [ ] **Step 3.6: Write the failing test for `hit_test_scroll`**

In `src/src/platform/hit_test.rs`, add to the `#[cfg(test)]` block:

```rust
#[test]
fn hit_test_scroll_finds_node_with_on_scroll() {
    use std::cell::Cell;
    let scrolled = Rc::new(Cell::new(false));
    let s = scrolled.clone();

    let mut tree = RetainedTree::mount(
        Box_::new()
            .width(200.0)
            .height(150.0)
            .on_scroll(move |_| s.set(true))
            .into_element(),
    )
    .unwrap();
    let layout = layout_pass(
        &mut tree,
        Viewport { width: 400.0, height: 400.0 },
        1.0,
    )
    .unwrap();

    let root = tree.root.as_ref().unwrap();
    let hit = hit_test_scroll(root, &layout, LogicalPoint::new(50.0, 50.0));
    assert!(hit.is_some());
    if let Some(node) = hit {
        node.handlers.on_scroll.as_ref().unwrap()(10.0);
    }
    assert!(scrolled.get());
}
```

- [ ] **Step 3.7: Run the test to verify it fails**

```bash
cargo test -p lemon hit_test_scroll 2>&1 | tail -5
```

Expected: compile error — `hit_test_scroll` is not defined.

- [ ] **Step 3.8: Add `hit_test_scroll` to `src/src/platform/hit_test.rs`**

Add after the existing `dispatch_click` function:

```rust
/// Walk the retained tree in post-order and return the top-most node under
/// `point` that has an `on_scroll` handler.
pub fn hit_test_scroll<'a>(
    node: &'a RetainedNode,
    layout: &LayoutMap,
    point: LogicalPoint,
) -> Option<&'a RetainedNode> {
    if matches!(node.kind, RetainedKind::Component { .. }) {
        let mut hit = None;
        for child in &node.children {
            hit = hit_test_scroll(child, layout, point).or(hit);
        }
        return hit;
    }

    let mut hit = None;
    for child in &node.children {
        hit = hit_test_scroll(child, layout, point).or(hit);
    }

    if node.handlers.on_scroll.is_some() {
        if let Some(id) = node.taffy_id {
            if let Some(rect) = layout.get(id) {
                if point_in_rect(point, rect) {
                    hit = Some(node);
                }
            }
        }
    }

    hit
}
```

- [ ] **Step 3.9: Run the test to verify it passes**

```bash
cargo test -p lemon hit_test_scroll
```

Expected: `hit_test_scroll_finds_node_with_on_scroll ... ok`.

- [ ] **Step 3.10: Handle `MouseWheel` in the platform**

In `src/src/platform/mod.rs`, add to the `use` block:

```rust
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use hit_test::{dispatch_click, hit_test_on_click, hit_test_scroll, LogicalPoint};
```

Add a new `event_pass_scroll` method to `AppState`:

```rust
fn event_pass_scroll(&mut self, point: LogicalPoint, delta_y: f64) -> bool {
    let Some(root) = self.retained.as_ref().and_then(|t| t.root.as_ref()) else {
        return false;
    };
    let Some(node) = hit_test_scroll(root, &self.layout_map, point) else {
        return false;
    };
    if let Some(handler) = node.handlers.on_scroll.as_ref() {
        handler(delta_y);
        self.layout_dirty = true;
        self.paint_dirty = true;
        true
    } else {
        false
    }
}
```

Then in the `ApplicationHandler` impl, inside `window_event`, add handling for `MouseWheel` alongside the existing `CursorMoved` and `MouseInput` match arms:

```rust
WindowEvent::MouseWheel { delta, .. } => {
    let (px, py) = self.last_cursor.unwrap_or((0.0, 0.0));
    let logical = self.cursor_logical(f64::from(px), f64::from(py));
    let delta_y = match delta {
        MouseScrollDelta::LineDelta(_, y) => f64::from(y) * 20.0,
        MouseScrollDelta::PixelDelta(pos) => pos.y,
    };
    // Positive delta_y = scroll down (content moves up = offset increases).
    self.event_pass_scroll(logical, -delta_y);
}
```

> **Note:** winit's `LineDelta.y` is positive when scrolling up (away from user), so we negate the result so that "scroll down physically" → positive delta passed to handlers → offset increases → content moves up.

- [ ] **Step 3.11: Run all tests**

```bash
cargo test --workspace
```

Expected: all tests pass.

- [ ] **Step 3.12: Commit**

```bash
git add src/src/retained/mod.rs \
        src/src/element/builders.rs \
        src/src/platform/hit_test.rs \
        src/src/platform/mod.rs
git commit -m "feat: add on_scroll handler, margin_top builder, MouseWheel dispatch"
```

---

## Task 4: Click-to-focus wiring

**Files:**
- Modify: `src/src/platform/hit_test.rs`
- Modify: `src/src/platform/mod.rs`

The V0.2 `FocusManager` was wired for Tab cycling. This task wires up the missing piece: when a focusable node is clicked, it becomes the focused node and future keyboard events route to it.

- [ ] **Step 4.1: Write the failing test**

In `src/src/platform/hit_test.rs`, add to the `#[cfg(test)]` block:

```rust
#[test]
fn hit_test_focusable_finds_node_with_focusable_flag() {
    let mut tree = RetainedTree::mount(
        Column::new()
            .width(200.0)
            .height(200.0)
            .child(Box_::new().width(80.0).height(40.0).focusable())
            .child(Box_::new().width(80.0).height(40.0))
            .into_element(),
    )
    .unwrap();
    let layout = layout_pass(
        &mut tree,
        Viewport { width: 400.0, height: 400.0 },
        1.0,
    )
    .unwrap();

    let root = tree.root.as_ref().unwrap();
    let focusable_child = &root.children[0];
    let rect = layout.get(focusable_child.taffy_id.unwrap()).unwrap();

    let hit = hit_test_focusable(root, &layout, LogicalPoint::new(rect.x + 4.0, rect.y + 4.0));
    assert!(hit.is_some());
    assert_eq!(hit.unwrap().taffy_id, focusable_child.taffy_id);

    // Click outside focusable node should not find it.
    let miss = hit_test_focusable(root, &layout, LogicalPoint::new(rect.x + 4.0, rect.y + 60.0));
    assert!(miss.is_none());
}
```

- [ ] **Step 4.2: Run the test to verify it fails**

```bash
cargo test -p lemon hit_test_focusable 2>&1 | tail -5
```

Expected: compile error — `hit_test_focusable` is not defined.

- [ ] **Step 4.3: Add `hit_test_focusable` to `src/src/platform/hit_test.rs`**

```rust
/// Walk the retained tree in post-order and return the top-most focusable node under `point`.
pub fn hit_test_focusable<'a>(
    node: &'a RetainedNode,
    layout: &LayoutMap,
    point: LogicalPoint,
) -> Option<&'a RetainedNode> {
    if matches!(node.kind, RetainedKind::Component { .. }) {
        let mut hit = None;
        for child in &node.children {
            hit = hit_test_focusable(child, layout, point).or(hit);
        }
        return hit;
    }

    let mut hit = None;
    for child in &node.children {
        hit = hit_test_focusable(child, layout, point).or(hit);
    }

    if node.style.focusable {
        if let Some(id) = node.taffy_id {
            if let Some(rect) = layout.get(id) {
                if point_in_rect(point, rect) {
                    hit = Some(node);
                }
            }
        }
    }

    hit
}
```

- [ ] **Step 4.4: Run the test to verify it passes**

```bash
cargo test -p lemon hit_test_focusable
```

Expected: `hit_test_focusable_finds_node_with_focusable_flag ... ok`.

- [ ] **Step 4.5: Wire click-to-focus in `event_pass_click`**

In `src/src/platform/mod.rs`, update the `use` for hit_test to include `hit_test_focusable`:

```rust
use hit_test::{dispatch_click, hit_test_focusable, hit_test_on_click, hit_test_scroll, LogicalPoint};
```

Then update `event_pass_click` to also update `FocusManager` when a focusable node is under the click point:

```rust
fn event_pass_click(&mut self, point: LogicalPoint) -> bool {
    let Some(tree) = self.retained.as_ref() else { return false; };
    let Some(root) = tree.root.as_ref() else { return false; };

    // Update focus before dispatching click so key handlers get it immediately.
    if let Some(focused_node) = hit_test_focusable(root, &self.layout_map, point) {
        if let Some(id) = focused_node.taffy_id {
            self.focus_manager.focused = Some(id);
        }
    }

    let Some(root) = self.retained.as_ref().and_then(|t| t.root.as_ref()) else {
        return false;
    };
    let Some(node) = hit_test_on_click(root, &self.layout_map, point) else {
        return false;
    };
    let handled = dispatch_click(node);
    if handled {
        self.layout_dirty = true;
        self.paint_dirty = true;
    }
    handled
}
```

> **Note:** We borrow `retained` twice. To avoid the borrow checker issue, extract focusable hit-test result before the second borrow, or restructure using an early clone of the taffy_id. Simplest fix:

```rust
fn event_pass_click(&mut self, point: LogicalPoint) -> bool {
    // Phase 1: find focusable node under click, update focus.
    let focusable_id = self.retained
        .as_ref()
        .and_then(|t| t.root.as_ref())
        .and_then(|root| hit_test_focusable(root, &self.layout_map, point))
        .and_then(|node| node.taffy_id);

    if let Some(id) = focusable_id {
        self.focus_manager.focused = Some(id);
        self.paint_dirty = true;
    }

    // Phase 2: dispatch on_click.
    let Some(root) = self.retained.as_ref().and_then(|t| t.root.as_ref()) else {
        return false;
    };
    let Some(node) = hit_test_on_click(root, &self.layout_map, point) else {
        return false;
    };
    let handled = dispatch_click(node);
    if handled {
        self.layout_dirty = true;
        self.paint_dirty = true;
    }
    handled
}
```

- [ ] **Step 4.6: Run all tests**

```bash
cargo test --workspace
```

Expected: all tests pass.

- [ ] **Step 4.7: Commit**

```bash
git add src/src/platform/hit_test.rs \
        src/src/platform/mod.rs
git commit -m "feat: wire click-to-focus so clicking a focusable node updates FocusManager"
```

---

## Task 5: `Scroll` widget in `lemon::widget`

**Files:**
- Create: `src/widget/src/scroll.rs`
- Modify: `src/widget/src/lib.rs`

`Scroll` is a **pure builder** (not a Component). The user owns the `Signal<f64>` offset and passes it in. This is intentional: ownership stays in the parent component, enabling the parent to read or clamp the offset as needed.

- [ ] **Step 5.1: Write the failing test**

In `src/widget/src/lib.rs`, add at the bottom:

```rust
pub mod scroll;
pub use scroll::Scroll;

#[cfg(test)]
mod scroll_tests {
    use super::*;
    use lemon::{Cx, Overflow};

    #[test]
    fn scroll_widget_produces_overflow_hidden_viewport() {
        fn root(cx: &Cx) -> lemon::element::Element {
            let offset = cx.use_signal(0.0f64);
            Scroll::new(lemon::element::Element::None, offset)
                .height(200.0)
                .into_element()
        }
        // Compile-only: if it builds, the builder API is correct.
        let _ = root;
    }
}
```

- [ ] **Step 5.2: Run the test to verify it fails**

```bash
cargo test -p lemon::widget scroll 2>&1 | tail -5
```

Expected: compile error — `scroll` module not found.

- [ ] **Step 5.3: Write `src/widget/src/scroll.rs`**

```rust
use std::rc::Rc;
use lemon::{
    element::Element,
    element::builders::{Box_, Column},
    element::style::Overflow,
    runtime::signal::Signal,
};

pub struct Scroll {
    child: Element,
    offset: Signal<f64>,
    height: f32,
    width: Option<f32>,
}

impl Scroll {
    pub fn new(child: impl Into<Element>, offset: Signal<f64>) -> Self {
        Scroll {
            child: child.into(),
            offset,
            height: 200.0,
            width: None,
        }
    }

    pub fn height(mut self, h: f32) -> Self {
        self.height = h;
        self
    }

    pub fn width(mut self, w: f32) -> Self {
        self.width = Some(w);
        self
    }

    pub fn into_element(self) -> Element {
        let offset_val = self.offset.get() as f32;
        let off = self.offset.clone();

        let mut viewport = Box_::new()
            .height(self.height)
            .overflow(Overflow::Hidden)
            .on_scroll(move |delta| {
                let new_val = (off.get() + delta).max(0.0);
                off.set(new_val);
            });

        if let Some(w) = self.width {
            viewport = viewport.width(w);
        }

        viewport
            .child(
                Box_::new()
                    .margin_top(-offset_val)
                    .child(self.child)
            )
            .into_element()
    }
}

impl From<Scroll> for Element {
    fn from(s: Scroll) -> Self {
        s.into_element()
    }
}
```

> **Import note:** `Signal<T>` is in `lemon::runtime::signal`. Verify the exact path with `cargo check -p lemon::widget` and adjust if needed. Common alternatives: `lemon::Signal` if re-exported from `lemon::lib.rs`.

- [ ] **Step 5.4: Verify imports compile**

```bash
cargo check -p lemon::widget 2>&1 | head -20
```

If `lemon::runtime::signal::Signal` is not the right path, check `src/src/lib.rs` for how `Signal` is re-exported and update the import in `scroll.rs` to match.

- [ ] **Step 5.5: Run the test to verify it passes**

```bash
cargo test -p lemon::widget scroll
```

Expected: `scroll_tests::scroll_widget_produces_overflow_hidden_viewport ... ok`.

- [ ] **Step 5.6: Run all tests**

```bash
cargo test --workspace
```

Expected: all tests pass.

- [ ] **Step 5.7: Commit**

```bash
git add src/widget/src/scroll.rs src/widget/src/lib.rs
git commit -m "feat: add Scroll widget to lemon::widget"
```

---

## Task 6: `TextFieldState` in `lemon::widget`

**Files:**
- Create: `src/widget/src/text_field_state.rs`
- Modify: `src/widget/src/lib.rs`

`TextFieldState` is a plain Rust struct (no signals, no Cx) that holds the editing state of a text field. It lives in `lemon::widget` because it depends on `lemon::KeyEvent` types.

- [ ] **Step 6.1: Write the failing tests**

Create `src/widget/src/text_field_state.rs` with the full struct and tests:

```rust
use lemon::{KeyEvent, KeyState, LemonKey, Modifiers, NamedKey};

#[derive(Clone, Debug, Default, PartialEq)]
pub struct TextFieldState {
    pub value: String,
    pub cursor: usize, // byte offset into `value`
}

impl TextFieldState {
    pub fn new(initial: impl Into<String>) -> Self {
        TextFieldState {
            value: initial.into(),
            cursor: 0,
        }
    }

    pub fn handle_key(&mut self, event: &KeyEvent) {
        if event.state == KeyState::Released {
            return;
        }
        match &event.key {
            LemonKey::Character(c) if !event.modifiers.ctrl => {
                for ch in c.chars() {
                    self.value.insert(self.cursor, ch);
                    self.cursor += ch.len_utf8();
                }
            }
            LemonKey::Named(NamedKey::Backspace) => {
                if self.cursor > 0 {
                    let prev = self.prev_char_boundary(self.cursor);
                    self.value.remove(prev);
                    self.cursor = prev;
                }
            }
            LemonKey::Named(NamedKey::Delete) => {
                if self.cursor < self.value.len() {
                    self.value.remove(self.cursor);
                }
            }
            LemonKey::Named(NamedKey::ArrowLeft) => {
                if self.cursor > 0 {
                    self.cursor = self.prev_char_boundary(self.cursor);
                }
            }
            LemonKey::Named(NamedKey::ArrowRight) => {
                if self.cursor < self.value.len() {
                    self.cursor = self.next_char_boundary(self.cursor);
                }
            }
            LemonKey::Named(NamedKey::Home) => {
                self.cursor = 0;
            }
            LemonKey::Named(NamedKey::End) => {
                self.cursor = self.value.len();
            }
            _ => {}
        }
    }

    fn prev_char_boundary(&self, pos: usize) -> usize {
        let mut p = pos.saturating_sub(1);
        while p > 0 && !self.value.is_char_boundary(p) {
            p -= 1;
        }
        p
    }

    fn next_char_boundary(&self, pos: usize) -> usize {
        let mut p = pos + 1;
        while p < self.value.len() && !self.value.is_char_boundary(p) {
            p += 1;
        }
        p.min(self.value.len())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn key(key: LemonKey) -> KeyEvent {
        KeyEvent {
            key,
            modifiers: Modifiers::default(),
            repeat: false,
            state: KeyState::Pressed,
        }
    }

    #[test]
    fn typing_ascii_appends_and_advances_cursor() {
        let mut s = TextFieldState::new("");
        s.handle_key(&key(LemonKey::Character("h".into())));
        s.handle_key(&key(LemonKey::Character("i".into())));
        assert_eq!(s.value, "hi");
        assert_eq!(s.cursor, 2);
    }

    #[test]
    fn backspace_removes_char_before_cursor() {
        let mut s = TextFieldState::new("hello");
        s.cursor = 5;
        s.handle_key(&key(LemonKey::Named(NamedKey::Backspace)));
        assert_eq!(s.value, "hell");
        assert_eq!(s.cursor, 4);
    }

    #[test]
    fn backspace_at_start_is_noop() {
        let mut s = TextFieldState::new("hi");
        s.cursor = 0;
        s.handle_key(&key(LemonKey::Named(NamedKey::Backspace)));
        assert_eq!(s.value, "hi");
        assert_eq!(s.cursor, 0);
    }

    #[test]
    fn delete_removes_char_at_cursor() {
        let mut s = TextFieldState::new("hello");
        s.cursor = 1;
        s.handle_key(&key(LemonKey::Named(NamedKey::Delete)));
        assert_eq!(s.value, "hllo");
        assert_eq!(s.cursor, 1);
    }

    #[test]
    fn arrow_left_moves_cursor_back() {
        let mut s = TextFieldState::new("ab");
        s.cursor = 2;
        s.handle_key(&key(LemonKey::Named(NamedKey::ArrowLeft)));
        assert_eq!(s.cursor, 1);
        s.handle_key(&key(LemonKey::Named(NamedKey::ArrowLeft)));
        assert_eq!(s.cursor, 0);
        // No underflow.
        s.handle_key(&key(LemonKey::Named(NamedKey::ArrowLeft)));
        assert_eq!(s.cursor, 0);
    }

    #[test]
    fn arrow_right_moves_cursor_forward() {
        let mut s = TextFieldState::new("ab");
        s.cursor = 0;
        s.handle_key(&key(LemonKey::Named(NamedKey::ArrowRight)));
        assert_eq!(s.cursor, 1);
        s.handle_key(&key(LemonKey::Named(NamedKey::ArrowRight)));
        assert_eq!(s.cursor, 2);
        // No overflow.
        s.handle_key(&key(LemonKey::Named(NamedKey::ArrowRight)));
        assert_eq!(s.cursor, 2);
    }

    #[test]
    fn home_end_jump_to_bounds() {
        let mut s = TextFieldState::new("hello");
        s.cursor = 2;
        s.handle_key(&key(LemonKey::Named(NamedKey::End)));
        assert_eq!(s.cursor, 5);
        s.handle_key(&key(LemonKey::Named(NamedKey::Home)));
        assert_eq!(s.cursor, 0);
    }

    #[test]
    fn key_released_events_are_ignored() {
        let mut s = TextFieldState::new("");
        s.handle_key(&KeyEvent {
            key: LemonKey::Character("x".into()),
            modifiers: Modifiers::default(),
            repeat: false,
            state: KeyState::Released,
        });
        assert_eq!(s.value, "");
    }

    #[test]
    fn typing_unicode_char_advances_cursor_by_byte_length() {
        let mut s = TextFieldState::new("");
        // '€' is 3 bytes in UTF-8.
        s.handle_key(&key(LemonKey::Character("€".into())));
        assert_eq!(s.value, "€");
        assert_eq!(s.cursor, 3);
    }
}
```

- [ ] **Step 6.2: Run the tests to verify they fail**

```bash
cargo test -p lemon::widget text_field_state 2>&1 | tail -5
```

Expected: compile error — module not found.

- [ ] **Step 6.3: Add the module to `lib.rs`**

In `src/widget/src/lib.rs`, add:

```rust
pub mod text_field_state;
pub use text_field_state::TextFieldState;
```

- [ ] **Step 6.4: Run the tests to verify they pass**

```bash
cargo test -p lemon::widget text_field_state
```

Expected: 8 tests pass.

- [ ] **Step 6.5: Run all tests**

```bash
cargo test --workspace
```

Expected: all tests pass.

- [ ] **Step 6.6: Commit**

```bash
git add src/widget/src/text_field_state.rs src/widget/src/lib.rs
git commit -m "feat: add TextFieldState with keyboard editing logic"
```

---

## Task 7: `TextInput` widget in `lemon::widget`

**Files:**
- Create: `src/widget/src/text_input.rs`
- Modify: `src/widget/src/lib.rs`

`TextInput` is a **pure builder** (not a Component). It takes:
- `state: Signal<TextFieldState>` — the editing state, owned by the parent component
- `focused: Signal<bool>` — tracks whether this field has visual focus; the parent sets it to `true` via `.on_click()` and to `false` for siblings when another field is clicked

The builder reads `state.get()` and `focused.get()` at render time, which registers reactive dependencies so the parent re-renders when either signal changes.

- [ ] **Step 7.1: Write the failing test**

In `src/widget/src/lib.rs`, add:

```rust
pub mod text_input;
pub use text_input::TextInput;

#[cfg(test)]
mod text_input_tests {
    use super::*;
    use lemon::{Cx, element::Element};

    #[test]
    fn text_input_renders_placeholder_when_value_is_empty() {
        fn root(cx: &Cx) -> Element {
            let state = cx.use_signal(TextFieldState::new(""));
            let focused = cx.use_signal(false);
            TextInput::new(state, focused)
                .placeholder("Enter text...")
                .into_element()
        }
        // Compile-only check: builder API is correct and returns Element.
        let _ = root;
    }

    #[test]
    fn text_input_with_value_compiles() {
        fn root(cx: &Cx) -> Element {
            let state = cx.use_signal(TextFieldState::new("hello"));
            let focused = cx.use_signal(false);
            TextInput::new(state, focused).into_element()
        }
        let _ = root;
    }
}
```

- [ ] **Step 7.2: Run the tests to verify they fail**

```bash
cargo test -p lemon::widget text_input 2>&1 | tail -5
```

Expected: compile error — `text_input` module not found.

- [ ] **Step 7.3: Write `src/widget/src/text_input.rs`**

```rust
use std::rc::Rc;
use lemon::{
    Color,
    element::Element,
    element::builders::{Box_, Text},
    element::events::Cursor,
    runtime::signal::Signal,
};
use crate::text_field_state::TextFieldState;

pub struct TextInput {
    state: Signal<TextFieldState>,
    focused: Signal<bool>,
    placeholder: String,
    width: Option<f32>,
}

impl TextInput {
    pub fn new(state: Signal<TextFieldState>, focused: Signal<bool>) -> Self {
        TextInput {
            state,
            focused,
            placeholder: String::new(),
            width: None,
        }
    }

    pub fn placeholder(mut self, text: &str) -> Self {
        self.placeholder = text.to_string();
        self
    }

    pub fn width(mut self, w: f32) -> Self {
        self.width = Some(w);
        self
    }

    pub fn into_element(self) -> Element {
        let state_val = self.state.get();
        let is_focused = self.focused.get();

        let border_color = if is_focused {
            Color::rgb8(80, 130, 230)
        } else {
            Color::rgb8(90, 90, 110)
        };

        let state = self.state.clone();
        let focused = self.focused.clone();

        let text_child: Element = if state_val.value.is_empty() {
            Text::new(self.placeholder)
                .color(Color::rgb8(90, 90, 110))
                .into_element()
        } else {
            Text::new(state_val.value).into_element()
        };

        let mut outer = Box_::new()
            .focusable()
            .padding(8.0)
            .border(border_color, 1.5)
            .radius(4.0)
            .cursor(Cursor::Text)
            .on_click(move || {
                focused.set(true);
            })
            .on_key_down(move |event| {
                let mut s = state.get();
                s.handle_key(&event);
                state.set(s);
            })
            .child(text_child);

        if let Some(w) = self.width {
            outer = outer.width(w);
        }

        outer.into_element()
    }
}

impl From<TextInput> for Element {
    fn from(t: TextInput) -> Self {
        t.into_element()
    }
}
```

> **Signal path note:** same as Task 5 — verify `lemon::runtime::signal::Signal` vs. `lemon::Signal`. Adjust the import to match what `src/src/lib.rs` re-exports.

> **`Box_::border` note:** The `border(color, width)` method takes a `Color` directly, not a `ColorSource`. This matches the existing `Button` builder in `src/src/element/builders.rs`. If `Box_` (added in V0.2) takes `impl Into<ColorSource>`, pass `border_color` directly — `Color` implements `Into<ColorSource>`.

- [ ] **Step 7.4: Verify imports compile**

```bash
cargo check -p lemon::widget 2>&1 | head -20
```

Fix any import path issues shown.

- [ ] **Step 7.5: Run the tests to verify they pass**

```bash
cargo test -p lemon::widget text_input
```

Expected: both tests pass.

- [ ] **Step 7.6: Run all tests**

```bash
cargo test --workspace
```

Expected: all tests pass.

- [ ] **Step 7.7: Commit**

```bash
git add src/widget/src/text_input.rs src/widget/src/lib.rs
git commit -m "feat: add TextInput widget with keyboard editing and focus visual"
```

---

## Task 8: Example `form`

**Files:**
- Create: `examples/form/Cargo.toml`
- Create: `examples/form/src/main.rs`
- Modify: `Cargo.toml` (root)

Demonstrates V0.3 complete: two `TextInput` fields (name, email), a scrollable list of submitted entries, and a Submit button.

- [ ] **Step 8.1: Add `examples/form` to the workspace**

In `Cargo.toml` (root), add `"examples/form"` to the `members` array alongside the other examples.

- [ ] **Step 8.2: Write `examples/form/Cargo.toml`**

```toml
[package]
name = "form"
version.workspace = true
edition.workspace = true
publish = false

[dependencies]
lemon = { workspace = true }
lemon::widget = { workspace = true }
```

- [ ] **Step 8.3: Write `examples/form/src/main.rs`**

```rust
use lemon::{run, Color, Cx, WindowConfig};
use lemon::widget::{Button, Column, Row, Scroll, Text, TextFieldState, TextInput};

#[derive(Clone)]
struct Entry {
    name: String,
    email: String,
}

fn app(cx: &Cx) -> lemon::element::Element {
    let name_state = cx.use_signal(TextFieldState::new(""));
    let email_state = cx.use_signal(TextFieldState::new(""));
    let name_focused = cx.use_signal(false);
    let email_focused = cx.use_signal(false);
    let entries = cx.use_signal(Vec::<Entry>::new());
    let scroll_offset = cx.use_signal(0.0f64);

    let name_s = name_state.clone();
    let email_s = email_state.clone();
    let name_f = name_focused.clone();
    let email_f = email_focused.clone();
    let entries_w = entries.clone();

    let name_for_input = name_state.clone();
    let email_for_input = email_state.clone();
    let name_focused_for_email_click = name_focused.clone();
    let email_focused_for_name_click = email_focused.clone();

    Column::new()
        .padding(32.0)
        .gap(16.0)
        .child(
            lemon::element::builders::Text::new("Contact Form").font_size(22.0).into_element()
        )
        // Name field
        .child(
            Column::new()
                .gap(4.0)
                .child(Text::new("Name").font_size(13.0).color(Color::rgb8(160, 160, 180)))
                .child(
                    TextInput::new(name_for_input, name_focused.clone())
                        .placeholder("Your name")
                        .width(320.0)
                        .on_click_pre(move || { email_focused_for_name_click.set(false); })
                )
        )
        // Email field
        .child(
            Column::new()
                .gap(4.0)
                .child(Text::new("Email").font_size(13.0).color(Color::rgb8(160, 160, 180)))
                .child(
                    TextInput::new(email_for_input, email_focused.clone())
                        .placeholder("your@email.com")
                        .width(320.0)
                        .on_click_pre(move || { name_focused_for_email_click.set(false); })
                )
        )
        // Submit button
        .child(
            Button::new("Submit").on_click(move || {
                let name = name_s.get().value.clone();
                let email = email_s.get().value.clone();
                if !name.is_empty() || !email.is_empty() {
                    entries_w.update(|list| list.push(Entry { name, email }));
                    name_s.set(TextFieldState::new(""));
                    email_s.set(TextFieldState::new(""));
                    name_f.set(false);
                    email_f.set(false);
                }
            })
        )
        // Submitted entries list
        .child(
            Scroll::new(
                {
                    let entries_r = entries.clone();
                    Column::new()
                        .gap(8.0)
                        .child(
                            lemon::element::builders::Text::new("Submitted entries")
                                .font_size(14.0)
                                .color(Color::rgb8(140, 140, 160))
                                .into_element()
                        )
                        .child({
                            let list = entries_r.get();
                            if list.is_empty() {
                                lemon::element::builders::Text::new("No entries yet.")
                                    .font_size(13.0)
                                    .color(Color::rgb8(100, 100, 120))
                                    .into_element()
                            } else {
                                Column::new()
                                    .gap(4.0)
                                    .children(
                                        list.iter().enumerate().map(|(i, e)| {
                                            lemon::element::builders::Text::new(
                                                format!("{}. {} — {}", i + 1, e.name, e.email)
                                            )
                                            .font_size(13.0)
                                            .into_element()
                                        })
                                    )
                                    .into_element()
                            }
                        })
                        .into_element()
                },
                scroll_offset,
            )
            .height(200.0)
            .width(320.0)
        )
        .into_element()
}

fn main() {
    run(
        WindowConfig::default().title("Lemon Form").size(500.0, 600.0),
        app,
    );
}
```

> **Note:** The example above uses `.on_click_pre()` on `TextInput` and `.children()` on `Column` — these don't exist yet. Simplify the example to avoid them:

**Simplified version that uses only existing APIs:**

```rust
use lemon::{run, Color, Cx, WindowConfig};
use lemon::widget::{Button, Column, Row, Scroll, Text, TextFieldState, TextInput};

#[derive(Clone)]
struct Entry {
    name: String,
    email: String,
}

fn app(cx: &Cx) -> lemon::element::Element {
    let name_state = cx.use_signal(TextFieldState::new(""));
    let email_state = cx.use_signal(TextFieldState::new(""));
    // Simple focus: only one field can be visually focused at a time.
    // Clicking a field sets its focused signal. We don't clear the other here
    // for simplicity; keyboard routing still works via FocusManager.
    let name_focused = cx.use_signal(false);
    let email_focused = cx.use_signal(false);
    let entries = cx.use_signal(Vec::<Entry>::new());
    let scroll_offset = cx.use_signal(0.0f64);

    let name_s = name_state.clone();
    let email_s = email_state.clone();
    let entries_w = entries.clone();

    let entries_r = entries.clone();
    let list = entries_r.get();

    let list_content: lemon::element::Element = if list.is_empty() {
        Text::new("No entries yet.").font_size(13.0).into_element()
    } else {
        list.iter().fold(Column::new().gap(4.0), |col, e| {
            col.child(
                Text::new(format!("{} — {}", e.name, e.email)).font_size(13.0)
            )
        }).into_element()
    };

    Column::new()
        .padding(32.0)
        .gap(16.0)
        .child(Text::new("Contact Form").font_size(22.0))
        .child(
            Column::new()
                .gap(4.0)
                .child(Text::new("Name").font_size(13.0).color(Color::rgb8(160, 160, 180)))
                .child(
                    TextInput::new(name_state, name_focused)
                        .placeholder("Your name")
                        .width(320.0)
                )
        )
        .child(
            Column::new()
                .gap(4.0)
                .child(Text::new("Email").font_size(13.0).color(Color::rgb8(160, 160, 180)))
                .child(
                    TextInput::new(email_state, email_focused)
                        .placeholder("your@email.com")
                        .width(320.0)
                )
        )
        .child(
            Button::new("Submit").on_click(move || {
                let name = name_s.get().value.clone();
                let email = email_s.get().value.clone();
                if !name.is_empty() || !email.is_empty() {
                    entries_w.update(|list| list.push(Entry { name, email }));
                    name_s.set(TextFieldState::new(""));
                    email_s.set(TextFieldState::new(""));
                }
            })
        )
        .child(Text::new("Submitted:").font_size(14.0).color(Color::rgb8(140, 140, 160)))
        .child(
            Scroll::new(list_content, scroll_offset)
                .height(200.0)
                .width(320.0)
        )
        .into_element()
}

fn main() {
    run(
        WindowConfig::default().title("Lemon Form").size(500.0, 600.0),
        app,
    );
}
```

- [ ] **Step 8.4: Verify the example builds**

```bash
cargo build -p form
```

Fix any compile errors. Common issues:
- `Signal::update` closure signature: `update(|v: &mut T| ...)` — adjust if your runtime uses a different signature
- `Text::new` takes `impl Into<TextContent>`; `String` and `&str` both work, but a `format!()` result needs `.as_str()` or just pass the `String` directly

- [ ] **Step 8.5: Verify clippy is clean**

```bash
cargo clippy --workspace --all-targets -- -D warnings
```

Fix any warnings before committing.

- [ ] **Step 8.6: Commit**

```bash
git add examples/form/ Cargo.toml
git commit -m "example: add form demo with TextInput, Scroll, and Submit"
```

---

## Task 9: Final checks

- [ ] **Step 9.1: Run the full test suite**

```bash
cargo test --workspace
```

Expected: all tests pass, including the new tests from Tasks 1–7.

- [ ] **Step 9.2: Run clippy with warnings as errors**

```bash
cargo clippy --workspace --all-targets -- -D warnings
```

Expected: zero warnings.

- [ ] **Step 9.3: Run fmt check**

```bash
cargo fmt --all -- --check
```

If there are diffs, run `cargo fmt --all` and re-check.

- [ ] **Step 9.4: Smoke-test the form example visually**

```bash
cargo run -p form
```

Verify:
- [ ] Clicking the Name field: blue border appears, typing updates the text
- [ ] Clicking the Email field: gets keyboard focus, typing works
- [ ] Backspace removes the last character
- [ ] Submit with both fields non-empty: entry appears in the scroll list, fields clear
- [ ] MouseWheel over the scroll list: content scrolls up/down

- [ ] **Step 9.5: Final commit if any polish changes were needed**

```bash
git add -p   # review each hunk
git commit -m "polish: V0.3 final cleanup"
```

---

## Criteria of done

- [ ] `cargo test --workspace` — no failures
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` — no warnings
- [ ] `overflow: Overflow::Hidden` clips children in paint pass (verified by test)
- [ ] `MouseWheel` event dispatches `on_scroll` to the hit-tested node
- [ ] `Scroll` widget scrolls content on wheel input; offset clamped at 0 minimum
- [ ] `TextInput` accepts typed characters, Backspace, Delete, arrow keys, Home, End
- [ ] Clicking a `TextInput` sets it as the FocusManager-focused node
- [ ] Focus visual: blue border when `focused` signal is true, gray otherwise
- [ ] `cargo run -p form` — form renders, editing and Submit work end-to-end
