# V0.5 Platform & Polish 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 typed props, fix Fragment edge cases, introduce a Theme system, animations with opacity, and multi-window support to Lemon.

**Architecture:** Each feature is independent and additive. Typed props extend `ComponentElement` with an `AnyProps` trait object for equality-based skip optimization. Theme is a thread-local injected by `AppState` before each frame — no Runtime plumbing required. Animations use a shared `Weak<AnimSlot>` registry; `AppState` advances them each frame and requests a redraw while any are active. Multi-window extracts the per-window state into a `WindowState` struct and maps `WindowId → WindowState`.

**Tech Stack:** Rust, winit 0.30, wgpu 29, Vello 0.9, Taffy 0.7, Parley — no new crates except for a11y (Task 12, optional).

**Pre-requisites:** V0.4 complete (Image, Z-index, Select, Slider, Scroll, TextInput implemented and passing `cargo test --workspace`).

---

## File Structure

**Create:**
- `src/src/theme.rs` — `Theme`, `ColorTokens`, `TypographyTokens`, `SpacingTokens`, `RadiusTokens`, thread-local `ACTIVE_THEME`
- `src/src/animation.rs` — `AnimationConfig`, `Easing`, `AnimSlot`, `AnimationHandle`, `AnimRegistry`, easing math
- `examples/showcase/Cargo.toml`
- `examples/showcase/src/main.rs`

**Modify:**
- `src/src/element/types.rs` — add `AnyProps` trait + `props: Option<Box<dyn AnyProps>>` to `ComponentElement`
- `src/src/element/builders.rs` — add `Component::new_with_props`
- `src/src/element/style.rs` — add `opacity: f32` to `StyleProps` (default `1.0`)
- `src/src/runtime/mod.rs` — `ComponentSlot` stores `props`, skip `sync_render` when props equal; flatten Fragment children in diff
- `src/src/retained/mod.rs` — flatten Fragment when building children
- `src/src/runtime/cx.rs` — add `use_theme()`, `use_animation()`, `open_window_sink`
- `src/src/paint/mod.rs` — push opacity layer when `opacity < 1.0`
- `src/src/platform/mod.rs` — `run_with_theme`, inject theme TL, `active_animations` advance loop, extract `WindowState`, multi-window via `HashMap<WindowId, WindowState>`
- `src/src/lib.rs` — re-export `Theme`, `AnimationConfig`, `Easing`, `AnimationHandle`, `run_with_theme`
- `src/widget/src/lib.rs` — widget builders call `cx.use_theme()` for default colors/sizes
- `Cargo.toml` — add `examples/showcase` to workspace members

---

## Task 1: AnyProps trait + typed-props ComponentElement constructor

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

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

Add inside the `#[cfg(test)]` block in `src/src/element/types.rs`:

```rust
#[test]
fn component_element_with_props_stores_and_compares_props() {
    #[derive(Clone, PartialEq)]
    struct MyProps { value: i32 }

    fn my_comp(_cx: &crate::runtime::cx::Cx, props: &MyProps) -> Element {
        Text::new(props.value.to_string()).into_element()
    }

    let a = ComponentElement::from_component_fn_with_props(my_comp, MyProps { value: 1 });
    let b = ComponentElement::from_component_fn_with_props(my_comp, MyProps { value: 1 });
    let c = ComponentElement::from_component_fn_with_props(my_comp, MyProps { value: 2 });

    assert_eq!(a.identity(), b.identity());
    assert!(a.props_eq(&b), "same props must be equal");
    assert!(!a.props_eq(&c), "different props must not be equal");
}

#[test]
fn component_new_with_props_renders_with_props() {
    use crate::element::builders::Component;
    #[derive(Clone, PartialEq)]
    struct P { label: String }

    fn labelled(_cx: &crate::runtime::cx::Cx, p: &P) -> Element {
        Text::new(p.label.clone()).into_element()
    }

    let el = Component::new_with_props(labelled, P { label: "hi".into() }).into_element();
    let Element::Component(comp) = el else { panic!("expected component") };
    let rendered = (comp.view())(&crate::runtime::cx::Cx::new());
    let Element::Text(t) = rendered else { panic!("expected text") };
    assert_eq!(t.content.resolve(), "hi");
}
```

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

```bash
cargo test -p lemon --lib element::types::tests 2>&1 | head -30
```

Expected: FAIL — `from_component_fn_with_props`, `props_eq`, `Component::new_with_props` not found.

- [ ] **Step 3: Add `AnyProps` trait and extend `ComponentElement`**

In `src/src/element/types.rs`, add before `ComponentElement`:

```rust
pub trait AnyProps: 'static {
    fn clone_box(&self) -> Box<dyn AnyProps>;
    fn eq_box(&self, other: &dyn AnyProps) -> bool;
    fn as_any(&self) -> &dyn std::any::Any;
}

impl<T: Clone + PartialEq + 'static> AnyProps for T {
    fn clone_box(&self) -> Box<dyn AnyProps> {
        Box::new(self.clone())
    }
    fn eq_box(&self, other: &dyn AnyProps) -> bool {
        other.as_any().downcast_ref::<T>().map_or(false, |o| self == o)
    }
    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}
```

Add `props: Option<Box<dyn AnyProps>>` to `ComponentElement`:

```rust
pub struct ComponentElement {
    view: Rc<dyn Fn(&crate::runtime::cx::Cx) -> crate::element::Element>,
    type_id: std::any::TypeId,
    identity: usize,
    key: Option<Key>,
    pub(crate) props: Option<Box<dyn AnyProps>>,  // None for closure-based components
}
```

Update `ComponentElement::new` to include `props: None`:

```rust
fn new(
    type_id: std::any::TypeId,
    identity: usize,
    view: Rc<dyn Fn(&crate::runtime::cx::Cx) -> crate::element::Element>,
) -> Self {
    Self { view, type_id, identity, key: None, props: None }
}
```

Add `from_component_fn_with_props` and `props_eq`:

