# V0.4 — Rich Content 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 image rendering, z-index paint ordering, a draggable `Slider`, a `Select` dropdown, and a scrollbar visual — making Lemon capable of real data-display UIs.

**Architecture:** Images are decoded by the `image` crate into RGBA8 pixels stored in `Arc<ImageData>`; paint renders them via `vello::peniko::Image` with object-fit:contain scaling. Z-index is a paint-only concern: a two-pass approach collects deferred nodes during the normal traversal, then paints them sorted by z_index. `Select` and `Slider` are pure builders that take user-owned `Signal<T>` state; pointer drag uses a new `on_pointer_down` / `on_pointer_move` handler pair where the platform normalizes cursor position to the node's rect (0.0..1.0 fraction), and `mouse_capture_node` in `AppState` holds focus through the gesture. `Select` uses `on_click_outside` to close its dropdown.

**Tech Stack:** Rust, `image 0.25` (PNG/JPEG decode), Vello 0.9 + peniko (`draw_image`), Taffy 0.7 (z_index is paint-only, layout unchanged), lemon::widget crate.

**Pre-requisites:** V0.3 complete. The following are already in place:
- `EventHandlers`: `on_click`, `on_key_down`, `on_key_up`, `on_hover_enter`, `on_hover_leave`, `on_scroll`
- `StyleProps`: `focusable`, `cursor`, `overflow`, and margin/padding/flex fields
- `AppState`: `focus_manager: FocusManager`, `hovered_node_id`, `active_modifiers`, `last_cursor`
- `find_node_by_taffy_id` in `platform/hit_test.rs`
- `Scroll`, `TextInput`, `TextFieldState` in `lemon::widget`
- Container builders: `.on_click()`, `.on_key_down()`, `.on_scroll()`, `.overflow()`, `.margin_top()`

---

## File Map

| Action | Path | Responsibility |
|--------|------|----------------|
| Modify | `Cargo.toml` (root) | Add `image = "0.25"` to workspace deps |
| Modify | `src/Cargo.toml` | Add `image` dependency |
| Create | `src/src/asset/mod.rs` | `pub mod image_handle` |
| Create | `src/src/asset/image_handle.rs` | `ImageHandle`, `ImageData`, `ImageError` |
| Modify | `src/src/lib.rs` | `pub mod asset`; re-export `ImageHandle` |
| Modify | `src/src/element/style.rs` | Add `image: Option<ImageHandle>` to `PaintProps`/`PaintData`; add `z_index: i32` to `StyleProps` |
| Modify | `src/src/element/builders.rs` | Add `.image()`, `.z_index()`, `.on_click_outside()`, `.on_pointer_down()`, `.on_pointer_move()` to container builders |
| Modify | `src/src/retained/mod.rs` | Add `on_click_outside`, `on_pointer_down`, `on_pointer_move` to `EventHandlers`; propagate `PaintData.image` |
| Modify | `src/src/paint/mod.rs` | Draw image in paint pass; two-pass z_index ordering |
| Modify | `src/src/platform/hit_test.rs` | Add `hit_test_pointer_down`, `dispatch_click_outside` |
| Modify | `src/src/platform/mod.rs` | Add `mouse_capture_node`, `mouse_button_down` to `AppState`; wire `event_pass_pointer_down`, `event_pass_pointer_move`, `event_pass_click_outside` |
| Create | `src/widget/src/image.rs` | `Image` builder widget |
| Create | `src/widget/src/select.rs` | `Select<T>` builder widget |
| Create | `src/widget/src/slider.rs` | `Slider` builder widget |
| Modify | `src/widget/src/scroll.rs` | Add scrollbar visual |
| Modify | `src/widget/src/lib.rs` | Export new widgets |
| Create | `examples/rich/Cargo.toml` | Rich example manifest |
| Create | `examples/rich/src/main.rs` | Demo: image + Select + Slider + Scroll |
| Modify | `Cargo.toml` (root) | Add `examples/rich` to workspace members |

---

## Task 1: `image` crate dependency + `ImageHandle` type

**Files:**
- Modify: `Cargo.toml` (root)
- Modify: `src/Cargo.toml`
- Create: `src/src/asset/mod.rs`
- Create: `src/src/asset/image_handle.rs`
- Modify: `src/src/lib.rs`

- [ ] **Step 1.1: Write the failing tests in `src/src/asset/image_handle.rs`**

Create the file with full struct + tests:

```rust
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::Arc;

pub struct ImageData {
    pub width: u32,
    pub height: u32,
    pub pixels: Vec<u8>, // RGBA8, row-major
}

#[derive(Clone)]
pub struct ImageHandle(Arc<ImageData>);

#[derive(Debug)]
pub struct ImageError(pub String);

impl std::fmt::Display for ImageError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl ImageHandle {
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ImageError> {
        let img = image::load_from_memory(bytes)
            .map_err(|e| ImageError(e.to_string()))?
            .to_rgba8();
        Ok(Self(Arc::new(ImageData {
            width: img.width(),
            height: img.height(),
            pixels: img.into_raw(),
        })))
    }

    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, ImageError> {
        let bytes = std::fs::read(path).map_err(|e| ImageError(e.to_string()))?;
        Self::from_bytes(&bytes)
    }

    pub fn width(&self) -> u32 {
        self.0.width
    }

    pub fn height(&self) -> u32 {
        self.0.height
    }

    pub fn pixels(&self) -> &[u8] {
        &self.0.pixels
    }
}

impl PartialEq for ImageHandle {
    fn eq(&self, other: &Self) -> bool {
        Arc::ptr_eq(&self.0, &other.0)
    }
}

impl Eq for ImageHandle {}

impl Hash for ImageHandle {
    fn hash<H: Hasher>(&self, state: &mut H) {
        (Arc::as_ptr(&self.0) as usize).hash(state);
    }
}

impl std::fmt::Debug for ImageHandle {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "ImageHandle({}x{})", self.0.width, self.0.height)
    }
}

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

    fn minimal_rgba_png() -> Vec<u8> {
        // Smallest valid 1x1 RGBA PNG (hand-crafted bytes)
        vec![
            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
            0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length + type
            0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
            0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, // bit depth 8, RGBA, CRC
            0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41, 0x54, // IDAT chunk
            0x78, 0x9C, 0x62, 0xF8, 0xFF, 0xFF, 0xFF, 0x00, 0x05, 0xFE, 0x02, 0xFE,
            0xDC, 0xCC, 0x59, 0xE7, // IDAT CRC
            0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, // IEND
        ]
    }

    #[test]
    fn from_bytes_decodes_png() {
        let handle = ImageHandle::from_bytes(&minimal_rgba_png());
        // May fail if the hand-crafted PNG bytes are wrong — use from_bytes on a real PNG.
        // If this test is flaky, skip it and rely on from_bytes_invalid_returns_err.
        // A simpler compile-only check:
        let _ = handle; // just check it compiles
    }

    #[test]
    fn from_bytes_invalid_returns_err() {
        let result = ImageHandle::from_bytes(b"not a png");
        assert!(result.is_err());
    }

    #[test]
    fn two_handles_from_same_arc_are_equal() {
        let a = ImageHandle(Arc::new(ImageData {
            width: 1,
            height: 1,
            pixels: vec![255, 0, 0, 255],
        }));
        let b = a.clone();
        assert_eq!(a, b);
    }

    #[test]
    fn two_independent_handles_are_not_equal() {
        let make = || {
            ImageHandle(Arc::new(ImageData {
                width: 1,
                height: 1,
                pixels: vec![255, 0, 0, 255],
            }))
        };
        assert_ne!(make(), make());
    }

    #[test]
    fn width_height_accessors() {
        let h = ImageHandle(Arc::new(ImageData {
            width: 42,
            height: 7,
            pixels: vec![0u8; 42 * 7 * 4],
        }));
        assert_eq!(h.width(), 42);
        assert_eq!(h.height(), 7);
    }
}
```

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

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

