Named containers automatically scope their children's IDs, producing unique hierarchical paths without manual prefixing. This is how you distinguish "the delete button in file A" from "the delete button in file B" because the container's ID becomes part of the path.

Scoping rules

Node typeCreates scope?Notes
Named container (explicit ID)YesID pushed onto scope chain
Auto-ID container (auto: prefix)NoTransparent, no scope effect
Window node (type: "window")YesAppended to end of scope list
Custom widgetNoWidget IDs are transparent to scoping

User-provided IDs must not contain /. The slash is reserved for the scope separator; Plushie.Tree.normalize/1 raises ArgumentError on violation.

ID resolution

During normalisation, each named container pushes its ID onto the scope chain. Descendant IDs are prefixed with the full scope path, joined by /:

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

Resolution is recursive; nesting depth is unlimited. Internally, the scope is tracked as a forward-order string (e.g. "sidebar/form") and prepended to each child's ID during the normalisation pass.

Auto-ID containers are transparent

Layout containers with auto-generated IDs (column, row, stack, etc.) do not create scopes. This means you can wrap content in layout containers freely without affecting the ID hierarchy:

container "form" do
  column spacing: 8 do          # auto-ID, no scope effect
    text_input("email", "")     # scoped as "form/email", not "form/auto:.../email"
    button("save", "Save")      # scoped as "form/save"
  end
end

This is intentional. Intermediate layout containers exist for visual arrangement, not semantic grouping. Only named containers that you give an explicit ID create scope boundaries.

Custom widgets are transparent

Custom widget IDs do not create scopes. When a custom widget's view/2 or view/3 renders children, those children inherit the parent's scope, not the widget's:

# If MyWidget has ID "my-widget" and renders a button "save":
container "form" do
  MyWidget.new("my-widget", label: "Submit")
end

# The button inside MyWidget gets scoped as "form/save"
# NOT "form/my-widget/save"

This means custom widgets are invisible to the scope chain. Events from widgets inside a custom widget carry the enclosing container's scope, not the custom widget's ID. The widget's handle_event/2 callback intercepts events before they reach update/2, providing the encapsulation layer instead.

Duplicate ID detection

Normalisation detects duplicate sibling IDs (two children of the same parent with the same ID) and raises ArgumentError:

** (ArgumentError) duplicate sibling IDs detected during normalize: ["save"]

Detection is sibling-scoped. The same local ID can exist in different scopes safely. The scope prefix ensures global uniqueness:

container "form-a" do
  button("save", "Save")   # "form-a/save"
end

container "form-b" do
  button("save", "Save")   # "form-b/save", no conflict
end

Dynamic IDs

IDs can be any string expression, including dynamic values from your model. This is how you scope list items:

for file <- model.files do
  container file do
    button("select", file)
    button("delete", "x")
  end
end

Each file becomes a scope. The delete button for "hello.ex" gets the wire ID "hello.ex/delete". In the event, you extract the filename from the scope:

def update(model, %WidgetEvent{type: :click, id: "delete", scope: [file | _]}) do
  delete_file(file)
end

Dynamic IDs follow the same rules as static IDs: no / characters, and no duplicates among siblings.

Event scope field

When the renderer emits a widget event, the wire ID is the full scoped path (e.g. "sidebar/form/save"). The SDK splits it into id (local) and scope (reversed ancestor chain, nearest parent first, window ID last):

%WidgetEvent{type: :click, id: "save", scope: ["form", "sidebar", "main"], window_id: "main"}

The scope is reversed so you can pattern match on the immediate parent with [parent | _] without knowing the full ancestry. The window ID is always the last element in the scope list, giving you the full hierarchy from innermost container to outermost window.

The window_id field remains on the event struct for direct access. You can use either approach:

# Via scope (window is last element)
%WidgetEvent{scope: [_form, _sidebar, window_id]} = event

# Via dedicated field
%WidgetEvent{window_id: window_id} = event

Pattern matching examples

# Local ID only (any scope)
def update(model, %WidgetEvent{type: :click, id: "save"}), do: ...

# Immediate parent match (window_id at end doesn't affect [parent | _])
def update(model, %WidgetEvent{type: :click, id: "save", scope: ["form" | _]}), do: ...

# Bind dynamic parent (list items)
def update(model, %WidgetEvent{type: :toggle, id: "done", scope: [item_id | _]}), do: ...

# Match window via scope
def update(model, %WidgetEvent{id: "save", window_id: "settings"}), do: ...

# Top-level widget (only window in scope)
def update(model, %WidgetEvent{id: "save", scope: [window_id]}), do: ...

Only Plushie.Event.WidgetEvent and Plushie.Event.ImeEvent carry scope. Other subscription events (KeyEvent, ModifiersEvent) are global and unscoped. Pointer subscription events (mouse/touch) are delivered as WidgetEvent with id set to the window ID and scope set to [].

Path reconstruction

Plushie.Event.target/1 reconstructs the full forward-slash path from an event's id and scope fields. The window ID is automatically stripped from the scope since it is not part of the container path:

Plushie.Event.target(%WidgetEvent{id: "save", scope: ["form", "sidebar", "main"], window_id: "main"})
# => "sidebar/form/save"

Canvas element scoping

Canvas elements participate in the same mechanism. The canvas widget's ID creates a scope, and interactive group IDs within it are scoped under it:

canvas "drawing"              ->  "drawing"
  group "handle" ...          ->  "drawing/handle"

Canvas element clicks are regular :click events with the canvas ID in scope (and window ID at the end):

%WidgetEvent{type: :click, id: "handle", scope: ["drawing", "main"], window_id: "main"}

Command paths

Commands that target widgets by path use the forward-slash scoped format:

Command.focus("form/email")
Command.scroll_to("sidebar/list", 0)

In multi-window apps, commands can target a specific window using the window_id#path syntax:

Command.focus("settings#email")
Command.scroll_to("main#sidebar/list", 0)

The # separates the window ID from the widget path. Without a window qualifier, the command targets whatever window contains the widget.

Multi-window scoping

The window ID is part of the scope chain (always the last element). Each window creates a separate namespace. The widget handler registry keys entries by {window_id, scoped_id}. A widget with scoped ID "form/save" in window "main" is a different registry entry from "form/save" in window "settings".

Events from a widget in window "main" carry scope: ["form", "main"], window_id: "main". In multi-window apps, you can pattern match on the window via window_id:

def update(model, %WidgetEvent{id: "save", window_id: "settings"}) do
  save_settings(model)
end

Events from one window never trigger handlers in another.

Test selectors

Test helpers (find/1, click/1, etc.) accept #-prefixed ID selectors with full scoped paths:

find!("#save")                     # local ID
click("#sidebar/form/save")        # full scoped path
assert_text("#form/email", "")     # scoped assertion

In multi-window apps, selectors can include a window qualifier using the window_id#widget_path syntax:

click("main#save")                 # "save" in window "main"
find!("settings#form/email")       # scoped path in window "settings"
assert_text("main#count", "3")     # assertion scoped to a window

The # separates the window ID from the widget path. Without a window qualifier (i.e. "#save"), the selector searches all windows. An ambiguous match across windows raises an error. Use the window qualifier or the window: option to disambiguate.

The test backend resolves IDs against the normalised tree.

Accessibility cross-references

A11y props (labelled_by, described_by, error_message) reference widget IDs. Bare IDs are resolved relative to the current scope during normalisation. An ID already containing / passes through unchanged:

text_input("email", model.email,
  a11y: %{labelled_by: "email-label"}  # resolves to "form/email-label" inside "form"
)

See also