```rust
pub fn from_component_fn_with_props<P: Clone + PartialEq + 'static>(
    f: fn(&crate::runtime::cx::Cx, &P) -> crate::element::Element,
    props: P,
) -> Self {
    let props_for_view = props.clone();
    Self {
        view: Rc::new(move |cx| f(cx, &props_for_view)),
        type_id: std::any::TypeId::of::<fn(&crate::runtime::cx::Cx, &P) -> crate::element::Element>(),
        identity: f as *const () as usize,
        key: None,
        props: Some(Box::new(props)),
    }
}

pub fn props_eq(&self, other: &ComponentElement) -> bool {
    match (&self.props, &other.props) {
        (Some(a), Some(b)) => a.eq_box(b.as_ref()),
        (None, None) => true,
        _ => false,
    }
}
```

Update `Clone` impl for `ComponentElement` (currently derived — change to manual):

```rust
impl Clone for ComponentElement {
    fn clone(&self) -> Self {
        Self {
            view: self.view.clone(),
            type_id: self.type_id,
            identity: self.identity,
            key: self.key.clone(),
            props: self.props.as_ref().map(|p| p.clone_box()),
        }
    }
}
```

Remove `#[derive(Clone)]` from `ComponentElement`.

- [ ] **Step 4: Add `Component::new_with_props` to builders.rs**

In `src/src/element/builders.rs`, inside the `Component` impl block:

```rust
pub fn new_with_props<P: Clone + PartialEq + 'static>(
    f: fn(&crate::runtime::cx::Cx, &P) -> Element,
    props: P,
) -> Self {
    Component(ComponentElement::from_component_fn_with_props(f, props))
}
```

- [ ] **Step 5: Run tests**

```bash
cargo test -p lemon --lib element::types::tests 2>&1 | tail -20
```

Expected: all tests in `element::types::tests` pass.

- [ ] **Step 6: Run full test suite**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all tests pass.

- [ ] **Step 7: Commit**

```bash
git add src/src/element/types.rs src/src/element/builders.rs
git commit -m "feat: add AnyProps trait and Component::new_with_props for typed component props"
```

---

## Task 2: Props equality skip — avoid re-rendering when props unchanged

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

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

Add to the `#[cfg(test)]` block in `src/src/runtime/mod.rs`:

```rust
#[test]
fn typed_props_component_skips_rerender_when_props_unchanged() {
    use crate::element::builders::{Column, Component, Text};
    use std::cell::Cell;
    use std::rc::Rc;

    #[derive(Clone, PartialEq)]
    struct P { value: u32 }

    let render_count = Rc::new(Cell::new(0u32));
    let rc2 = render_count.clone();

    fn child(_cx: &Cx, p: &P) -> Element {
        Text::new(p.value.to_string()).into_element()
    }

    let trigger = Signal::new(0u32);
    let t2 = trigger.clone();
    let mut rt = Runtime::new();
    rt.mount(move |_cx| {
        t2.get();
        rc2.set(rc2.get() + 1);
        Column::new()
            .child(Component::new_with_props(child, P { value: 42 }))
            .into_element()
    });

    let count_after_mount = render_count.get();

    // Trigger parent re-render with same props — child must NOT re-render
    trigger.set(1);
    rt.flush_effects();
    rt.take_patches();

    // Parent rendered again, child props unchanged → child render count must not increase
    // We verify indirectly: no UpdateText patch for the child's text
    trigger.set(2);
    rt.flush_effects();
    let patches = rt.take_patches();

    assert!(
        !patches.iter().any(|p| matches!(p, Patch::UpdateText { content, .. } if content == "42")),
        "child should not re-render when props unchanged; patches: {patches:?}"
    );
    assert!(render_count.get() > count_after_mount, "parent did re-render");
}
```

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

```bash
cargo test -p lemon --lib runtime::tests::typed_props_component_skips_rerender_when_props_unchanged 2>&1 | tail -20
```

Expected: FAIL (test may pass vacuously or fail — we need to confirm the optimization is not in place yet by checking behavior).

- [ ] **Step 3: Add `props` field to `ComponentSlot` and skip logic**

In `src/src/runtime/mod.rs`, add to `ComponentSlot`:

```rust
struct ComponentSlot {
    path: NodePath,
    previous: Option<Element>,
    pending: Rc<RefCell<Option<Element>>>,
    view: Rc<RefCell<ViewFn>>,
    cx: Rc<RefCell<Cx>>,
    children: Vec<ComponentSlot>,
    deferred_effects: Rc<RefCell<Vec<Effect>>>,
    _effect: Effect,
    props: Option<Box<dyn crate::element::types::AnyProps>>,  // ← new
}
```

Update `create_component_slot` to initialize `props: None`:

```rust
ComponentSlot {
    path,
    previous: None,
    pending,
    view: view_cell,
    cx,
    children: Vec::new(),
    deferred_effects,
    _effect: effect,
    props: None,  // ← new
}
```

In `handle_component_pair`, replace the `same_component_identity` branch with:

```rust
if same_component_identity(old, &new) {
    let props_changed = !old.props_eq(&new);

    if let Some(slot) = find_slot_mut(parent_slots, &path) {
        if props_changed {
            *slot.view.borrow_mut() = new.view();
            sync_render(slot);
        }
        slot.props = new.props.as_ref().map(|p| p.clone_box());
        render_slot(slot, patches);
    } else {
        mount_component_slot(parent_slots, path.clone(), new.clone(), Rc::clone(&deferred_effects));
        if let Some(slot) = find_slot_mut(parent_slots, &path) {
            slot.props = new.props.as_ref().map(|p| p.clone_box());
            render_slot(slot, patches);
        }
    }
    patches.push(Patch::UpdateComponent { node: path, component: new_for_patch });
} else {
    // ... unchanged
}
```

- [ ] **Step 4: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all tests pass including the new skip test.

- [ ] **Step 5: Commit**

```bash
git add src/src/runtime/mod.rs
git commit -m "perf: skip child re-render when typed props are unchanged"
```

---

## Task 3: Fragment fixes — flatten in retained tree and diff

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

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

Add to `#[cfg(test)]` in `src/src/lib.rs`:

```rust
#[test]
fn fragment_children_mount_into_retained_tree() {
    use crate::element::Element;
    let tree = RetainedTree::mount(
        Column::new()
            .child(Element::Fragment(vec![
                Text::new("a").into_element(),
                Text::new("b").into_element(),
            ]))
            .into_element(),
    );
    assert!(tree.is_ok(), "Fragment must not crash mount: {tree:?}");
    let tree = tree.unwrap();
    let root = tree.root.as_ref().unwrap();
    assert_eq!(root.children.len(), 2, "Fragment children should be flattened into parent");
}

#[test]
fn fragment_in_component_output_renders_all_children() {
    use crate::element::Element;

    fn multi(_cx: &Cx) -> Element {
        Element::Fragment(vec![
            Text::new("x").into_element(),
            Text::new("y").into_element(),
        ])
    }

    let mut rt = Runtime::new();
    rt.mount(move |_cx| {
        Column::new()
            .child(Component::new(multi).key(1))
            .into_element()
    });

    let root = rt.root_element().unwrap();
    // No panic: fragments in component output are handled
    let _ = RetainedTree::mount(root);
}
```

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

```bash
cargo test -p lemon --lib tests::fragment_children_mount_into_retained_tree 2>&1 | tail -10
```

Expected: FAIL with `UnsupportedElement("Fragment")`.

- [ ] **Step 3: Add `flatten_elements` helper in `retained/mod.rs`**

Add before `impl RetainedTree`:

```rust
fn flatten_elements(elements: Vec<Element>) -> Vec<Element> {
    let mut out = Vec::with_capacity(elements.len());
    for el in elements {
        match el {
            Element::Fragment(children) => out.extend(flatten_elements(children)),
            other => out.push(other),
        }
    }
    out
}
```

In `build_box_node`, replace `let mut children = Vec::with_capacity(node_children.len());` + loop with:

```rust
let flat_children = flatten_elements(node_children);
let mut children = Vec::with_capacity(flat_children.len());
for child in flat_children {
    children.push(self.build_node(child)?);
}
```

- [ ] **Step 4: Flatten Fragments in `diff_children_slots` in `runtime/mod.rs`**

Add a `flatten_elements` helper in `runtime/mod.rs` (same logic):

```rust
fn flatten_elements(elements: Vec<Element>) -> Vec<Element> {
    let mut out = Vec::with_capacity(elements.len());
    for el in elements {
        match el {
            Element::Fragment(children) => out.extend(flatten_elements(children)),
            other => out.push(other),
        }
    }
    out
}
```

In `diff_with_nested_components`, in the `(Column | Row | Box_)` arm, flatten children before diffing:

```rust
(Element::Column(old), Element::Column(new))
| (Element::Row(old), Element::Row(new))
| (Element::Box_(old), Element::Box_(new)) => {
    diff_container_props(&old, &new, &path, patches);
    let old_children = flatten_elements(old.children);
    let new_children = flatten_elements(new.children);
    diff_children_slots(slots, &old_children, &new_children, &path, patches, deferred_effects);
}
```

Also add `(Element::Fragment(o), Element::Fragment(n))` handling in `freeze_element` (already exists), and in the fallback `diff` function in `diff/mod.rs`, add:

```rust
(Fragment(o), Fragment(n)) => {
    let min = o.len().min(n.len());
    for i in 0..min {
        patches.extend(diff(o[i].clone(), n[i].clone(), path.child(i)));
    }
    for i in (min..o.len()).rev() {
        patches.push(Patch::RemoveChild { parent: path.clone(), index: i });
    }
    for (offset, el) in n.iter().enumerate().skip(min) {
        patches.push(Patch::InsertChild { parent: path.clone(), index: min + offset, element: el.clone() });
    }
}
```

- [ ] **Step 5: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all tests pass including the two Fragment tests.

- [ ] **Step 6: Commit**

```bash
git add src/src/retained/mod.rs src/src/runtime/mod.rs src/src/diff/mod.rs
git commit -m "fix: flatten Fragment children in retained tree and diff pass"
```

---

## Task 4: Theme struct

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

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

Add to the `#[cfg(test)]` block in `src/src/lib.rs`:

```rust
#[test]
fn theme_default_light_has_sensible_values() {
    use crate::theme::Theme;
    let t = Theme::default_light();
    assert_eq!(t.typography.font_size_md, 14.0);
    assert_eq!(t.spacing.lg, 16.0);
    assert_eq!(t.radius.md, 8.0);
    assert!(t.colors.accent.r > 0.0 || t.colors.accent.b > 0.0);
}
```

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

```bash
cargo test -p lemon --lib tests::theme_default_light_has_sensible_values 2>&1 | tail -10
```

Expected: FAIL — `crate::theme` not found.

- [ ] **Step 3: Create `src/src/theme.rs`**