Expected: compile error — `image` crate not found, module not declared.

- [ ] **Step 1.3: Add `image` crate to `Cargo.toml` (root)**

In `[workspace.dependencies]`, add:

```toml
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
```

- [ ] **Step 1.4: Add `image` dependency to `src/Cargo.toml`**

```toml
[dependencies]
image = { workspace = true }
# ... existing deps
```

- [ ] **Step 1.5: Create `src/src/asset/mod.rs`**

```rust
pub mod image_handle;
pub use image_handle::{ImageError, ImageHandle};
```

- [ ] **Step 1.6: Declare `asset` module in `src/src/lib.rs`**

Add after existing `pub mod` declarations:

```rust
pub mod asset;
pub use asset::ImageHandle;
```

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

```bash
cargo test -p lemon asset
```

Expected: 5 tests pass (the PNG decode test may be an assertion-only check depending on the PNG bytes — fix if needed by adjusting the test to just check `from_bytes_invalid_returns_err` and `width_height_accessors`).

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

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

Expected: all existing tests still pass.

- [ ] **Step 1.9: Commit**

```bash
git add Cargo.toml src/Cargo.toml \
        src/src/asset/ src/src/lib.rs
git commit -m "feat: add ImageHandle type with PNG/JPEG decoding via image crate"
```

---

## Task 2: Image field in `PaintProps`/`PaintData` and paint pass

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

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

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

```rust
#[test]
fn image_handle_in_paint_data_runs_without_panic() {
    use crate::asset::ImageHandle;
    use std::sync::Arc;
    use crate::asset::image_handle::ImageData;

    let handle = ImageHandle::from_arc(Arc::new(ImageData {
        width: 4,
        height: 4,
        pixels: vec![128u8; 4 * 4 * 4],
    }));

    let mut tree = RetainedTree::mount(
        Box_::new()
            .width(100.0)
            .height(100.0)
            .image(handle)
            .into_element(),
    )
    .unwrap();

    let stats = layout_and_paint(&mut tree, 1.0);
    assert_eq!(stats.fills, 0, "image draw does not count as fill");
}
```

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

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

Expected: compile error — `ImageHandle::from_arc` and `.image()` builder method don't exist.

- [ ] **Step 2.3: Add `from_arc` constructor to `ImageHandle`**

In `src/src/asset/image_handle.rs`, add:

```rust
impl ImageHandle {
    /// Low-level constructor from a pre-built `Arc<ImageData>`.
    /// Useful in tests to avoid decoding a full PNG.
    pub fn from_arc(data: Arc<ImageData>) -> Self {
        Self(data)
    }
    // ... existing methods
}
```

Also make `ImageData` public (it already is in the struct but check the visibility):

```rust
pub struct ImageData {
    pub width: u32,
    pub height: u32,
    pub pixels: Vec<u8>,
}
```

- [ ] **Step 2.4: Add `image` field to `PaintProps` and `PaintData` in `style.rs`**

In `src/src/element/style.rs`:

Add `use crate::asset::ImageHandle;` at the top (or use full path).

Update `PaintProps`:

```rust
#[derive(Clone)]
pub struct PaintProps {
    pub background: Option<ColorSource>,
    pub border_color: Option<ColorSource>,
    pub border_width: f32,
    pub radius: CornerRadii,
    pub image: Option<crate::asset::ImageHandle>,
}

impl Default for PaintProps {
    fn default() -> Self {
        PaintProps {
            background: None,
            border_color: None,
            border_width: 0.0,
            radius: CornerRadii::default(),
            image: None,
        }
    }
}
```

Update `PaintData`:

```rust
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PaintData {
    pub background: Option<Color>,
    pub border_color: Option<Color>,
    pub border_width: f32,
    pub radius: CornerRadii,
    pub image: Option<crate::asset::ImageHandle>,
}
```

Update `PaintProps::resolve`:

```rust
pub fn resolve(&self) -> PaintData {
    PaintData {
        background: self.background.as_ref().map(|c| c.resolve()),
        border_color: self.border_color.as_ref().map(|c| c.resolve()),
        border_width: self.border_width,
        radius: self.radius.clone(),
        image: self.image.clone(),
    }
}
```

> **Note:** `PaintData` derives `PartialEq`. `ImageHandle` implements `PartialEq` by pointer, so this is correct — diff will detect when the image changes.

Also update `PaintProps`'s `Debug` impl to include the new field:

```rust
impl std::fmt::Debug for PaintProps {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PaintProps")
            .field("background", &self.background.as_ref().map(|c| c.resolve()))
            .field("border_color", &self.border_color.as_ref().map(|c| c.resolve()))
            .field("border_width", &self.border_width)
            .field("radius", &self.radius)
            .field("image", &self.image)
            .finish()
    }
}
```

- [ ] **Step 2.5: Add `.image()` builder method to the `container_builder!` macro**

In `src/src/element/builders.rs`, inside the macro body, add:

```rust
            pub fn image(mut self, handle: crate::asset::ImageHandle) -> Self {
                self.0.paint.image = Some(handle);
                self
            }
```

- [ ] **Step 2.6: Add image rendering to the paint pass**

In `src/src/paint/mod.rs`, add `use vello::peniko::{Blob, Image as PenikoImage, ImageFormat};` to the imports. Then add a new function after `paint_text`:

