# Scoped Widget IDs

Named containers automatically scope the IDs of their children. This
gives every widget a unique, hierarchical identity that reflects where
it lives in the tree, without requiring the developer to manually
construct long prefixed strings.

## How it works

When `Tree.normalize/1` processes a node with an explicit (non-auto) ID,
it pushes that ID onto a scope chain. All descendant nodes have their
IDs prefixed with the scope path, joined by `/`.

```
sidebar (container)        ->  id: "sidebar"
  form (container)         ->  id: "sidebar/form"
    email (text_input)     ->  id: "sidebar/form/email"
    save (button)          ->  id: "sidebar/form/save"
```

### What creates scope

- **Named containers** -- any node with an explicit ID (not starting
  with `auto:`) pushes its ID onto the scope chain.

### What does NOT create scope

- **Auto-ID containers** -- IDs starting with `auto:` (generated by
  the builder layer) pass through without adding scope.
- **Window nodes** -- `type: "window"` nodes never scope, since windows
  are logical boundaries, not containment boundaries.

### Slash validation

User-provided IDs cannot contain `/`. Attempting to normalize a tree
with a manually slashed ID raises an `ArgumentError`. The `/` separator
is reserved for the scoping mechanism.

## Events

When the renderer emits an event for a widget, the wire ID is the full
scoped path (e.g. `"sidebar/form/save"`). The protocol decode layer
splits it into a local `id` and a `scope` list:

```elixir
%Widget{type: :click, id: "save", scope: ["form", "sidebar"]}
```

The `scope` list is in **reverse order** -- nearest parent first. This
design optimises the common case of matching on the immediate parent:

<!-- test: scoped_ids_match_local_id_test, scoped_ids_match_immediate_parent_test, scoped_ids_dynamic_list_bind_parent_test -- keep this code block in sync with the test -->
```elixir
# Match on local ID only (ignores scope entirely)
def update(model, %Widget{type: :click, id: "save"}), do: ...

# Match on ID + immediate parent
def update(model, %Widget{type: :click, id: "save", scope: ["form" | _]}), do: ...

# Bind the parent for dynamic lists
def update(model, %Widget{type: :toggle, id: "done", scope: [item_id | _]}) do
  toggle_item(model, item_id)
end
```

### Reconstructing the full path

Use `Plushie.Event.target/1` to get the full forward-order path:

```elixir
event = %Widget{type: :click, id: "save", scope: ["form", "sidebar"]}
Plushie.Event.target(event)
# => "sidebar/form/save"
```

## Which event structs carry scope

- `Plushie.Event.Widget`
- `Plushie.Event.Canvas`
- `Plushie.Event.MouseArea`
- `Plushie.Event.Pane`
- `Plushie.Event.Sensor`

Subscription events (Key, Mouse, Touch, IME, Modifiers) are global and
do not carry scope.

## Tree search

`Tree.find/2` and `Tree.exists?/2` support both full scoped paths and
local IDs:

```elixir
# Exact match on scoped path
Tree.find(tree, "sidebar/form/save")

# Falls back to local ID match (last segment)
Tree.find(tree, "save")
```

Exact matches take priority. Local ID fallback only triggers when the
target doesn't contain `/` and no exact match was found.

## Test selectors

Test helpers (`find/1`, `click/1`, etc.) accept both local and scoped
selectors:

```elixir
# Local ID -- resolved against the current tree
find("#save")
click("#save")

# Full scoped path
find("#sidebar/form/save")
```

The backends resolve local IDs to their full scoped path using the
Elixir-side tree before sending queries to the renderer.

## Dynamic lists

When rendering a list of items, wrap each item in a named container
(row, column, container) using the item's ID. The container creates
a scope for the item's children, giving each instance unique IDs
without manual prefixing.

<!-- test: scoped_ids_dynamic_list_delete_test -- keep this code block in sync with the test -->
```elixir
# View
column "todo_list" do
  for item <- model.items do
    row item.id do
      checkbox("done", item.completed)
      button("delete", "X")
    end
  end
end
```

This produces IDs like `"todo_list/item_1/done"` and
`"todo_list/item_2/delete"`. In `update/2`, bind the item ID
from the scope:

```elixir
def update(model, %Widget{type: :toggle, id: "done", scope: [item_id | _]}) do
  toggle_item(model, item_id)
end

def update(model, %Widget{type: :click, id: "delete", scope: [item_id | _]}) do
  delete_item(model, item_id)
end
```

The `| _` ignores the `"todo_list"` scope above, so the same
pattern works if the list is moved to a different part of the tree.

## Pattern matching tips

The reversed scope list is designed for ergonomic pattern matching:

<!-- test: scoped_ids_depth_agnostic_test, scoped_ids_exact_depth_test, scoped_ids_no_scope_test -- keep this code block in sync with the test -->
```elixir
# Depth-agnostic: works whether "search" is at root or deeply nested
def update(model, %Widget{id: "query", scope: ["search" | _]}), do: ...

# Exact depth: only matches if "search" is the only scope ancestor
def update(model, %Widget{id: "query", scope: ["search"]}), do: ...

# No scope: only matches unscoped widgets
def update(model, %Widget{id: "save", scope: []}), do: ...
```

## Accessibility cross-references

The a11y props `labelled_by`, `described_by`, and `error_message`
reference other widgets by ID. During normalization, these references
are automatically resolved relative to the current scope:

```elixir
container "form" do
  text("name_label", "Name:")
  text_input("name", model.name, a11y: %{labelled_by: "name_label"})
end
```

On the wire, `labelled_by` becomes `"form/name_label"`, matching
the scoped ID of the label widget. References that already contain
`/` (full paths) pass through unchanged.

## Inspect output

All scoped event structs implement the `Inspect` protocol to show
the full path in debug output:

```
#Widget<:click "sidebar/form/save">
#Widget<:input "email" value="test@example.com">
#Canvas<:press "panel/drawing" x=42 y=100>
#Sensor<:resize "content/measure" 800x600>
```

Use `Plushie.Event.target/1` to get the same string programmatically.