```rust
use std::cell::RefCell;
use std::rc::Rc;
use crate::element::style::Color;

#[derive(Clone, PartialEq)]
pub struct Theme {
    pub colors: ColorTokens,
    pub typography: TypographyTokens,
    pub spacing: SpacingTokens,
    pub radius: RadiusTokens,
}

#[derive(Clone, PartialEq)]
pub struct ColorTokens {
    pub background: Color,
    pub surface: Color,
    pub foreground: Color,
    pub foreground_secondary: Color,
    pub accent: Color,
    pub accent_hover: Color,
    pub error: Color,
    pub border: Color,
}

#[derive(Clone, PartialEq)]
pub struct TypographyTokens {
    pub font_size_sm: f32,
    pub font_size_md: f32,
    pub font_size_lg: f32,
    pub font_size_xl: f32,
    pub line_height: f32,
}

#[derive(Clone, PartialEq)]
pub struct SpacingTokens {
    pub xs: f32,
    pub sm: f32,
    pub md: f32,
    pub lg: f32,
    pub xl: f32,
}

#[derive(Clone, PartialEq)]
pub struct RadiusTokens {
    pub sm: f32,
    pub md: f32,
    pub lg: f32,
}

impl Theme {
    pub fn default_light() -> Self {
        Self {
            colors: ColorTokens {
                background: Color::rgb8(255, 255, 255),
                surface: Color::rgb8(245, 245, 245),
                foreground: Color::rgb8(17, 17, 17),
                foreground_secondary: Color::rgb8(100, 100, 100),
                accent: Color::rgb8(59, 130, 246),
                accent_hover: Color::rgb8(37, 99, 235),
                error: Color::rgb8(220, 38, 38),
                border: Color::rgb8(220, 220, 220),
            },
            typography: TypographyTokens {
                font_size_sm: 12.0,
                font_size_md: 14.0,
                font_size_lg: 18.0,
                font_size_xl: 24.0,
                line_height: 1.5,
            },
            spacing: SpacingTokens { xs: 4.0, sm: 8.0, md: 12.0, lg: 16.0, xl: 24.0 },
            radius: RadiusTokens { sm: 4.0, md: 8.0, lg: 12.0 },
        }
    }

    pub fn default_dark() -> Self {
        Self {
            colors: ColorTokens {
                background: Color::rgb8(17, 17, 17),
                surface: Color::rgb8(30, 30, 30),
                foreground: Color::rgb8(240, 240, 240),
                foreground_secondary: Color::rgb8(160, 160, 160),
                accent: Color::rgb8(96, 165, 250),
                accent_hover: Color::rgb8(147, 197, 253),
                error: Color::rgb8(248, 113, 113),
                border: Color::rgb8(60, 60, 60),
            },
            typography: TypographyTokens {
                font_size_sm: 12.0,
                font_size_md: 14.0,
                font_size_lg: 18.0,
                font_size_xl: 24.0,
                line_height: 1.5,
            },
            spacing: SpacingTokens { xs: 4.0, sm: 8.0, md: 12.0, lg: 16.0, xl: 24.0 },
            radius: RadiusTokens { sm: 4.0, md: 8.0, lg: 12.0 },
        }
    }
}

thread_local! {
    pub(crate) static ACTIVE_THEME: RefCell<Rc<Theme>> =
        RefCell::new(Rc::new(Theme::default_light()));
}

pub(crate) fn set_active_theme(theme: Rc<Theme>) {
    ACTIVE_THEME.with(|t| *t.borrow_mut() = theme);
}

pub fn current_theme() -> Theme {
    ACTIVE_THEME.with(|t| t.borrow().as_ref().clone())
}
```

- [ ] **Step 4: Register module and re-export in `lib.rs`**

```rust
pub mod theme;
pub use theme::{Theme, ColorTokens, TypographyTokens, SpacingTokens, RadiusTokens};
```

- [ ] **Step 5: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add src/src/theme.rs src/src/lib.rs
git commit -m "feat: add Theme struct with default light/dark tokens and thread-local injection"
```

---

## Task 5: `use_theme` hook + `run_with_theme`

**Files:**
- Modify: `src/src/runtime/cx.rs`
- Modify: `src/src/platform/mod.rs`
- Modify: `src/src/lib.rs`

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

Add to `src/src/runtime/cx.rs` tests:

```rust
#[test]
fn use_theme_returns_active_theme() {
    use crate::theme::{set_active_theme, Theme};
    use std::rc::Rc;
    let dark = Theme::default_dark();
    set_active_theme(Rc::new(dark.clone()));
    let cx = Cx::new();
    let t = cx.use_theme();
    assert_eq!(t.colors.background, dark.colors.background);
}
```

- [ ] **Step 2: Run to verify it fails**

```bash
cargo test -p lemon --lib runtime::cx::tests::use_theme_returns_active_theme 2>&1 | tail -10
```

Expected: FAIL — `use_theme` not found.

- [ ] **Step 3: Add `use_theme` to `Cx`**

`use_theme` does NOT consume a hook slot — it reads the thread-local directly each call.

```rust
pub fn use_theme(&self) -> crate::theme::Theme {
    crate::theme::current_theme()
}
```

- [ ] **Step 4: Add `theme` field to `AppState` and set TL before each frame**

Add `pub theme: std::rc::Rc<crate::theme::Theme>` to `AppState`. Update `AppState::new` to default to `Rc::new(Theme::default_light())`. Add `run_with_theme`:

```rust
pub fn run_with_theme(
    config: WindowConfig,
    theme: crate::theme::Theme,
    root_fn: fn(&crate::runtime::cx::Cx) -> crate::element::Element,
) {
    crate::theme::set_active_theme(std::rc::Rc::new(theme.clone()));
    let mut state = AppState::new(config, root_fn);
    state.theme = std::rc::Rc::new(theme);
    let event_loop = EventLoop::new().expect("create event loop");
    event_loop.run_app(&mut state).expect("run app");
}
```

In `WindowState::handle_event` (or `AppState::update_frame` before it), set the TL:

```rust
crate::theme::set_active_theme(std::rc::Rc::clone(&app_state.theme));
```

- [ ] **Step 5: Re-export from `lib.rs`**

```rust
pub use platform::{run, run_with_theme, AppState, WindowConfig};
```

- [ ] **Step 6: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all pass.

- [ ] **Step 7: Commit**

```bash
git add src/src/runtime/cx.rs src/src/platform/mod.rs src/src/lib.rs
git commit -m "feat: add use_theme hook and run_with_theme entry point"
```

---

## Task 6: Widgets use theme for default colors and sizes

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

After V0.3/V0.4 are complete, `lemon::widget` contains: `Button`, `TextInput`, `Scroll`, `Select`, `Slider`. Each hardcodes colors. This task makes them read `cx.use_theme()`.

The key API change: all widget builders gain a `cx: &lemon::Cx` parameter.

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

```rust
#[test]
fn button_uses_accent_color_from_theme() {
    use lemon::theme::{set_active_theme, Theme};
    use lemon::{Color, Cx};
    use std::rc::Rc;

    let mut theme = Theme::default_light();
    theme.colors.accent = Color::rgb8(255, 0, 0);
    set_active_theme(Rc::new(theme));

    let cx = Cx::new();
    let el = Button::new("Test", &cx).into_element();
    let lemon::element::Element::Button(btn) = el else { panic!("expected button") };
    let paint = btn.paint.resolve();
    assert_eq!(paint.background, Some(Color::rgb8(255, 0, 0)));
}
```

- [ ] **Step 2: Run to verify it fails**

```bash
cargo test -p lemon::widget --lib 2>&1 | tail -10
```

Expected: FAIL.

- [ ] **Step 3: Update each widget builder to accept `cx: &lemon::Cx`**

For each widget, replace hardcoded color literals with theme tokens:

**`Button::new(label, cx: &Cx)`:**
```rust
pub fn new(label: impl Into<String>, cx: &lemon::Cx) -> Self {
    let theme = cx.use_theme();
    Button(lemon::element::builders::Button::new(label)
        .background(theme.colors.accent)
        .radius(theme.radius.md)
        .padding(theme.spacing.sm))
}
```

**`TextInput::new(state, focused, cx: &Cx)`** — replace `Color::rgb8(59,130,246)` (blue focus border) with `theme.colors.accent` and `Color::rgb8(200,200,200)` (gray border) with `theme.colors.border`.

**`Select::new(value, options, cx: &Cx)`** — `theme.colors.surface` for dropdown bg, `theme.colors.foreground` for text.

**`Slider::new(value, min, max, cx: &Cx)`** — `theme.colors.accent` for fill, `theme.colors.border` for track.

**`Scroll::new(offset, cx: &Cx)`** — `theme.colors.foreground_secondary` for scrollbar thumb.

- [ ] **Step 4: Update all example callers**

```bash
grep -rn "Button::new\|TextInput::new\|Select::new\|Slider::new\|Scroll::new" examples/ --include="*.rs"
```

Each call site that uses a widget: add `, cx` as the last argument, and ensure `cx` is in scope.

- [ ] **Step 5: Build and test**

```bash
cargo test --workspace && cargo build --workspace 2>&1 | tail -20
```

Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add src/widget/src/lib.rs examples/
git commit -m "feat: lemon::widget reads theme tokens via cx.use_theme() instead of hardcoded colors"
```