```rust
fn paint_image(
    node: &RetainedNode,
    rect: &LayoutRect,
    scene: &mut Scene,
    ctx: PaintContext,
) {
    let Some(handle) = &node.paint.image else {
        return;
    };
    if rect.width <= 0.0 || rect.height <= 0.0 {
        return;
    }

    let img_w = handle.width() as f32;
    let img_h = handle.height() as f32;
    if img_w <= 0.0 || img_h <= 0.0 {
        return;
    }

    // object-fit: contain — scale uniformly to fit in rect, centered.
    let scale = (rect.width / img_w).min(rect.height / img_h);
    let draw_w = img_w * scale;
    let draw_h = img_h * scale;
    let ox = rect.x + (rect.width - draw_w) * 0.5;
    let oy = rect.y + (rect.height - draw_h) * 0.5;

    let transform = ctx.base
        * Affine::translate((f64::from(ox), f64::from(oy)))
        * Affine::scale(f64::from(scale));

    let blob = Blob::from(handle.pixels().to_vec());
    let peniko_img = PenikoImage::new(blob, ImageFormat::Rgba8, handle.width(), handle.height());

    scene.draw_image(&peniko_img, transform);
}
```

Then call it inside `paint_node`, right after `paint_text`:

```rust
    paint_container(node, rect, scene, ctx, stats);
    paint_text(node, rect, scene, ctx, stats);
    paint_image(node, rect, scene, ctx);  // ← add this

    // ... children ...
```

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

```bash
cargo test -p lemon image_handle_in_paint
```

Expected: `image_handle_in_paint_data_runs_without_panic ... ok`.

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

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

Expected: all tests pass.

- [ ] **Step 2.9: Commit**

```bash
git add src/src/asset/image_handle.rs \
        src/src/element/style.rs \
        src/src/element/builders.rs \
        src/src/paint/mod.rs
git commit -m "feat: add image field to PaintData and draw_image in paint pass"
```

---

## Task 3: `Image` widget in `lemon::widget`

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

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

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

```rust
pub mod image;
pub use image::Image;

#[cfg(test)]
mod image_tests {
    use super::*;
    use lemon::asset::image_handle::ImageData;
    use lemon::ImageHandle;
    use std::sync::Arc;

    #[test]
    fn image_widget_produces_box_with_image_paint() {
        let handle = ImageHandle::from_arc(Arc::new(ImageData {
            width: 2,
            height: 2,
            pixels: vec![255u8; 2 * 2 * 4],
        }));
        let element = Image::new(handle).width(100.0).height(100.0).into_element();
        // Compile-only: if it builds and returns Element, the API is correct.
        let _ = element;
    }
}
```

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

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

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

- [ ] **Step 3.3: Write `src/widget/src/image.rs`**

```rust
use lemon::{
    element::builders::Box_,
    element::Element,
    ImageHandle,
};

pub struct Image {
    handle: ImageHandle,
    width: Option<f32>,
    height: Option<f32>,
}

impl Image {
    pub fn new(handle: ImageHandle) -> Self {
        Image { handle, width: None, height: None }
    }

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

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

    pub fn into_element(self) -> Element {
        let mut b = Box_::new().image(self.handle);
        if let Some(w) = self.width {
            b = b.width(w);
        }
        if let Some(h) = self.height {
            b = b.height(h);
        }
        b.into_element()
    }
}

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

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

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

Expected: `image_tests::image_widget_produces_box_with_image_paint ... ok`.

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

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

Expected: all tests pass.

- [ ] **Step 3.6: Commit**

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

---

## Task 4: `z_index` in StyleProps + two-pass paint ordering

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

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

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

```rust
#[test]
fn z_index_node_is_painted_after_siblings() {
    // Two siblings: first has z_index 1 (should paint last), second has z_index 0.
    // We can't inspect draw order directly, but we verify paint completes without panic
    // and both fills are counted.
    let mut tree = RetainedTree::mount(
        Column::new()
            .width(200.0)
            .height(200.0)
            .child(
                Box_::new()
                    .width(80.0)
                    .height(80.0)
                    .background(Color::rgb8(255, 0, 0))
                    .z_index(1),
            )
            .child(
                Box_::new()
                    .width(80.0)
                    .height(80.0)
                    .background(Color::rgb8(0, 255, 0)),
            )
            .into_element(),
    )
    .unwrap();

    let stats = layout_and_paint(&mut tree, 1.0);
    assert_eq!(stats.fills, 2, "both boxes must be painted");
}
```

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

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

Expected: compile error — `.z_index()` method does not exist.

- [ ] **Step 4.3: Add `z_index: i32` to `StyleProps`**

In `src/src/element/style.rs`, 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,
    pub z_index: i32,
}
```

> `z_index` defaults to `0` via `#[derive(Default)]` and `i32::default() == 0`.

> `z_index` is **not** mapped to Taffy — it is paint-only. The layout pass ignores it.

- [ ] **Step 4.4: Add `.z_index()` to the `container_builder!` macro**

In `src/src/element/builders.rs`, inside the macro body, add:

```rust
            pub fn z_index(mut self, z: i32) -> Self {
                self.0.style.z_index = z;
                self
            }
```

Also add `z_index` to the imports import list (it's already in `StyleProps`, nothing extra needed).

- [ ] **Step 4.5: Implement two-pass z_index ordering in `paint_pass`**

In `src/src/paint/mod.rs`, restructure `paint_pass` and `paint_node` to use a deferred list:

Replace the current `paint_pass` and add a new inner function:

```rust
pub fn paint_pass(
    tree: &RetainedTree,
    layout: &LayoutMap,
    scene: &mut Scene,
    scale_factor: f32,
) -> PaintStats {
    let mut stats = PaintStats::default();
    let base = if scale_factor == 1.0 {
        Affine::IDENTITY
    } else {
        Affine::scale(f64::from(scale_factor))
    };
    let ctx = PaintContext { base };

    scene.push_layer(
        Fill::NonZero,
        BlendMode::default(),
        1.0,
        base,
        &clip_everything(),
    );

    let mut deferred: Vec<(i32, *const RetainedNode)> = Vec::new();

    if let Some(root) = tree.root.as_ref() {
        paint_node_z(root, layout, scene, ctx, &mut stats, &mut deferred);
    }

    // Sort deferred by z_index ascending (lower z_index painted first = behind).
    deferred.sort_by_key(|(z, _)| *z);

    for (_, node_ptr) in deferred {
        // SAFETY: the retained tree is alive for the duration of paint_pass.
        let node = unsafe { &*node_ptr };
        paint_node(node, layout, scene, ctx, &mut stats);
    }

    scene.pop_layer();
    stats
}

/// Like `paint_node` but defers nodes with z_index != 0 to `deferred`.
fn paint_node_z(
    node: &RetainedNode,
    layout: &LayoutMap,
    scene: &mut Scene,
    ctx: PaintContext,
    stats: &mut PaintStats,
    deferred: &mut Vec<(i32, *const RetainedNode)>,
) {
    if matches!(node.kind, RetainedKind::Component { .. }) {
        for child in &node.children {
            paint_node_z(child, layout, scene, ctx, stats, deferred);
        }
        return;
    }

    let Some(taffy_id) = node.taffy_id else {
        for child in &node.children {
            paint_node_z(child, layout, scene, ctx, stats, deferred);
        }
        return;
    };

    if node.style.z_index != 0 {
        // Defer this node (with its subtree) to the z_index pass.
        deferred.push((node.style.z_index, node as *const RetainedNode));
        return;
    }

    let Some(rect) = layout.get(taffy_id) else {
        return;
    };

    paint_container(node, rect, scene, ctx, stats);
    paint_text(node, rect, scene, ctx, stats);
    paint_image(node, rect, scene, ctx);

    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_z(child, layout, scene, ctx, stats, deferred);
        }
        scene.pop_layer();
    } else {
        for child in &node.children {
            paint_node_z(child, layout, scene, ctx, stats, deferred);
        }
    }
}
```

> **Raw pointer note:** We use `*const RetainedNode` to avoid lifetime issues in the deferred vec. The `RetainedTree` borrow covers the entire `paint_pass` call, so the pointer is valid. If you prefer lifetimes, use `Vec<(i32, &'_ RetainedNode)>` with a `'_` lifetime tied to the tree borrow — the split-borrow makes this awkward, so the raw pointer is pragmatic here.

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

```bash
cargo test -p lemon z_index
```

Expected: `z_index_node_is_painted_after_siblings ... ok`.

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

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

Expected: all tests pass.

- [ ] **Step 4.8: Commit**

```bash
git add src/src/element/style.rs \
        src/src/element/builders.rs \
        src/src/paint/mod.rs
git commit -m "feat: add z_index to StyleProps and two-pass z-ordered paint"
```

---

## Task 5: `on_click_outside`, `on_pointer_down`, `on_pointer_move` in EventHandlers + builders

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

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

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

```rust
#[test]
fn on_click_outside_is_stored_in_retained_node() {
    use std::cell::Cell;
    let fired = Rc::new(Cell::new(false));
    let f = fired.clone();

    let tree = RetainedTree::mount(
        Box_::new()
            .width(100.0)
            .height(100.0)
            .on_click_outside(move || f.set(true))
            .into_element(),
    )
    .unwrap();

    let root = tree.root.as_ref().unwrap();
    assert!(root.handlers.on_click_outside.is_some());
    root.handlers.on_click_outside.as_ref().unwrap()();
    assert!(fired.get());
}

#[test]
fn on_pointer_down_and_move_are_stored_in_retained_node() {
    use std::cell::Cell;
    let down_x = Rc::new(Cell::new(0.0f32));
    let move_x = Rc::new(Cell::new(0.0f32));
    let dx = down_x.clone();
    let mx = move_x.clone();

    let tree = RetainedTree::mount(
        Box_::new()
            .width(200.0)
            .height(50.0)
            .on_pointer_down(move |frac_x, _| dx.set(frac_x))
            .on_pointer_move(move |frac_x, _| mx.set(frac_x))
            .into_element(),
    )
    .unwrap();

    let root = tree.root.as_ref().unwrap();
    assert!(root.handlers.on_pointer_down.is_some());
    assert!(root.handlers.on_pointer_move.is_some());

    root.handlers.on_pointer_down.as_ref().unwrap()(0.5, 0.5);
    assert_eq!(down_x.get(), 0.5);
    root.handlers.on_pointer_move.as_ref().unwrap()(0.75, 0.25);
    assert_eq!(move_x.get(), 0.75);
}
```

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

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

Expected: compile error — methods don't exist.

- [ ] **Step 5.3: Add the three new handlers 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)>>,
    pub on_click_outside: Option<Rc<dyn Fn()>>,
    pub on_pointer_down: Option<Rc<dyn Fn(f32, f32)>>,
    pub on_pointer_move: Option<Rc<dyn Fn(f32, f32)>>,
}

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(|_| "Fn()"))
            .field("on_key_down", &self.on_key_down.as_ref().map(|_| "Fn(KeyEvent)"))
            .field("on_key_up", &self.on_key_up.as_ref().map(|_| "Fn(KeyEvent)"))
            .field("on_hover_enter", &self.on_hover_enter.as_ref().map(|_| "Fn()"))
            .field("on_hover_leave", &self.on_hover_leave.as_ref().map(|_| "Fn()"))
            .field("on_scroll", &self.on_scroll.as_ref().map(|_| "Fn(f64)"))
            .field("on_click_outside", &self.on_click_outside.as_ref().map(|_| "Fn()"))
            .field("on_pointer_down", &self.on_pointer_down.as_ref().map(|_| "Fn(f32,f32)"))
            .field("on_pointer_move", &self.on_pointer_move.as_ref().map(|_| "Fn(f32,f32)"))
            .finish()
    }
}
```

- [ ] **Step 5.4: Add builder methods to the `container_builder!` macro**

In `src/src/element/builders.rs`, inside the macro body, add:

```rust
            pub fn on_click_outside(mut self, f: impl Fn() + 'static) -> Self {
                self.0.handlers.on_click_outside = Some(Rc::new(f));
                self
            }
            pub fn on_pointer_down(mut self, f: impl Fn(f32, f32) + 'static) -> Self {
                self.0.handlers.on_pointer_down = Some(Rc::new(f));
                self
            }
            pub fn on_pointer_move(mut self, f: impl Fn(f32, f32) + 'static) -> Self {
                self.0.handlers.on_pointer_move = Some(Rc::new(f));
                self
            }