---

## Task 7: `opacity` in `StyleProps` + paint layer

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

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

Add to `src/src/paint/mod.rs`:

```rust
#[test]
fn opacity_less_than_one_does_not_panic() {
    use crate::element::builders::{Box_, Text};
    let mut tree = crate::retained::RetainedTree::mount(
        Box_::new()
            .opacity(0.5)
            .child(Text::new("hello"))
            .into_element(),
    ).unwrap();
    let layout = crate::layout::layout_pass(
        &mut tree,
        crate::layout::Viewport { width: 400.0, height: 300.0 },
        1.0,
    ).unwrap();
    let mut scene = vello::Scene::new();
    let _stats = crate::paint::paint_pass(&tree, &layout, &mut scene, 1.0);
}
```

- [ ] **Step 2: Run to verify it fails**

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

Expected: FAIL — `.opacity()` builder not found.

- [ ] **Step 3: Add `opacity: f32` to `StyleProps`**

In `src/src/element/style.rs`, change `#[derive(Default)]` to manual impl:

```rust
#[derive(Clone, Debug, 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 opacity: f32,
}

impl Default for StyleProps {
    fn default() -> Self {
        Self {
            width: None, height: None, padding: None, margin: None, gap: None,
            flex_grow: None, flex_shrink: None, align_items: None, justify_content: None,
            align_self: None, focusable: false, cursor: Default::default(), opacity: 1.0,
        }
    }
}
```

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

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

```rust
pub fn opacity(mut self, v: f32) -> Self {
    self.0.style.opacity = v.clamp(0.0, 1.0);
    self
}
```

- [ ] **Step 5: Add opacity layer in `paint/mod.rs`**

At the top of `paint_node` (before painting background/children), check `node.style.opacity`:

```rust
let use_opacity_layer = node.style.opacity < 1.0 - f32::EPSILON;
if use_opacity_layer {
    let r = &rect;  // LayoutRect → vello Rect
    let clip = vello::kurbo::Rect::new(
        r.x as f64, r.y as f64,
        (r.x + r.width) as f64, (r.y + r.height) as f64,
    );
    scene.push_layer(
        vello::peniko::BlendMode::new(
            vello::peniko::Mix::Normal,
            vello::peniko::Compose::SrcOver,
        ),
        node.style.opacity,
        ctx.base,
        &clip,
    );
}

// ... existing paint logic (background, border, text, children) ...

if use_opacity_layer {
    scene.pop_layer();
}
```

Check the exact `push_layer` signature in the current Vello version by looking at `src/src/paint/mod.rs` where `push_layer` is already used for clip overflow (Task 2 of V0.3 plan).

- [ ] **Step 6: Propagate `opacity` through `RetainedNode::style`**

`StyleProps` is already stored in `RetainedNode::style` and propagated via `UpdateStyle` patches — no extra work needed.

- [ ] **Step 7: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all pass.

- [ ] **Step 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 opacity to StyleProps with Vello push_layer in paint pass"
```

---

## Task 8: Animation types + `use_animation` hook

**Files:**
- Create: `src/src/animation.rs`
- Modify: `src/src/runtime/cx.rs`
- Modify: `src/src/lib.rs`

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

Add to `src/src/runtime/cx.rs` tests:

```rust
#[test]
fn use_animation_returns_handle_with_zero_progress() {
    use crate::animation::{AnimationConfig, Easing};
    let cx = Cx::new();
    let handle = cx.use_animation(AnimationConfig {
        duration_ms: 300,
        easing: Easing::EaseOut,
        on_complete: None,
    });
    assert_eq!(handle.progress.get(), 0.0);
}

#[test]
fn use_animation_same_handle_on_re_render() {
    use crate::animation::{AnimationConfig, Easing};
    let cx = Cx::new();
    let h1 = cx.use_animation(AnimationConfig {
        duration_ms: 100, easing: Easing::Linear, on_complete: None,
    });
    h1.progress.set(0.5);
    cx.reset_hooks();
    let h2 = cx.use_animation(AnimationConfig {
        duration_ms: 100, easing: Easing::Linear, on_complete: None,
    });
    assert_eq!(h2.progress.get(), 0.5, "same signal persisted via hook index");
}
```

- [ ] **Step 2: Run to verify they fail**

```bash
cargo test -p lemon --lib runtime::cx::tests 2>&1 | grep "FAILED\|error" | head -10
```

Expected: FAIL — `crate::animation` not found.

- [ ] **Step 3: Create `src/src/animation.rs`**

```rust
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
use std::time::Instant;

use crate::runtime::signal::Signal;

#[derive(Clone)]
pub enum Easing {
    Linear,
    EaseIn,
    EaseOut,
    EaseInOut,
}

impl Easing {
    pub fn apply(&self, t: f32) -> f32 {
        match self {
            Easing::Linear => t,
            Easing::EaseIn => t * t * t,
            Easing::EaseOut => { let u = 1.0 - t; 1.0 - u * u * u }
            Easing::EaseInOut => {
                if t < 0.5 { 4.0 * t * t * t }
                else { 1.0 - (-2.0 * t + 2.0_f32).powi(3) / 2.0 }
            }
        }
    }
}

pub struct AnimationConfig {
    pub duration_ms: u32,
    pub easing: Easing,
    pub on_complete: Option<Rc<dyn Fn()>>,
}

pub struct AnimSlot {
    pub start: Cell<Option<Instant>>,
    pub duration_ms: u32,
    pub easing: Easing,
    pub progress: Signal<f32>,
    pub on_complete: Option<Rc<dyn Fn()>>,
}

impl AnimSlot {
    fn new(config: AnimationConfig) -> Rc<Self> {
        Rc::new(Self {
            start: Cell::new(None),
            duration_ms: config.duration_ms,
            easing: config.easing,
            progress: Signal::new(0.0f32),
            on_complete: config.on_complete,
        })
    }

    /// Called each frame. Returns true if still playing.
    pub fn tick(&self) -> bool {
        let Some(start) = self.start.get() else { return false; };
        let elapsed_ms = start.elapsed().as_millis() as f32;
        let t = (elapsed_ms / self.duration_ms as f32).clamp(0.0, 1.0);
        self.progress.set(self.easing.apply(t));
        if t >= 1.0 {
            if let Some(cb) = &self.on_complete { cb(); }
            self.start.set(None);
            false
        } else {
            true
        }
    }
}

pub type AnimRegistry = Rc<RefCell<Vec<Weak<AnimSlot>>>>;

thread_local! {
    static ANIM_REGISTRY: RefCell<Option<AnimRegistry>> = RefCell::new(None);
}

pub(crate) fn set_anim_registry(registry: AnimRegistry) {
    ANIM_REGISTRY.with(|r| *r.borrow_mut() = Some(registry));
}

fn push_to_registry(slot: &Rc<AnimSlot>) {
    ANIM_REGISTRY.with(|r| {
        if let Some(reg) = r.borrow().as_ref() {
            reg.borrow_mut().push(Rc::downgrade(slot));
        }
    });
}

#[derive(Clone)]
pub struct AnimationHandle {
    pub progress: Signal<f32>,
    pub play: Rc<dyn Fn()>,
    pub reset: Rc<dyn Fn()>,
    pub(crate) slot: Rc<AnimSlot>,
}