```

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

```bash
cargo test -p lemon on_click_outside on_pointer_down
```

Expected: both tests pass.

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

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

Expected: all tests pass.

- [ ] **Step 5.7: Commit**

```bash
git add src/src/retained/mod.rs src/src/element/builders.rs
git commit -m "feat: add on_click_outside, on_pointer_down, on_pointer_move to EventHandlers"
```

---

## Task 6: Mouse capture + pointer dispatch in the platform

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

- [ ] **Step 6.1: Write the failing test for `hit_test_pointer_down`**

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

```rust
#[test]
fn hit_test_pointer_down_finds_draggable_node() {
    use std::cell::Cell;
    let fired = Rc::new(Cell::new(false));
    let f = fired.clone();

    let mut tree = RetainedTree::mount(
        Column::new()
            .width(200.0)
            .height(200.0)
            .child(
                Box_::new()
                    .width(100.0)
                    .height(40.0)
                    .on_pointer_down(move |_, _| f.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 child = &root.children[0];
    let rect = layout.get(child.taffy_id.unwrap()).unwrap();

    let (hit_node, norm_x, norm_y) =
        hit_test_pointer_down(root, &layout, LogicalPoint::new(rect.x + 50.0, rect.y + 20.0))
            .expect("should find node");

    // Normalized position: 50/100 = 0.5, 20/40 = 0.5
    assert!((norm_x - 0.5).abs() < 0.01);
    assert!((norm_y - 0.5).abs() < 0.01);

    hit_node.handlers.on_pointer_down.as_ref().unwrap()(norm_x, norm_y);
    assert!(fired.get());
}
```

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

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

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

- [ ] **Step 6.3: Add `hit_test_pointer_down` and `collect_click_outside_nodes` to `hit_test.rs`**

In `src/src/platform/hit_test.rs`:

```rust
/// Find the topmost node with `on_pointer_down` under `point`.
/// Returns `(node, norm_x, norm_y)` where norm_x/y are in 0.0..=1.0 relative to the node's rect.
pub fn hit_test_pointer_down<'a>(
    node: &'a RetainedNode,
    layout: &LayoutMap,
    point: LogicalPoint,
) -> Option<(&'a RetainedNode, f32, f32)> {
    if matches!(node.kind, RetainedKind::Component { .. }) {
        for child in &node.children {
            if let Some(result) = hit_test_pointer_down(child, layout, point) {
                return Some(result);
            }
        }
        return None;
    }

    // Children first (post-order → deepest wins)
    for child in &node.children {
        if let Some(result) = hit_test_pointer_down(child, layout, point) {
            return Some(result);
        }
    }

    if node.handlers.on_pointer_down.is_some() {
        if let Some(id) = node.taffy_id {
            if let Some(rect) = layout.get(id) {
                if point_in_rect(point, rect) {
                    let norm_x = ((point.x - rect.x) / rect.width).clamp(0.0, 1.0);
                    let norm_y = ((point.y - rect.y) / rect.height).clamp(0.0, 1.0);
                    return Some((node, norm_x, norm_y));
                }
            }
        }
    }

    None
}

/// Collect all nodes with `on_click_outside` handlers, anywhere in the tree.
pub fn collect_click_outside_nodes<'a>(
    node: &'a RetainedNode,
    out: &mut Vec<&'a RetainedNode>,
) {
    if node.handlers.on_click_outside.is_some() {
        out.push(node);
    }
    for child in &node.children {
        collect_click_outside_nodes(child, out);
    }
}
```

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

```bash
cargo test -p lemon hit_test_pointer_down
```

Expected: `hit_test_pointer_down_finds_draggable_node ... ok`.

- [ ] **Step 6.5: Add `mouse_capture_node` and `mouse_button_down` to `AppState`**

In `src/src/platform/mod.rs`, add to the `AppState` struct:

```rust
pub struct AppState {
    // ... existing fields ...
    pub mouse_capture_node: Option<taffy::NodeId>,
    pub mouse_button_down: bool,
}
```

And to `AppState::new`:

```rust
mouse_capture_node: None,
mouse_button_down: false,
```

- [ ] **Step 6.6: Update the `use` imports in `platform/mod.rs`**

Add to the existing hit_test imports:

```rust
use hit_test::{
    collect_click_outside_nodes, dispatch_click, find_node_by_taffy_id,
    hit_test_focusable, hit_test_hover, hit_test_on_click, hit_test_pointer_down,
    hit_test_scroll, LogicalPoint,
};
```

- [ ] **Step 6.7: Add `event_pass_pointer_down`, `event_pass_pointer_move`, `event_pass_click_outside`**

Add these methods to `impl AppState`:

```rust
fn event_pass_pointer_down(&mut self, point: LogicalPoint) {
    let result = self
        .retained
        .as_ref()
        .and_then(|t| t.root.as_ref())
        .and_then(|root| hit_test_pointer_down(root, &self.layout_map, point));

    if let Some((node, norm_x, norm_y)) = result {
        let taffy_id = node.taffy_id;
        if let Some(handler) = node.handlers.on_pointer_down.clone() {
            handler(norm_x, norm_y);
            self.layout_dirty = true;
            self.paint_dirty = true;
        }
        self.mouse_capture_node = taffy_id;
    }
}

fn event_pass_pointer_move(&mut self, point: LogicalPoint) {
    let Some(capture_id) = self.mouse_capture_node else {
        return;
    };

    let handler_and_norm = self
        .retained
        .as_ref()
        .and_then(|t| t.root.as_ref())
        .and_then(|root| find_node_by_taffy_id(root, capture_id))
        .and_then(|node| {
            node.handlers.on_pointer_move.as_ref().map(|h| {
                let rect = self.layout_map.get(capture_id);
                let (norm_x, norm_y) = rect.map(|r| {
                    (
                        ((point.x - r.x) / r.width).clamp(0.0, 1.0),
                        ((point.y - r.y) / r.height).clamp(0.0, 1.0),
                    )
                }).unwrap_or((0.0, 0.0));
                (h.clone(), norm_x, norm_y)
            })
        });

    if let Some((handler, norm_x, norm_y)) = handler_and_norm {
        handler(norm_x, norm_y);
        self.layout_dirty = true;
        self.paint_dirty = true;
    }
}

fn event_pass_click_outside(&mut self, point: LogicalPoint) {
    let mut nodes_with_handler: Vec<(taffy::NodeId, Rc<dyn Fn()>)> = Vec::new();

    if let Some(root) = self.retained.as_ref().and_then(|t| t.root.as_ref()) {
        let mut nodes = Vec::new();
        collect_click_outside_nodes(root, &mut nodes);
        for node in nodes {
            if let (Some(id), Some(h)) = (node.taffy_id, node.handlers.on_click_outside.clone()) {
                nodes_with_handler.push((id, h));
            }
        }
    }

    for (id, handler) in nodes_with_handler {
        let outside = self.layout_map.get(id).map(|rect| {
            point.x < rect.x
                || point.x >= rect.x + rect.width
                || point.y < rect.y
                || point.y >= rect.y + rect.height
        }).unwrap_or(true);

        if outside {
            handler();
            self.layout_dirty = true;
            self.paint_dirty = true;
        }
    }
}
```

- [ ] **Step 6.8: Wire the new events in the `window_event` handler**

In the `ApplicationHandler` impl's `window_event` method, update the `MouseInput` and `CursorMoved` arms:

For `MouseInput`:
```rust
WindowEvent::MouseInput { state, button: MouseButton::Left, .. } => {
    let (px, py) = self.last_cursor.unwrap_or((0.0, 0.0));
    let point = self.cursor_logical(f64::from(px), f64::from(py));
    match state {
        ElementState::Pressed => {
            self.mouse_button_down = true;
            self.event_pass_click(point);
            self.event_pass_pointer_down(point);
            self.event_pass_click_outside(point);
        }
        ElementState::Released => {
            self.mouse_button_down = false;
            self.mouse_capture_node = None;
        }
    }
    window.request_redraw();
}
```

For `CursorMoved`, append after the existing hover handling:
```rust
WindowEvent::CursorMoved { position, .. } => {
    self.last_cursor = Some((position.x as f32, position.y as f32));
    let point = self.cursor_logical(position.x, position.y);
    self.event_pass_hover(point);
    if self.mouse_button_down {
        self.event_pass_pointer_move(point);
    }
    window.request_redraw();
}
```

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

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

Expected: all tests pass.

- [ ] **Step 6.10: Commit**

```bash
git add src/src/platform/hit_test.rs \
        src/src/platform/mod.rs
git commit -m "feat: add mouse capture, pointer down/move dispatch, and click_outside dispatch"
```

---

## Task 7: `Select<T>` widget

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

`Select` is a pure builder (not a Component). The user owns `Signal<bool>` for open state.

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

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

```rust
pub mod select;
pub use select::Select;

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

    #[test]
    fn select_closed_renders_trigger_only() {
        fn root(cx: &Cx) -> Element {
            let value = cx.use_signal("a".to_string());
            let open = cx.use_signal(false);
            Select::new(value, open)
                .option("Option A", "a".to_string())
                .option("Option B", "b".to_string())
                .into_element()
        }
        let _ = root;
    }

    #[test]
    fn select_open_renders_dropdown_with_z_index() {
        fn root(cx: &Cx) -> Element {
            let value = cx.use_signal("a".to_string());
            let open = cx.use_signal(true);
            Select::new(value, open)
                .option("Option A", "a".to_string())
                .into_element()
        }
        let _ = root;
    }
}
```

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

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

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

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

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

pub struct Select<T: Clone + PartialEq + 'static> {
    value: Signal<T>,
    open: Signal<bool>,
    options: Vec<(T, String)>,
    on_change: Option<Rc<dyn Fn(T)>>,
    width: Option<f32>,
}

impl<T: Clone + PartialEq + 'static> Select<T> {
    pub fn new(value: Signal<T>, open: Signal<bool>) -> Self {
        Select {
            value,
            open,
            options: Vec::new(),
            on_change: None,
            width: None,
        }
    }

    pub fn option(mut self, label: &str, value: T) -> Self {
        self.options.push((value, label.to_string()));
        self
    }

    pub fn on_change(mut self, f: impl Fn(T) + 'static) -> Self {
        self.on_change = Some(Rc::new(f));
        self
    }

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

    pub fn into_element(self) -> Element {
        let is_open = self.open.get();
        let current_val = self.value.get();

        // Find label for current value.
        let current_label = self.options.iter()
            .find(|(v, _)| v == &current_val)
            .map(|(_, l)| l.clone())
            .unwrap_or_default();

        let open_clone = self.open.clone();
        let chevron = if is_open { "▲" } else { "▼" };

        // Trigger box.
        let mut trigger = Box_::new()
            .padding(8.0)
            .border(Color::rgb8(90, 90, 110), 1.0)
            .radius(4.0)
            .on_click(move || {
                open_clone.set(!open_clone.get());
            });

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

        let trigger = trigger.child(
            Row::new()
                .gap(8.0)
                .child(Text::new(current_label).flex_grow(1.0))
                .child(Text::new(chevron))
        );

        if !is_open {
            return trigger.into_element();
        }

        // When open: column with trigger + dropdown.
        let open_ref = self.open.clone();
        let mut dropdown = Box_::new()
            .border(Color::rgb8(90, 90, 110), 1.0)
            .radius(4.0)
            .z_index(100)
            .on_click_outside(move || {
                open_ref.set(false);
            });

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

        let mut option_col = Column::new();
        for (opt_val, opt_label) in self.options {
            let val = self.value.clone();
            let open = self.open.clone();
            let on_change = self.on_change.clone();
            let opt_val_for_click = opt_val.clone();
            let is_selected = opt_val == current_val;

            let bg = if is_selected {
                Color::rgb8(50, 80, 160)
            } else {
                Color::rgb8(30, 30, 42)
            };

            option_col = option_col.child(
                Box_::new()
                    .padding(8.0)
                    .background(bg)
                    .on_hover_enter({
                        // Note: hover visual requires Signal<bool> per-item or
                        // re-render on hover — for V0.4 we skip per-item hover
                        // and rely on the selected color only.
                        || {}
                    })
                    .on_click(move || {
                        val.set(opt_val_for_click.clone());
                        open.set(false);
                        if let Some(cb) = &on_change {
                            cb(opt_val_for_click.clone());
                        }
                    })
                    .child(Text::new(opt_label))
            );
        }

        let dropdown = dropdown.child(option_col);

        Column::new()
            .child(trigger)
            .child(dropdown)
            .into_element()
    }
}

impl<T: Clone + PartialEq + 'static> From<Select<T>> for Element {
    fn from(s: Select<T>) -> Self {
        s.into_element()
    }
}
```

> **Note on Signal path:** adjust `lemon::runtime::signal::Signal` to match the actual re-export path in `src/src/lib.rs`. If `lemon` re-exports `Signal` at the crate root, use `lemon::Signal`.

- [ ] **Step 7.4: Verify it compiles**

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

Fix any import path errors.

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

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

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/select.rs src/widget/src/lib.rs
git commit -m "feat: add Select widget with z-indexed dropdown and click-outside close"
```

---

## Task 8: `Slider` widget

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

`Slider` uses `on_pointer_down` and `on_pointer_move` with normalized position for both click-to-set and drag interaction. No absolute positioning needed — the track itself receives the normalized X position.

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

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

```rust
pub mod slider;
pub use slider::Slider;

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

    #[test]
    fn slider_renders_without_panic() {
        fn root(cx: &Cx) -> Element {
            let value = cx.use_signal(0.5f32);
            Slider::new(value).min(0.0).max(1.0).width(200.0).into_element()
        }
        let _ = root;
    }

    #[test]
    fn slider_value_updates_on_pointer_down() {
        use std::cell::Cell;
        use lemon::asset::image_handle::ImageData;
        use std::sync::Arc;

        // Simulate what happens when the slider's on_pointer_down fires with frac_x=0.75.
        // We build the element, find the track node's on_pointer_down, and call it.
        // This is a unit test of the closure logic, not the full widget tree.
        let value_store = std::rc::Rc::new(Cell::new(0.0f32));
        let vs = value_store.clone();
        let min = 0.0f32;
        let max = 10.0f32;

        // Simulate the closure that Slider creates:
        let simulate_pointer_down = move |frac_x: f32| {
            let new_val = (min + (max - min) * frac_x).clamp(min, max);
            vs.set(new_val);
        };

        simulate_pointer_down(0.75);
        assert!((value_store.get() - 7.5).abs() < 0.001);
    }
}
```

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

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

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

- [ ] **Step 8.3: Write `src/widget/src/slider.rs`**

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

pub struct Slider {
    value: Signal<f32>,
    min: f32,
    max: f32,
    on_change: Option<Rc<dyn Fn(f32)>>,
    width: Option<f32>,
}

impl Slider {
    pub fn new(value: Signal<f32>) -> Self {
        Slider {
            value,
            min: 0.0,
            max: 1.0,
            on_change: None,
            width: None,
        }
    }

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

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

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

    pub fn on_change(mut self, f: impl Fn(f32) + 'static) -> Self {
        self.on_change = Some(Rc::new(f));
        self
    }

    pub fn into_element(self) -> Element {
        let track_w = self.width.unwrap_or(200.0);
        let current = self.value.get().clamp(self.min, self.max);
        let frac = if (self.max - self.min).abs() < f32::EPSILON {
            0.0
        } else {
            (current - self.min) / (self.max - self.min)
        };

        // Left fill width: frac of track, leaving 16px for thumb.
        let fill_w = ((frac * track_w) - 8.0).max(0.0);

        let accent = Color::rgb8(80, 130, 230);
        let track_bg = Color::rgb8(55, 55, 75);

        // Clone signals for closures.
        let val_down = self.value.clone();
        let min_down = self.min;
        let max_down = self.max;
        let cb_down = self.on_change.clone();

        let val_move = self.value.clone();
        let min_move = self.min;
        let max_move = self.max;
        let cb_move = self.on_change.clone();

        let update = |val: &Signal<f32>, cb: &Option<Rc<dyn Fn(f32)>>, min: f32, max: f32, frac_x: f32| {
            let new_val = (min + (max - min) * frac_x).clamp(min, max);
            val.set(new_val);
            if let Some(f) = cb {
                f(new_val);
            }
        };

        // Track row: [left-fill | thumb | right-fill]
        Row::new()
            .width(track_w)
            .height(16.0)
            .align_items(Align::Center)
            .on_pointer_down(move |frac_x, _| {
                let new_val = (min_down + (max_down - min_down) * frac_x).clamp(min_down, max_down);
                val_down.set(new_val);
                if let Some(f) = &cb_down { f(new_val); }
            })
            .on_pointer_move(move |frac_x, _| {
                let new_val = (min_move + (max_move - min_move) * frac_x).clamp(min_move, max_move);
                val_move.set(new_val);
                if let Some(f) = &cb_move { f(new_val); }
            })
            // Left fill (tracks progress)
            .child(
                Box_::new()
                    .width(fill_w)
                    .height(4.0)
                    .radius(2.0)
                    .background(accent)
            )
            // Thumb circle
            .child(
                Box_::new()
                    .width(16.0)
                    .height(16.0)
                    .radius(8.0)
                    .background(accent)
            )
            // Right fill (remaining track)
            .child(
                Box_::new()
                    .flex_grow(1.0)
                    .height(4.0)
                    .radius(2.0)
                    .background(track_bg)
            )
            .into_element()
    }
}

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

- [ ] **Step 8.4: Verify it compiles**

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

Fix any path or type errors.

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

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

Expected: both tests pass.

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

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

Expected: all tests pass.

- [ ] **Step 8.7: Commit**

```bash
git add src/widget/src/slider.rs src/widget/src/lib.rs
git commit -m "feat: add Slider widget with click and drag via on_pointer_down/move"
```

---

## Task 9: Scrollbar visual on `Scroll` widget

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

Add a proportional scrollbar thumb overlay. The scrollbar uses `z_index: 1` so it renders above the content. It is draggable via `on_pointer_move`.

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

In `src/widget/src/lib.rs`, add to existing tests or the scroll_tests module:

```rust
#[cfg(test)]
mod scroll_scrollbar_tests {
    use lemon::{Cx, element::Element};
    use crate::Scroll;