impl AnimationHandle {
    pub fn new(config: AnimationConfig) -> Self {
        let slot = AnimSlot::new(config);
        let progress = slot.progress.clone();

        let slot_play = Rc::clone(&slot);
        let play: Rc<dyn Fn()> = Rc::new(move || {
            slot_play.start.set(Some(Instant::now()));
            push_to_registry(&slot_play);
        });

        let slot_reset = Rc::clone(&slot);
        let reset: Rc<dyn Fn()> = Rc::new(move || {
            slot_reset.start.set(None);
            slot_reset.progress.set(0.0);
        });

        Self { progress, play, reset, slot }
    }
}
```

- [ ] **Step 4: Add `use_animation` to `Cx`**

```rust
pub fn use_animation(
    &self,
    config: crate::animation::AnimationConfig,
) -> crate::animation::AnimationHandle {
    let idx = self.index.get();
    self.index.set(idx + 1);
    let mut hooks = self.hooks.borrow_mut();
    if idx < hooks.len() {
        hooks[idx]
            .downcast_ref::<crate::animation::AnimationHandle>()
            .expect("use_animation: hook type mismatch")
            .clone()
    } else {
        let handle = crate::animation::AnimationHandle::new(config);
        hooks.push(Box::new(handle.clone()));
        handle
    }
}
```

- [ ] **Step 5: Register module and re-export in `lib.rs`**

```rust
pub mod animation;
pub use animation::{AnimationConfig, AnimationHandle, Easing};
```

- [ ] **Step 6: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all pass.

- [ ] **Step 7: Commit**

```bash
git add src/src/animation.rs src/src/runtime/cx.rs src/src/lib.rs
git commit -m "feat: add use_animation hook with Easing, AnimationHandle, and Weak slot registry"
```

---

## Task 9: Animation frame loop in `AppState`

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

- [ ] **Step 1: Write the integration test**

Add to `src/src/lib.rs` tests:

```rust
#[test]
fn animation_tick_advances_progress() {
    use crate::animation::{set_anim_registry, AnimRegistry, AnimationConfig, Easing};
    use crate::runtime::cx::Cx;
    use std::cell::RefCell;
    use std::rc::Rc;
    use std::thread::sleep;
    use std::time::Duration;

    let registry: AnimRegistry = Rc::new(RefCell::new(Vec::new()));
    set_anim_registry(Rc::clone(&registry));

    let cx = Cx::new();
    let handle = cx.use_animation(AnimationConfig {
        duration_ms: 100,
        easing: Easing::Linear,
        on_complete: None,
    });
    (handle.play)();

    sleep(Duration::from_millis(60));

    let mut reg = registry.borrow_mut();
    reg.retain(|w| w.upgrade().map(|s| s.tick()).unwrap_or(false));
    drop(reg);

    let p = handle.progress.get();
    assert!(p > 0.3 && p < 1.0, "expected ~0.6 progress after 60ms of 100ms: {p}");
}
```

- [ ] **Step 2: Run to verify it fails**

```bash
cargo test -p lemon --lib tests::animation_tick_advances_progress 2>&1 | tail -10
```

Expected: FAIL — `set_anim_registry` not accessible from `crate::animation`.

Make `set_anim_registry` `pub(crate)` or `pub` in `animation.rs` (it's already `pub(crate)` from the previous task — change to `pub` for the test).

- [ ] **Step 3: Add `anim_registry` to `AppState` and wire up**

In `AppState` (or `WindowState` after Task 10):

```rust
pub anim_registry: crate::animation::AnimRegistry,
```

Initialize in `new`:
```rust
let anim_registry = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
crate::animation::set_anim_registry(std::rc::Rc::clone(&anim_registry));
```

Add `advance_animations` method:
```rust
fn advance_animations(&mut self) -> bool {
    crate::animation::set_anim_registry(std::rc::Rc::clone(&self.anim_registry));
    let mut reg = self.anim_registry.borrow_mut();
    reg.retain(|weak| weak.upgrade().map(|s| s.tick()).unwrap_or(false));
    !reg.is_empty()
}
```

In `update_frame`, after paint:
```rust
if self.advance_animations() {
    if let Some(w) = &self.window { w.request_redraw(); }
}
```

- [ ] **Step 4: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all pass.

- [ ] **Step 5: Commit**

```bash
git add src/src/platform/mod.rs
git commit -m "feat: AppState advances AnimSlots each frame and requests redraw while animations active"
```

---

## Task 10: Multi-window — extract `WindowState` + `HashMap<WindowId, WindowState>`

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

- [ ] **Step 1: Record baseline tests**

```bash
cargo test --workspace 2>&1 | tail -5
```

Note the number of passing tests. After this task the same tests must pass.

- [ ] **Step 2: Define `WindowState` struct**

Extract the per-window fields from `AppState` into a new struct above it:

```rust
struct WindowState {
    window: Arc<Window>,
    surface: RenderSurface<'static>,
    renderer: Renderer,
    scene: Scene,
    runtime: Runtime,
    retained: Option<RetainedTree>,
    layout_map: LayoutMap,
    layout_dirty: bool,
    paint_dirty: bool,
    focus_manager: FocusManager,
    hovered_node_id: Option<taffy::NodeId>,
    active_modifiers: Modifiers,
    last_cursor: Option<(f32, f32)>,
    root_component: Option<RootComponent>,
    mounted: bool,
}
```

- [ ] **Step 3: Move per-window methods to `impl WindowState`**

Move `attach_window`, `ensure_mounted`, `viewport`, `scale_factor`, `update_frame`, `resize_surface`, all `event_pass_*` methods to `impl WindowState`. Methods that need `font_cx` or `render_cx` take them as `&mut` parameters.

- [ ] **Step 4: Rewrite `AppState`**

```rust
pub struct AppState {
    pub render_cx: RenderContext,
    pub font_cx: FontContext,
    pub theme: std::rc::Rc<crate::theme::Theme>,
    pub anim_registry: crate::animation::AnimRegistry,
    initial_config: WindowConfig,
    initial_root: Option<RootComponent>,
    windows: std::collections::HashMap<WindowId, WindowState>,
    pending_opens: Vec<(WindowConfig, RootComponent)>,
}
```

- [ ] **Step 5: Update `ApplicationHandler` implementation**

`resumed`: create the primary `WindowState` and insert into `windows`.

`window_event`: dispatch to `self.windows.get_mut(&window_id)`, passing `&mut self.font_cx`, `&mut self.render_cx`, `&self.theme`, `&self.anim_registry`. After dispatch, drain `pending_opens` (see Task 11).

`CloseRequested`: remove from `windows`; call `event_loop.exit()` if `windows.is_empty()`.

- [ ] **Step 6: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: same tests as baseline pass. No regressions.

- [ ] **Step 7: Commit**

```bash
git add src/src/platform/mod.rs
git commit -m "refactor: extract WindowState from AppState enabling multi-window management"
```

---

## Task 11: `cx.open_window` + close-on-X

**Files:**
- Modify: `src/src/runtime/cx.rs`
- Modify: `src/src/platform/mod.rs`
- Modify: `src/src/lib.rs`

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

Add to `src/src/runtime/cx.rs` tests:

```rust
#[test]
fn open_window_queues_request_to_thread_local_sink() {
    use crate::platform::window::WindowConfig;

    fn second(_cx: &Cx) -> crate::element::Element {
        crate::element::builders::Text::new("second").into_element()
    }

    // Set up a sink
    let sink = crate::runtime::cx::init_open_window_sink();
    let cx = Cx::new();
    cx.open_window(WindowConfig::default().title("Win2"), second);

    let requests = sink.borrow();
    assert_eq!(requests.len(), 1);
    assert_eq!(requests[0].0.title, "Win2");
}
```

- [ ] **Step 2: Run to verify it fails**

```bash
cargo test -p lemon --lib runtime::cx::tests::open_window_queues_request_to_thread_local_sink 2>&1 | tail -10
```

Expected: FAIL.

- [ ] **Step 3: Add thread-local `OPEN_WINDOW_SINK` to `cx.rs`**

```rust
pub type OpenWindowSink =
    std::rc::Rc<std::cell::RefCell<Vec<(crate::platform::window::WindowConfig, crate::platform::RootComponent)>>>;

thread_local! {
    static OPEN_WINDOW_SINK: std::cell::RefCell<Option<OpenWindowSink>> =
        std::cell::RefCell::new(None);
}

pub fn init_open_window_sink() -> OpenWindowSink {
    let sink: OpenWindowSink = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
    OPEN_WINDOW_SINK.with(|s| *s.borrow_mut() = Some(std::rc::Rc::clone(&sink)));
    sink
}
```

Add to `Cx`:

```rust
pub fn open_window(
    &self,
    config: crate::platform::window::WindowConfig,
    root_fn: fn(&Cx) -> crate::element::Element,
) {
    OPEN_WINDOW_SINK.with(|s| {
        if let Some(sink) = s.borrow().as_ref() {
            sink.borrow_mut().push((config, std::sync::Arc::new(root_fn)));
        }
    });
}
```

- [ ] **Step 4: Initialize sink in `AppState` and drain after each frame**

In `AppState::new` (or `new_with_theme`):
```rust
let open_window_sink = crate::runtime::cx::init_open_window_sink();
```

Store `open_window_sink` in `AppState`. In `window_event` after dispatching to `WindowState::handle_event`, drain:

```rust
let pending = std::mem::take(&mut *self.open_window_sink.borrow_mut());
for (config, root) in pending {
    let attrs = winit::window::WindowAttributes::default()
        .with_title(&config.title)
        .with_inner_size(winit::dpi::LogicalSize::new(config.width, config.height))
        .with_resizable(config.resizable);
    if let Ok(w) = event_loop.create_window(attrs) {
        let window = Arc::new(w);
        let ws = WindowState::new(window.clone(), &mut self.render_cx, root);
        self.windows.insert(window.id(), ws);
    }
}
```

- [ ] **Step 5: Re-export nothing new** — `cx.open_window` is called on a `&Cx` which users already have.

- [ ] **Step 6: Run tests**

```bash
cargo test --workspace 2>&1 | tail -20
```

Expected: all pass.

- [ ] **Step 7: Commit**

```bash
git add src/src/runtime/cx.rs src/src/platform/mod.rs
git commit -m "feat: cx.open_window spawns additional windows via thread-local request sink"
```

---

## Task 12: Showcase example

**Files:**
- Modify: `Cargo.toml`
- Create: `examples/showcase/Cargo.toml`
- Create: `examples/showcase/src/main.rs`

- [ ] **Step 1: Add showcase to workspace in `Cargo.toml`**

```toml
[workspace]
members = [
    "src",
    "src/widget",
    "examples/counter",
    "examples/card",
    "examples/list_keyed",
    "examples/showcase",
]
```

- [ ] **Step 2: Create `examples/showcase/Cargo.toml`**

```toml
[package]
name = "showcase"
version.workspace = true
edition.workspace = true

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

- [ ] **Step 3: Create `examples/showcase/src/main.rs`**

```rust
use lemon::{
    animation::{AnimationConfig, Easing},
    element::builders::{Column, Text},
    runtime::cx::Cx,
    theme::Theme,
    Element,
};
use lemon::widget::Button;

fn root(cx: &Cx) -> Element {
    let theme = cx.use_theme();
    let dark_mode = cx.use_signal(false);
    let anim = cx.use_animation(AnimationConfig {
        duration_ms: 400,
        easing: Easing::EaseOut,
        on_complete: None,
    });

    let dark = dark_mode.clone();
    let play = anim.play.clone();
    let label = if dark_mode.get() { "Switch to Light" } else { "Switch to Dark" };

    Column::new()
        .padding(theme.spacing.xl)
        .gap(theme.spacing.lg)
        .background(theme.colors.background)
        .child(
            Text::new("Lemon Showcase")
                .font_size(theme.typography.font_size_xl),
        )
        .child(
            Text::new(format!("Animation: {:.2}", anim.progress.get()))
                .font_size(theme.typography.font_size_md),
        )
        .child(Button::new(label, cx).on_click(move || {
            dark.set(!dark.get());
            (play)();
        }))
        .opacity(0.5 + anim.progress.get() * 0.5)
        .into_element()
}

fn main() {
    lemon::run_with_theme(
        lemon::WindowConfig::default().title("Lemon Showcase").size(800.0, 600.0),
        Theme::default_light(),
        root,
    );
}
```

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

```bash
cargo build -p showcase 2>&1 | tail -20
```

Expected: compiles without errors.

- [ ] **Step 5: Run full suite**

```bash
cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -20
```

Expected: all tests pass, no warnings.

- [ ] **Step 6: Commit**

```bash
git add Cargo.toml examples/showcase/
git commit -m "feat: add showcase example with theme, animation, and theme toggle"
```

---

## Self-Review

**Spec coverage:**

| Spec requirement | Task |
|---|---|
| `Component::new_with_props` | 1 |
| Props equality skip | 2 |
| Fragment: no crash in retained tree | 3 |
| Fragment: diff children correctly | 3 |
| `Theme` struct with all token groups | 4 |
| `use_theme()` reads active theme | 5 |
| `run_with_theme` API | 5 |
| Widgets use theme tokens | 6 |
| `opacity: f32` in StyleProps | 7 |
| Paint opacity via `push_layer` | 7 |
| `use_animation` hook with hook-slot semantics | 8 |
| `AnimationHandle`: `progress`, `play`, `reset` | 8 |
| Easing: Linear, EaseIn, EaseOut, EaseInOut | 8 |
| Frame loop advances AnimSlots | 9 |
| `window.request_redraw()` while animations active | 9 |
| `WindowState` extracted for multi-window | 10 |
| `cx.open_window` creates new window | 11 |
| Close window on X removes from map, exits when last | 11 |
| Showcase example | 12 |
| A11y / AccessKit | **Deferred to V0.6** |

**Placeholder scan:** No TBDs or vague steps found.

**Type consistency:**
- `AnimRegistry = Rc<RefCell<Vec<Weak<AnimSlot>>>>` — Tasks 8, 9 consistent.
- `set_anim_registry(registry: AnimRegistry)` — defined Task 8, called Task 9.
- `set_active_theme(theme: Rc<Theme>)` — defined Task 4, called Task 5.
- `AnyProps::clone_box`, `eq_box`, `as_any` — Tasks 1, 2 consistent.
- `AnimationHandle` fields — consistent across Tasks 8, 9, 12.
- `Button::new(label, cx)` — Tasks 6, 12 consistent.
- `OpenWindowSink = Rc<RefCell<Vec<(WindowConfig, RootComponent)>>>` — Tasks 11 consistent.
- `init_open_window_sink() -> OpenWindowSink` — defined and called in Task 11 only.