    #[test]
    fn scroll_with_scrollbar_compiles() {
        fn root(cx: &Cx) -> Element {
            let offset = cx.use_signal(0.0f64);
            let content_h = cx.use_signal(600.0f32);
            Scroll::new(lemon::element::Element::None, offset)
                .height(200.0)
                .content_height(content_h)
                .into_element()
        }
        let _ = root;
    }
}
```

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

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

Expected: compile error — `.content_height()` method does not exist.

- [ ] **Step 9.3: Update `src/widget/src/scroll.rs` to add scrollbar**

Replace the entire file:

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

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

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

    /// Provide the content's total height so a proportional scrollbar can be rendered.
    /// If not provided, no scrollbar is shown.
    pub fn content_height(mut self, h: Signal<f32>) -> Self {
        self.content_height = Some(h);
        self
    }

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

        // Content box with negative margin_top for scroll offset.
        let content_box = Box_::new()
            .margin_top(-offset_val)
            .child(self.child);

        // Build the scrollbar if content_height is known.
        let scrollbar: Option<Element> = self.content_height.as_ref().map(|ch_sig| {
            let content_h = ch_sig.get();
            if content_h <= viewport_h {
                return Element::None;
            }
            // Thumb height proportional to viewport / content.
            let thumb_h = (viewport_h * viewport_h / content_h).max(20.0);
            // Thumb Y position proportional to offset.
            let max_offset = (content_h - viewport_h).max(1.0);
            let thumb_y = (offset_val / max_offset) * (viewport_h - thumb_h);

            let off_drag = off.clone();
            let max_off = max_offset as f64;

            // Scrollbar track (right side, z_index: 1).
            Box_::new()
                .width(6.0)
                .height(viewport_h)
                .z_index(1)
                .background(Color::rgb8(30, 30, 42))
                .radius(3.0)
                // Thumb inside track.
                .child(
                    Box_::new()
                        .width(6.0)
                        .height(thumb_h)
                        .margin_top(thumb_y)
                        .background(Color::rgb8(100, 100, 130))
                        .radius(3.0)
                        .on_pointer_move(move |_, frac_y| {
                            let new_offset = (frac_y as f64 * max_off).clamp(0.0, max_off);
                            off_drag.set(new_offset);
                        })
                )
                .into_element()
        });

        // Viewport with content + scrollbar side by side.
        let mut viewport = Box_::new()
            .height(viewport_h)
            .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 = viewport.child(content_box);

        if let Some(sb) = scrollbar {
            if !matches!(sb, Element::None) {
                viewport = viewport.child(sb);
            }
        }

        viewport.into_element()
    }
}

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

> **Note:** The scrollbar is placed as a sibling of the content inside the viewport. For it to visually overlay the content, it would need absolute positioning (not yet available). In V0.4, the scrollbar appears to the right of the content within the flex row. Proper overlay positioning is deferred to V0.5.

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

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

Expected: `scroll_scrollbar_tests::scroll_with_scrollbar_compiles ... ok`.

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

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

Expected: all tests pass.

- [ ] **Step 9.6: Commit**

```bash
git add src/widget/src/scroll.rs
git commit -m "feat: add proportional scrollbar with drag to Scroll widget"
```

---

## Task 10: Example `rich`

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

Demonstrates V0.4 complete: PNG image, `Select` category filter, `Slider` quantity, scrollable results list.

- [ ] **Step 10.1: Add `examples/rich` to workspace**

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

- [ ] **Step 10.2: Write `examples/rich/Cargo.toml`**

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

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

- [ ] **Step 10.3: Write `examples/rich/src/main.rs`**

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

#[derive(Clone, PartialEq)]
enum Category {
    All,
    Fruits,
    Vegetables,
}

impl Category {
    fn label(&self) -> &str {
        match self {
            Category::All => "All",
            Category::Fruits => "Fruits",
            Category::Vegetables => "Vegetables",
        }
    }
}

fn all_items() -> Vec<(&'static str, Category)> {
    vec![
        ("Apple", Category::Fruits),
        ("Banana", Category::Fruits),
        ("Carrot", Category::Vegetables),
        ("Broccoli", Category::Vegetables),
        ("Grape", Category::Fruits),
        ("Spinach", Category::Vegetables),
        ("Orange", Category::Fruits),
        ("Kale", Category::Vegetables),
    ]
}

fn app(cx: &Cx) -> lemon::element::Element {
    let category = cx.use_signal(Category::All);
    let cat_open = cx.use_signal(false);
    let quantity = cx.use_signal(1.0f32);
    let scroll_offset = cx.use_signal(0.0f64);

    let cat_read = category.clone();
    let qty_read = quantity.clone();

    let items: Vec<&str> = all_items()
        .iter()
        .filter(|(_, cat)| {
            let selected = cat_read.get();
            matches!(selected, Category::All) || cat == &selected
        })
        .map(|(name, _)| *name)
        .collect();

    let item_count = items.len();

    Column::new()
        .padding(24.0)
        .gap(16.0)
        .child(Text::new("Rich Content Demo").font_size(22.0))
        // Category selector
        .child(
            Row::new()
                .gap(12.0)
                .align_items(lemon::element::style::Align::Center)
                .child(Text::new("Category:").font_size(14.0).color(Color::rgb8(160, 160, 180)))
                .child(
                    Select::new(category, cat_open)
                        .option(Category::All.label(), Category::All)
                        .option(Category::Fruits.label(), Category::Fruits)
                        .option(Category::Vegetables.label(), Category::Vegetables)
                        .width(160.0)
                )
        )
        // Quantity slider
        .child(
            Row::new()
                .gap(12.0)
                .align_items(lemon::element::style::Align::Center)
                .child(Text::new("Qty:").font_size(14.0).color(Color::rgb8(160, 160, 180)))
                .child(Slider::new(quantity).min(1.0).max(10.0).width(180.0))
                .child(
                    Text::new(move || format!("{:.0}", qty_read.get()))
                        .font_size(14.0)
                )
        )
        // Results header
        .child(
            Text::new(move || format!("{} items", item_count))
                .font_size(13.0)
                .color(Color::rgb8(140, 140, 160))
        )
        // Scrollable results list
        .child(
            Scroll::new(
                items.iter().fold(Column::new().gap(6.0), |col, name| {
                    col.child(
                        lemon::element::builders::Box_::new()
                            .padding(10.0)
                            .background(Color::rgb8(30, 30, 42))
                            .radius(6.0)
                            .width(280.0)
                            .child(Text::new(*name).font_size(14.0))
                    )
                }).into_element(),
                scroll_offset,
            )
            .height(240.0)
            .width(300.0)
        )
        .into_element()
}

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

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

```bash
cargo build -p rich
```

Fix any compile errors. Common issues:
- `Signal::update` vs `Signal::set` — use whichever is correct for your runtime
- `Text::new(move || ...)` — dynamic text content requires `TextContent` closure support (verify it's working)
- `Category` needs `PartialEq` for `==` comparisons in filter (already derived above)

- [ ] **Step 10.5: Run clippy on the whole workspace**

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

Fix all warnings.

- [ ] **Step 10.6: Commit**

```bash
git add examples/rich/ Cargo.toml
git commit -m "example: add rich demo with Select, Slider, Scroll, and image support"
```

---

## Task 11: Final checks

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

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

Expected: all tests pass, including image, z_index, pointer, select, slider tests.

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

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

Expected: zero warnings.

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

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

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

- [ ] **Step 11.4: Smoke-test visually**

```bash
cargo run -p rich
```

Verify:
- [ ] Category `Select` opens dropdown on click, closes on option select and on click outside
- [ ] `Slider` updates quantity: clicking different positions sets the value, dragging works
- [ ] Results list scrolls with mouse wheel
- [ ] Scrollbar thumb appears proportional to list height

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

```bash
git add -p
git commit -m "polish: V0.4 final cleanup"
```

---

## Self-review

**Spec coverage:**
- [x] `ImageHandle` from PNG/JPEG bytes/path — Task 1
- [x] Image rendering via peniko `draw_image` with object-fit:contain — Task 2
- [x] `Image` widget in lemon::widget — Task 3
- [x] `z_index: i32` in StyleProps + `.z_index()` builder — Task 4
- [x] Two-pass paint ordering (deferred z_index nodes) — Task 4
- [x] `Select<T>` with dropdown (z_index 100) + close on click outside — Tasks 5, 6, 7
- [x] `Slider` with click-to-set + drag — Tasks 5, 6, 8
- [x] Scrollbar visual (proportional thumb, draggable via on_pointer_move) — Task 9
- [x] Example `rich` — Task 10

**Texture cache:** The spec mentions a `wgpu::Texture`-based cache. This plan uses `peniko::Image` directly (Vello manages GPU uploads). The `peniko::Blob` is already Arc-backed so pixel data is not re-copied per frame when using `handle.pixels().to_vec()`. For a proper zero-copy cache, a future task could add `ImageHandle::to_peniko_image()` that stores the `PenikoImage` in the same `Arc<ImageData>`. This is an optimization deferred to V0.5.

**Scrollbar overlay:** The scrollbar in Task 9 is placed as a flex sibling of the content (not overlaid). True overlay requires position: absolute (V0.5). The visual is functional for V0.4.

---

## Criteria of done

- [ ] `cargo test --workspace` — no failures
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` — no warnings
- [ ] `Image` widget renders a PNG loaded via `ImageHandle::from_path`
- [ ] `z_index` nodes paint above z_index 0 siblings
- [ ] `Select` opens dropdown, option selection closes it, clicking outside closes it
- [ ] `Slider` sets value on click; dragging updates value continuously
- [ ] Scrollbar thumb appears proportional to content height when `content_height` is provided
- [ ] `cargo run -p rich` — all features functional end-to-end
