git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitc1494e7a4803ca5669b236eb28d01512eb76dec7
parentbbec9777bb
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 11:19
feat(keyboard): a modal Dialog and a RadioGroup (accessibility RFC phase 3)

Dialog is a raised plate (the menu's material) around a lasso of members the
host lays out. Opening it makes it modal through the context
(UiContext::open_modal / close_modal): the Tab walk is trapped among its
members, every widget outside reads as covered so neither a press nor a hover
reaches it, focus moves in and is given back on close, and the members are
linked as its children so a reader sees a modal Dialog node holding them. It
paints its plate, an optional backdrop, and its members on the plate.

RadioGroup is one Tab stop whose arrows move the choice (wrapping, Home /
End); each option is the check box's well at a full corner, the chosen one
holding a lit bead (a disc on a flat host). A reader sees a radio group of
radio buttons, each clickable: widgets can now show parts of themselves as
nodes (Input::a11y_items, a11y::A11yItem, Input::a11y_select_item).

The demo has an Options... dialog with a radio group and OK / Cancel. Checked
in a shadow: Tab cycles its three stops only, a click behind it does nothing,
Escape cancels, Space on OK keeps the choice; over AT-SPI the dialog holds the
group's radio buttons, and a click on one chooses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                        |  19 ++-
 docs/rfc-accessibility-locale.md |  15 ++
 src/a11y.rs                      | 106 +++++++++++-
 src/backend/a11y_unix.rs         |  18 ++
 src/context.rs                   |  75 +++++++-
 src/main.rs                      | 131 +++++++++++++-
 src/widget/container/dialog.rs   | 306 +++++++++++++++++++++++++++++++++
 src/widget/container/mod.rs      |   2 +
 src/widget/input/checkbox.rs     |   2 +-
 src/widget/input/mod.rs          |   2 +
 src/widget/input/radio_group.rs  | 361 +++++++++++++++++++++++++++++++++++++++
 src/widget/mod.rs                |  14 +-
 src/widget/model.rs              |  21 +++
 src/widget/owned.rs              |   2 +
 14 files changed, 1059 insertions(+), 15 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ecfb76f..fda7876 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1189,6 +1189,19 @@ widget does not fit one of them, say so rather than stretching a word.
   within `snap` of the plate's edge take the edge one padding in and corners
   on the plate's corner follow it concentrically: on a narrow pane a group is
   that pane's inset lining, on a wide one a lasso. A group never hits.
+- **A dialog is a raised plate around a lasso** (`widget::Dialog`, since
+  2026-10-08). Like a group it owns nothing: the host lays its members out,
+  and its plate is their padded hull with a title band above, in the menu's
+  material. `open(ctx, members)` makes it MODAL through the context
+  (`UiContext::open_modal`): the Tab walk is trapped among the members, every
+  widget outside reads as covered (`is_coordinate_covered`, which every hit
+  test and hover asks) so nothing behind takes a press, focus moves in and is
+  given back on `close`, and the members are linked as its children, so the
+  accessibility tree nests them under a modal `Dialog` node. Paint the dialog,
+  not its members (it paints them on its plate), after everything it covers;
+  `set_backdrop` dims the window. Escape is the host's to read. Hide the
+  members while it is closed, or they are stops nobody can see. The demo's
+  Options… dialog is the pattern.
 - **Segments are plates or floors sharing one silhouette, parted by seams.**
   A seam is a `Groove` cut across the shared surface, dying into its rolled
   edge: Breadcrumb segments, ButtonStrip segments, the ColorSelector's
@@ -1232,7 +1245,8 @@ What this buys, and where the code is heading:
   A Checkbox and a Toggle light the rim of their field, as every field is
   lit.
   Roles today: Button, Checkbox, Toggle, Dropdown, FontSelector, ButtonStrip
-  (arrows move the selection between its segment plates) and Breadcrumb
+  (arrows move the selection between its segment plates), RadioGroup (one
+  stop; arrows move the choice, which follows them) and Breadcrumb
   (arrows walk its visible segments, Enter navigates) are plates; TextBox,
   Spinbox, ColorSelector, KeybindRecorder, TreeList, Slider (a band, but
   entered and adjusted in place — arrows step it, Enter opens the readout)
@@ -1717,7 +1731,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
   window's `TreeUpdate` — its registered widgets (role, name, value, bounds, actions, focus;
   `WidgetHost::a11y_role` / `a11y_value` are what a widget says about itself), the nodes an
   app without widgets declares (`Application::accessibility`, `AppNodes`), and an open context
-  menu. `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
+  menu; a widget's parts of its own (a radio group's radio buttons) are `A11yItem`s
+  (`Input::a11y_items`). `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
   `docs/rfc-accessibility-locale.md`, phase 2).
 - `ime.rs` — input-method composition shared between the editing widget and the shell:
   `Preedit`, the composition and its generation, the reported caret, the reset request
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index 57fbbda..75af33a 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -188,6 +188,21 @@ cce-list, cce-weather, the demo and cce-relief ring their second stop; cce-fonts
 two stops were its picker buttons, registered outside picker mode and never drawn, and are
 now registered only in it; cce-text-editor has no stops and is unchanged.
 
+**A modal dialog and a radio group (2026-10-08).** `widget::Dialog` is a raised plate (the
+menu's material) around a lasso of members the host lays out; opening it makes it modal
+through the context (`UiContext::open_modal` / `close_modal`): the Tab walk is trapped among
+its members, every widget outside reads as covered so neither a press nor a hover reaches
+it, focus moves in and is given back on close, and a reader sees a modal `Dialog` node
+holding the members. `widget::RadioGroup` is one Tab stop whose arrows move the choice;
+each option is the check box's well at a full corner, the chosen one holding a lit bead.
+To a reader it is a radio group of radio buttons, each clickable, which needed a way for a
+widget to show parts of itself as nodes (`Input::a11y_items`, `a11y::A11yItem`, clicked
+through `Input::a11y_select_item`). The demo's Options… dialog holds a radio group and
+OK / Cancel; in a shadow: Tab cycles its three stops only, a click behind it does nothing,
+Escape cancels, Space on OK keeps the choice; over AT-SPI the dialog holds the group's
+three radio buttons, and a click on one chooses it. The tooltip that doubles as the
+accessible description is not done: no tooltips yet, by decision.
+
 - `plate_navigation` defaults to true; an app that routes Tab itself (a terminal, a web
   view) opts OUT.
 - A modal dialog widget that traps focus, a tooltip that doubles as the accessible
diff --git a/src/a11y.rs b/src/a11y.rs
index 8923ea9..60c0df0 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -41,7 +41,37 @@ pub const WINDOW: NodeId = NodeId(0);
 
 /// The widget a node is, when it is a widget's ([`node_id`]'s inverse).
 pub fn widget_of(node: NodeId) -> Option<WidgetId> {
-    (node.0 > 0 && node.0 < APP_BASE).then(|| WidgetId((node.0 - 1) as usize))
+    (node.0 > 0 && node.0 < ITEM_BASE).then(|| WidgetId((node.0 - 1) as usize))
+}
+
+/// A part of a widget a screen reader sees as a node of its own, under the widget's node:
+/// a radio group's radio buttons (`Input::a11y_items`). A click on it is
+/// `Input::a11y_select_item`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct A11yItem {
+    pub role: Role,
+    pub label: String,
+    /// Where it is, in window px.
+    pub rect: crate::scene::layout::Rect,
+    /// Its checked state, for a radio button or a check item.
+    pub toggled: Option<bool>,
+    /// Whether the keyboard is on it: the widget's focus, given to this item.
+    pub focused: bool,
+}
+
+/// Where widgets' items begin: `ITEM_BASE + (widget id << 16) + index`, below the apps' own
+/// nodes and above every widget's.
+const ITEM_BASE: u64 = 1 << 60;
+
+/// The node of item `idx` of widget `id` ([`A11yItem`]).
+pub fn item_id(id: WidgetId, idx: usize) -> NodeId {
+    NodeId(ITEM_BASE + ((id.0 as u64) << 16) + (idx as u64 & 0xffff))
+}
+
+/// The widget and item a node is, when it is an item's ([`item_id`]'s inverse).
+pub fn item_of(node: NodeId) -> Option<(WidgetId, usize)> {
+    (node.0 >= ITEM_BASE && node.0 < APP_BASE)
+        .then(|| (WidgetId(((node.0 - ITEM_BASE) >> 16) as usize), ((node.0 - ITEM_BASE) & 0xffff) as usize))
 }
 
 /// The context-menu row a node is, when it is one ([`menu_row_id`]'s inverse).
@@ -188,6 +218,8 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
 pub fn key_for(w: &dyn WidgetHost, action: Action) -> Option<NamedKey> {
     let role = role_for(w.type_name(), w.focus_role(), w.a11y_role());
     match (action, role) {
+        // A radio group is clicked through its radio buttons (`A11yItem`), not as a whole.
+        (Action::Click, Role::RadioGroup) => None,
         (Action::Click, _) if w.focus_role() == FocusRole::Plate => Some(NamedKey::Space),
         (Action::Increment, Role::Slider) if w.type_name() != "Slider2D" => Some(NamedKey::ArrowRight),
         (Action::Decrement, Role::Slider) if w.type_name() != "Slider2D" => Some(NamedKey::ArrowLeft),
@@ -255,10 +287,34 @@ pub fn window_tree(ctx: Option<&UiContext>, app: AppNodes, title: &str, scale: f
 
     let mut nodes = Vec::with_capacity(widgets.len() + 1);
     let mut top: Vec<(WidgetId, f32, f32)> = Vec::new();
+    // The keyboard's node: a focused widget's, or its focused item's.
+    let mut item_focus: Option<NodeId> = None;
     for (id, w) in &widgets {
-        let children: Vec<NodeId> =
-            ctx.tree.child_ids(*id).into_iter().filter(|c| shown.contains(c)).map(node_id).collect();
-        nodes.push((node_id(*id), widget_node(*w, children)));
+        // The widget's own items come first, then its linked children.
+        let mut children: Vec<NodeId> = Vec::new();
+        for (i, item) in w.a11y_items().into_iter().enumerate() {
+            let nid = item_id(*id, i);
+            let mut node = Node::new(item.role);
+            node.set_label(item.label);
+            let r = item.rect;
+            node.set_bounds(Rect::new(r.x as f64, r.y as f64, (r.x + r.width) as f64, (r.y + r.height) as f64));
+            if let Some(on) = item.toggled {
+                node.set_toggled(Toggled::from(on));
+            }
+            node.add_action(Action::Click);
+            if item.focused && ctx.focused_widget == Some(*id) {
+                item_focus = Some(nid);
+            }
+            nodes.push((nid, node));
+            children.push(nid);
+        }
+        children.extend(ctx.tree.child_ids(*id).into_iter().filter(|c| shown.contains(c)).map(node_id));
+        let mut node = widget_node(*w, children);
+        // The open modal is said to be one: a reader keeps to it.
+        if ctx.modal_owner() == Some(*id) {
+            node.set_modal();
+        }
+        nodes.push((node_id(*id), node));
         let parented = ctx.tree.parent_id(*id).is_some_and(|p| shown.contains(&p));
         if !parented {
             let (x, y, _, _) = w.rect();
@@ -284,6 +340,7 @@ pub fn window_tree(ctx: Option<&UiContext>, app: AppNodes, title: &str, scale: f
 
     let mut focus = app
         .focus
+        .or(item_focus)
         .or_else(|| ctx.focused_widget.filter(|id| shown.contains(id)).map(node_id))
         .unwrap_or(WINDOW);
     if let Some(menu_focus) = push_context_menu(&mut nodes) {
@@ -356,6 +413,47 @@ mod tests {
     use super::*;
     use crate::widget::{Button, Checkbox, Owned, RangeSlider, Slider, Spinbox, TextBox};
 
+    /// An open dialog is a modal `Dialog` node holding its members; a radio group in it is a
+    /// `RadioGroup` of `RadioButton` items, the chosen one checked and, with the group
+    /// focused, the keyboard's node.
+    #[test]
+    fn a_dialog_is_modal_and_a_radio_group_is_its_radio_buttons() {
+        use crate::widget::{Dialog, RadioGroup};
+        let mut ctx = UiContext::new();
+        let mut size = Owned::new(RadioGroup::new(["Small", "Medium", "Large"]).with_selected(1));
+        size.set_rect(120.0, 120.0, 200.0, 100.0);
+        let mut ok = Owned::new(Button::new(120.0, 240.0, 80.0, 24.0).with_label("OK"));
+        let mut dialog = Owned::new(Dialog::new().with_label("Size"));
+        ctx.register_host(&mut dialog);
+        ctx.register_host(&mut size);
+        ctx.register_host(&mut ok);
+        dialog.open(&mut ctx, vec![size.base().id(), ok.base().id()]);
+
+        let update = tree_update(&ctx, "App", 1.0);
+        let d = node(&update, node_id(dialog.base().id()));
+        assert_eq!((d.role(), d.label(), d.is_modal()), (Role::Dialog, Some("Size"), true));
+        assert_eq!(d.children(), &[node_id(size.base().id()), node_id(ok.base().id())]);
+        assert_eq!(node(&update, WINDOW).children(), &[node_id(dialog.base().id())], "the members hang from it");
+
+        let g = node(&update, node_id(size.base().id()));
+        assert_eq!(g.role(), Role::RadioGroup);
+        assert!(!g.supports_action(Action::Click), "clicked through its buttons");
+        let buttons: Vec<(Option<&str>, Option<Toggled>)> = g
+            .children()
+            .iter()
+            .map(|c| node(&update, *c))
+            .inspect(|b| assert!(b.role() == Role::RadioButton && b.supports_action(Action::Click)))
+            .map(|b| (b.label(), b.toggled()))
+            .collect();
+        assert_eq!(
+            buttons,
+            [(Some("Small"), Some(Toggled::False)), (Some("Medium"), Some(Toggled::True)), (Some("Large"), Some(Toggled::False))]
+        );
+        assert_eq!(update.focus, item_id(size.base().id(), 1), "the dialog's first stop, on its chosen button");
+        assert_eq!(item_of(item_id(size.base().id(), 2)), Some((size.base().id(), 2)));
+        assert_eq!(widget_of(item_id(size.base().id(), 2)), None, "an item is not a widget");
+    }
+
     /// A node offers the actions the Linux adapter can carry out, and each by the key a
     /// keyboard user presses: Space on a plate, Right / Left on a slider or a range,
     /// Up / Down on a spin button, nothing on a text box.
diff --git a/src/backend/a11y_unix.rs b/src/backend/a11y_unix.rs
index c073327..ee66de8 100644
--- a/src/backend/a11y_unix.rs
+++ b/src/backend/a11y_unix.rs
@@ -74,6 +74,8 @@ pub enum Acted {
 ///   (`WidgetHost::a11y_set_value`), marked changed for the app's `take_change`. An app
 ///   that drains changes in `tick` sees it this turn; one that drains them only in its
 ///   input handlers sees it at the next input.
+/// - **Click** on a widget's item (a radio button, `a11y::A11yItem`) does what a press on it
+///   does (`WidgetHost::a11y_select_item`) and puts the keyboard on its widget.
 /// - **Click** on an open context menu's row presses it where it is drawn, so the menu runs
 ///   the row's action exactly as a pointer would.
 ///
@@ -94,6 +96,22 @@ pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
         cm::mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x, y, app.ui_context_mut());
         return Acted::Changed;
     }
+    if let Some((id, idx)) = crate::a11y::item_of(request.target_node) {
+        // A click on a widget's item (a radio button): what a press on it does, and the
+        // keyboard goes to its widget, as after a press.
+        if request.action != Action::Click {
+            return Acted::Nothing;
+        }
+        let Some(ctx) = app.ui_context_mut() else { return Acted::Nothing };
+        if !ctx.get_widget_mut(id).is_some_and(|w| w.a11y_select_item(idx)) {
+            return Acted::Nothing;
+        }
+        if ctx.focused_widget != Some(id) {
+            ctx.set_focused_id(id);
+            app.focus_stepped();
+        }
+        return Acted::Changed;
+    }
     let Some(id) = crate::a11y::widget_of(request.target_node) else { return Acted::Nothing };
     let Some(ctx) = app.ui_context_mut() else { return Acted::Nothing };
     if request.action == Action::SetValue {
diff --git a/src/context.rs b/src/context.rs
index aa200fe..73f78c5 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -63,6 +63,14 @@ fn warn_unowned(type_name: &'static str) {
     }
 }
 
+/// One open modal: who opened it, what is inside it, and where focus was before.
+#[derive(Debug, Clone)]
+struct ModalScope {
+    owner: WidgetId,
+    members: Vec<WidgetId>,
+    restore: Option<WidgetId>,
+}
+
 pub struct UiContext {
     /// The widget tree + registry, consolidated into one generational store (Phase 1b of the
     /// core rebuild). Replaces the former `layout_tree` + `widget_registry` maps; see
@@ -73,6 +81,10 @@ pub struct UiContext {
     pub focused_widget: Option<WidgetId>,
     /// Open-popover registrations, id-keyed like focus (Phase 6bc slice 2).
     pub active_popovers: Vec<WidgetId>,
+    /// Open modals, innermost last ([`UiContext::open_modal`]): while one is open the Tab
+    /// walk visits only its members, and every widget outside it reads as covered, so no
+    /// press or hover reaches what lies behind.
+    modals: Vec<ModalScope>,
     /// Memo for `is_coordinate_covered` at a single cursor position: the ids of
     /// every widget whose popover rect contains it. That query scans the entire
     /// registry, and `hit_test` calls it — so dispatching one PointerMove to N
@@ -115,6 +127,7 @@ impl UiContext {
             tree: crate::scene::WidgetTree::new(),
             focused_widget: None,
             active_popovers: Vec::new(),
+            modals: Vec::new(),
             covered_cache: std::cell::RefCell::new((None, Vec::new())),
             hover_state: HoverState::new(),
             cursor_pos: (0.0, 0.0),
@@ -591,6 +604,59 @@ impl UiContext {
         }
     }
 
+    /// Open a modal owned by `owner` (a `Dialog`) around `members`: the Tab walk is trapped
+    /// among them (and their embedded children), every widget outside reads as covered
+    /// ([`UiContext::is_coordinate_covered`], which every hit test and hover asks), and focus
+    /// moves to the first stop inside, remembering where it was. Modals nest; the innermost
+    /// rules. Opening one already open replaces its members and keeps its focus memory.
+    pub fn open_modal(&mut self, owner: WidgetId, members: Vec<WidgetId>) {
+        self.invalidate_coverage_cache();
+        if let Some(scope) = self.modals.iter_mut().find(|m| m.owner == owner) {
+            scope.members = members;
+            return;
+        }
+        let restore = self.focused_widget;
+        self.modals.push(ModalScope { owner, members, restore });
+        match self.focus_stops().first() {
+            Some(&first) => self.set_focused_id(first),
+            None => self.clear_focus(),
+        }
+    }
+
+    /// Close the modal `owner` opened, giving focus back to what had it before (if that is
+    /// still registered and reachable), else to nothing.
+    pub fn close_modal(&mut self, owner: WidgetId) {
+        let Some(i) = self.modals.iter().position(|m| m.owner == owner) else { return };
+        let scope = self.modals.remove(i);
+        self.invalidate_coverage_cache();
+        let inside = |ctx: &Self, id: WidgetId| ctx.tree.is_registered(id) && ctx.in_modal_scope(id);
+        match scope.restore.filter(|&id| inside(self, id)) {
+            Some(id) => self.set_focused_id(id),
+            None => self.clear_focus(),
+        }
+    }
+
+    /// The owner of the innermost open modal.
+    pub fn modal_owner(&self) -> Option<WidgetId> {
+        self.modals.last().map(|m| m.owner)
+    }
+
+    /// Whether `id` may take input: true with no modal open; with one, true for the modal's
+    /// owner, its members and anything under them in the tree.
+    pub fn in_modal_scope(&self, id: WidgetId) -> bool {
+        let Some(scope) = self.modals.last() else { return true };
+        let mut at = Some(id);
+        // Bounded: a malformed parent chain must not hang the walk.
+        for _ in 0..64 {
+            let Some(cur) = at else { return false };
+            if cur == scope.owner || scope.members.contains(&cur) {
+                return true;
+            }
+            at = self.tree.parent_id(cur);
+        }
+        false
+    }
+
     pub fn clear_if_matches(&mut self, w: &dyn WidgetHost) {
         if self.focused_widget == Some(w.base().id()) {
             self.focused_widget = None;
@@ -613,7 +679,7 @@ impl UiContext {
                 continue;
             }
             let w = unsafe { &*ptr };
-            if w.focus_role() == crate::widget::FocusRole::None || !w.visible() {
+            if w.focus_role() == crate::widget::FocusRole::None || !w.visible() || !self.in_modal_scope(id) {
                 continue;
             }
             let (x, y, width, height) = w.rect();
@@ -957,12 +1023,17 @@ impl UiContext {
     /// Whether `(px, py)` is covered by an open popover or a popover-carrying widget other
     /// than `query_id` (the querying widget excludes itself). Every widget has a base id
     /// now (the flip) — the old `WidgetId(0)` no-base sentinel is gone.
-    /// Is `(px, py)` covered by some widget's popover rect other than `query_id`?
+    /// Is `(px, py)` covered by some widget's popover rect other than `query_id` — or does
+    /// `query_id` lie behind an open modal ([`UiContext::open_modal`]), where everything is?
     ///
     /// The covering set depends only on the point, so it is computed once and
     /// memoized; `query_id` is applied afterwards as an exclusion. See the
     /// `covered_at` field for why the previous per-call registry scan mattered.
     pub fn is_coordinate_covered(&self, query_id: WidgetId, px: f32, py: f32) -> bool {
+        // Behind an open modal everything is covered, wherever the point is.
+        if !self.in_modal_scope(query_id) {
+            return true;
+        }
         let mut cache = self.covered_cache.borrow_mut();
         if cache.0 != Some((px, py)) {
             cache.1.clear();
diff --git a/src/main.rs b/src/main.rs
index 41d22f4..1f90b3d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,7 +15,11 @@
 //!    `UiContext::propagate_event` per widget root. The router owns press hit-gating,
 //!    Enter/Leave synthesis, drag-target recording, and KeyInput-to-focused delivery;
 //!    the app keeps only state-gated `take_*` plumbing.
-//! 4. **No embedded bases.** Widgets are app-owned values (all [`Adapted`]); the window
+//! 4. **A modal dialog is a lasso.** The Options dialog's members are ordinary widgets the app
+//!    owns and lays out; [`Dialog`] is the plate under them, and opening it traps the Tab
+//!    walk among them and covers the window behind (`UiContext::open_modal`). Closed, its
+//!    members are hidden, so they are neither Tab stops nor in the accessibility tree.
+//! 5. **No embedded bases.** Widgets are app-owned values (all [`Adapted`]); the window
 //!    plate is prims, not a root plate container; popovers draw INTO the frame (there is no popup
 //!    surface); app state — not any widget tree — is the source of truth.
 
@@ -27,8 +31,8 @@ use cce_ui::scene::layout::{
 };
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 use cce_ui::widget::{
-    Adapted, Button, Dropdown, ImageView, WidgetHost, WidgetId, ElementState, Event, KeyEvent,
-    MouseButton, MouseScrollDelta, Slider, TextBox, Toggle,
+    Adapted, Button, Dialog, Dropdown, ImageView, RadioGroup, WidgetHost, WidgetId, ElementState, Event,
+    KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Slider, TextBox, Toggle,
 };
 
 #[derive(Debug, Clone)]
@@ -44,6 +48,10 @@ const TITLE_FONT_SIZE: f32 = 15.0;
 const STATUS_FONT_SIZE: f32 = 12.0;
 /// Vertical padding on each side of the status band's text line.
 const STATUS_BAND_PAD: f32 = 5.0;
+/// The Options dialog's choices.
+const TEXT_SIZES: [&str; 3] = ["Small", "Medium", "Large"];
+/// Its buttons' width.
+const DIALOG_BUTTON_W: f32 = 88.0;
 fn text_leaf_height(font_size: f32) -> f32 {
     (font_size * 1.2).ceil()
 }
@@ -61,12 +69,20 @@ pub(crate) struct DemoApp {
     // upload/free stay app-side): Contain letterboxes, Stretch fills.
     image_contain: Owned<Adapted<ImageView>>,
     image_stretch: Owned<Adapted<ImageView>>,
+    // The Options dialog: a button that opens it, the plate, and what stands on it.
+    options_button: Owned<Adapted<Button>>,
+    dialog: Owned<Adapted<Dialog>>,
+    text_size: Owned<Adapted<RadioGroup>>,
+    dialog_cancel: Owned<Adapted<Button>>,
+    dialog_ok: Owned<Adapted<Button>>,
 
     // ── App state: the source of truth. Widgets are re-asserted from it every rebuild
     // (`set_toggled` below); `take_*` changes flow back into it, never the reverse.
     toggle_on: bool,
     clicks: u32,
     status: String,
+    /// The text size chosen in the Options dialog (an index into `TEXT_SIZES`).
+    text_size_choice: usize,
 
     ui_context: cce_ui::context::UiContext,
     width: u32,
@@ -74,6 +90,8 @@ pub(crate) struct DemoApp {
     scale_factor: f64,
     needs_rebuild: bool,
     widgets_registered: bool,
+    /// Set while `open_dialog` lays the members out before the dialog is open.
+    dialog_pending: bool,
     title_rect: Rect,
     status_rect: Rect,
 }
@@ -82,7 +100,9 @@ impl DemoApp {
     /// The widget root ids, in paint order — what the router dispatches over.
     /// `propagate_event` takes a `WidgetId` and resolves it through the registry, so the
     /// event paths need no raw pointers and no unsafe self-alias.
-    fn root_ids(&self) -> [WidgetId; 7] {
+    fn root_ids(&self) -> [WidgetId; 12] {
+        // While the dialog is open the rest are still dispatched to: the context answers
+        // every one of them "covered", so none takes a press or a hover.
         [
             self.button.id(),
             self.toggle.id(),
@@ -91,9 +111,67 @@ impl DemoApp {
             self.theme_dropdown.id(),
             self.image_contain.id(),
             self.image_stretch.id(),
+            self.options_button.id(),
+            self.dialog.id(),
+            self.text_size.id(),
+            self.dialog_cancel.id(),
+            self.dialog_ok.id(),
         ]
     }
 
+    /// The dialog's members, in the order Tab walks them.
+    fn dialog_members(&self) -> Vec<WidgetId> {
+        vec![self.text_size.id(), self.dialog_cancel.id(), self.dialog_ok.id()]
+    }
+
+    /// Lay the dialog's members out in the middle of the window, shown while it is open.
+    fn layout_dialog(&mut self) {
+        let open = self.dialog.inner().is_open() || self.dialog_pending;
+        for w in [&mut *self.text_size as &mut dyn WidgetHost, &mut *self.dialog_cancel, &mut *self.dialog_ok] {
+            w.set_visible(open);
+        }
+        if !open {
+            return;
+        }
+        let gap = cce_ui::layout::control_gap();
+        let group_h = self.text_size.intrinsic_size().map_or(0.0, |s| s.height);
+        let (button_w, button_h) = (DIALOG_BUTTON_W, 28.0);
+        let content_w = 2.0 * button_w + gap;
+        let content_h = group_h + gap * 2.0 + button_h;
+        let (pad, top) = (self.dialog.inner().padding(), self.dialog.inner().headroom());
+        let x = (self.width as f32 - content_w) * 0.5;
+        let y = (self.height as f32 - (top + content_h + pad)) * 0.5 + top;
+        self.text_size.set_rect(x, y, content_w, group_h);
+        let by = y + group_h + gap * 2.0;
+        self.dialog_cancel.set_rect(x, by, button_w, button_h);
+        self.dialog_ok.set_rect(x + button_w + gap, by, button_w, button_h);
+        let window = Rect { x: 0.0, y: 0.0, width: self.width as f32, height: self.height as f32 };
+        self.dialog.set_backdrop(Some(window));
+        self.dialog.fit(&self.ui_context);
+    }
+
+    /// Open the Options dialog on the size the app holds.
+    fn open_dialog(&mut self) {
+        self.text_size.inner_mut().set_selected(self.text_size_choice);
+        self.dialog_pending = true;
+        self.layout_dialog();
+        self.dialog_pending = false;
+        let members = self.dialog_members();
+        self.dialog.open(&mut self.ui_context, members);
+        self.needs_rebuild = true;
+    }
+
+    /// Close it: `keep` takes the choice into the app, else it is dropped.
+    fn close_dialog(&mut self, keep: bool) {
+        if keep {
+            self.text_size_choice = self.text_size.inner().selected();
+            self.status = format!("Text size: {}", TEXT_SIZES[self.text_size_choice]);
+        }
+        self.dialog.close(&mut self.ui_context);
+        self.layout_dialog();
+        self.needs_rebuild = true;
+    }
+
     /// The widget roots as pointers, for the one genuinely pointer-consuming path left:
     /// registration (the registry stores them). The paint walk takes shared borrows.
     /// Register every dispatch root by reference: the registry keeps a pointer to each
@@ -108,12 +186,30 @@ impl DemoApp {
         ctx.register_host(&mut self.theme_dropdown);
         ctx.register_host(&mut self.image_contain);
         ctx.register_host(&mut self.image_stretch);
+        ctx.register_host(&mut self.options_button);
+        ctx.register_host(&mut self.dialog);
+        ctx.register_host(&mut self.text_size);
+        ctx.register_host(&mut self.dialog_cancel);
+        ctx.register_host(&mut self.dialog_ok);
+        self.layout_dialog();
     }
 
     /// `take_*` plumbing: translate widget changes into app state. Runs after any routed
     /// dispatch; every check is STATE-gated, so it does not matter which propagate call
     /// consumed the event (see the KeyInput note in `handle_key_input`).
     fn drain_widget_changes(&mut self) {
+        if self.options_button.take_click() {
+            self.open_dialog();
+        }
+        if self.dialog_ok.take_click() {
+            self.close_dialog(true);
+        }
+        if self.dialog_cancel.take_click() {
+            self.close_dialog(false);
+        }
+        if self.text_size.take_change() {
+            self.needs_rebuild = true;
+        }
         if self.button.take_click() {
             self.clicks += 1;
             self.status = format!("Button clicked {} time(s)", self.clicks);
@@ -187,6 +283,13 @@ impl Application for DemoApp {
             image_stretch: Owned::new(ImageView::new()
                 .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
                 .with_fit(FitMode::Stretch)),
+            options_button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Options…")),
+            dialog: Owned::new(Dialog::new().with_label("Text size")),
+            text_size: Owned::new(RadioGroup::new(TEXT_SIZES).with_selected(1)),
+            dialog_cancel: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel")),
+            dialog_ok: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("OK")),
+            text_size_choice: 1,
+            dialog_pending: false,
             toggle_on: false,
             clicks: 0,
             status: "Ready.".to_string(),
@@ -281,6 +384,7 @@ impl Application for DemoApp {
             let button = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(120.0, CONTROL_H)));
             let toggle = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(64.0, CONTROL_H)));
             let dropdown = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(150.0, CONTROL_H)));
+            let options = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(DIALOG_BUTTON_W, CONTROL_H)));
             let slider = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 24.0)));
             let name_box = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 30.0)));
             // ImageView row: same texture through two fit modes side by side.
@@ -300,6 +404,7 @@ impl Application for DemoApp {
             arena.append_child(controls, button);
             arena.append_child(controls, toggle);
             arena.append_child(controls, dropdown);
+            arena.append_child(controls, options);
             arena.append_child(root, slider);
             arena.append_child(root, name_box);
             arena.append_child(root, images);
@@ -321,6 +426,8 @@ impl Application for DemoApp {
             self.toggle.set_rect(t.x, t.y, t.width, t.height);
             let d = r(dropdown);
             self.theme_dropdown.set_rect(d.x, d.y, d.width, d.height);
+            let o = r(options);
+            self.options_button.set_rect(o.x, o.y, o.width, o.height);
             let s = r(slider);
             self.slider.set_rect(s.x, s.y, s.width, s.height);
             let n = r(name_box);
@@ -331,6 +438,7 @@ impl Application for DemoApp {
             self.image_stretch.set_rect(is.x, is.y, is.width, is.height);
             self.title_rect = r(title);
             self.status_rect = r(status);
+            self.layout_dialog();
 
             self.needs_rebuild = false;
             self.ui_context.rebuild_spatial_grid();
@@ -417,6 +525,7 @@ impl Application for DemoApp {
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_contain, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_stretch, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.theme_dropdown, &mut pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.options_button, &mut pc);
 
         // The dropdown popover — geometry and labels last, on top of everything, exactly
         // where it hit-tests. Labels carry bounds equal to the popover rect: that clips
@@ -428,6 +537,10 @@ impl Application for DemoApp {
             self.theme_dropdown.render_popover(&mut pc);
         }
 
+        // The dialog over everything: its backdrop, its plate, and its members on the plate
+        // (it paints them; they are never painted on their own).
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.dialog, &mut pc);
+
         Some(pc.finish())
     }
 
@@ -537,6 +650,16 @@ impl Application for DemoApp {
             }
         }
 
+        // Escape cancels the open dialog, as its Cancel does.
+        if self.dialog.inner().is_open()
+            && event.state == ElementState::Pressed
+            && event.logical_key == cce_ui::widget::Key::Named(NamedKey::Escape)
+        {
+            self.close_dialog(false);
+            *needs_rebuild = true;
+            return None;
+        }
+
         // KeyInput MUST short-circuit: the router delivers keys to the ctx-focused
         // widget FIRST on every propagate call, so a non-short-circuited chain would
         // hand a typed character to the focused widget once per root (N-time
diff --git a/src/widget/container/dialog.rs b/src/widget/container/dialog.rs
new file mode 100644
index 0000000..d769b06
--- /dev/null
+++ b/src/widget/container/dialog.rs
@@ -0,0 +1,306 @@
+//! `Dialog` — a modal plate around registered widgets (`docs/rfc-accessibility-locale.md`,
+//! phase 3).
+//!
+//! Like a [`Group`](super::Group) it OWNS nothing and lays nothing out: the host lays its
+//! members out where it wants them, and the dialog's plate is their padded hull with a title
+//! band above. What makes it a dialog is what [`Adapted<Dialog>::open`] does to the
+//! [`UiContext`]:
+//!
+//! - **Focus is trapped.** The Tab walk visits only the members (and their embedded
+//!   children), wrapping inside; focus moves to the first of them on open and goes back to
+//!   whatever had it on [`close`](Adapted::<Dialog>::close).
+//! - **What is behind takes nothing.** Every widget outside reads as covered
+//!   (`UiContext::is_coordinate_covered`, which every widget's hit test and hover ask), so
+//!   neither a press nor a hover reaches it; [`set_backdrop`](Adapted::<Dialog>::set_backdrop)
+//!   dims it.
+//! - **A screen reader is told.** The members are linked as the dialog's children, so the
+//!   accessibility tree nests them under a `Dialog` node marked modal, named by the title.
+//!
+//! **Paint the dialog, not its members**: it paints its plate and then each member, so they
+//! stand on it. Paint it after everything it covers. Escape is the host's to read: close the
+//! dialog on it as on Cancel.
+//!
+//! The plate is the menu's material (`Material::menu`, the `style.surface.menu` block): a
+//! dialog is a popover that holds controls instead of rows.
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, Input, Layout, Paint, UiContext, WidgetHost, WidgetId};
+
+/// What dims the window behind an open dialog with a backdrop.
+const SCRIM: [f32; 4] = [0.0, 0.0, 0.0, 0.5];
+
+#[derive(Debug, Clone)]
+pub struct Dialog {
+    members: Vec<WidgetId>,
+    title: Option<String>,
+    /// Plate inset around the members' hull.
+    padding: f32,
+    /// The window rect to dim behind the plate, while open.
+    backdrop: Option<Rect>,
+    open: bool,
+}
+
+impl Dialog {
+    /// A closed dialog titled by its label (`with_label`), at the pane rung's padding.
+    pub fn new() -> Adapted<Dialog> {
+        Adapted::new(Dialog {
+            members: Vec::new(),
+            title: None,
+            padding: crate::layout::plate_padding(),
+            backdrop: None,
+            open: false,
+        })
+    }
+
+    pub fn is_open(&self) -> bool {
+        self.open
+    }
+
+    pub fn members(&self) -> &[WidgetId] {
+        &self.members
+    }
+
+    fn title_font(&self) -> (String, f32) {
+        let (fam, size) = crate::layout::parse_font_string(&crate::layout::section_label_font());
+        (fam, size.unwrap_or(14.0))
+    }
+
+    /// The title band's height — zero without a title.
+    fn title_height(&self) -> f32 {
+        match self.title.as_deref().filter(|t| !t.is_empty()) {
+            Some(_) => self.title_font().1 + crate::layout::control_gap(),
+            None => 0.0,
+        }
+    }
+
+    /// The room the plate takes ABOVE its members: the padding and the title band. A host
+    /// lays the first member out this far below where it wants the plate's top.
+    pub fn headroom(&self) -> f32 {
+        self.padding + self.title_height()
+    }
+
+    /// The padding between the members and the plate's edge.
+    pub fn padding(&self) -> f32 {
+        self.padding
+    }
+
+    /// The plate: the members' hull, padded, with the title band on top. `None` when no
+    /// member is on screen.
+    pub fn plate(&self, ui: &UiContext) -> Option<Rect> {
+        let mut hull: Option<(f32, f32, f32, f32)> = None;
+        for &id in &self.members {
+            let Some(w) = ui.get_widget(id) else { continue };
+            let (x, y, ww, hh) = w.rect();
+            if !w.visible() || ww <= 0.0 || hh <= 0.0 || x < -9000.0 || y < -9000.0 {
+                continue;
+            }
+            let mut grow = |x: f32, y: f32, w: f32, h: f32| {
+                hull = Some(match hull {
+                    None => (x, y, x + w, y + h),
+                    Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h)),
+                });
+            };
+            grow(x, y, ww, hh);
+            if let Some(l) = w.detached_label_rect() {
+                grow(l.x, l.y, l.width, l.height);
+            }
+        }
+        let (x0, y0, x1, y1) = hull?;
+        let (p, top) = (self.padding, self.headroom());
+        Some(Rect { x: x0 - p, y: y0 - top, width: x1 - x0 + 2.0 * p, height: y1 - y0 + top + p })
+    }
+}
+
+impl Adapted<Dialog> {
+    /// Open around `members` (registered widget ids, laid out by the host): link them as
+    /// this dialog's children, trap focus among them and cover everything else (see the
+    /// module doc). Opening an open dialog changes its members and keeps where focus goes
+    /// back to.
+    pub fn open(&mut self, ctx: &mut UiContext, members: Vec<WidgetId>) {
+        let id = self.base().id();
+        for &old in &self.members {
+            ctx.unlink_child(id, old);
+        }
+        for &m in &members {
+            ctx.link_ids(id, m);
+        }
+        self.members = members.clone();
+        self.open = true;
+        self.fit(ctx);
+        ctx.open_modal(id, members);
+    }
+
+    /// Close: unlink the members, release the trap and give focus back.
+    pub fn close(&mut self, ctx: &mut UiContext) {
+        if !self.open {
+            return;
+        }
+        let id = self.base().id();
+        for &m in &self.members {
+            ctx.unlink_child(id, m);
+        }
+        self.open = false;
+        self.backdrop = None;
+        ctx.close_modal(id);
+    }
+
+    /// Take the plate's rect as the widget's own (its hit area and its accessible bounds),
+    /// from where the members now are. Call after laying them out.
+    pub fn fit(&mut self, ctx: &UiContext) {
+        match self.inner().plate(ctx).filter(|_| self.open) {
+            Some(r) => self.set_rect(r.x, r.y, r.width, r.height),
+            None => self.set_rect(0.0, 0.0, 0.0, 0.0),
+        }
+    }
+
+    /// Dim `window` (the window's rect) behind the plate while open; `None` for no dimming.
+    pub fn set_backdrop(&mut self, window: Option<Rect>) {
+        self.inner_mut().backdrop = window;
+    }
+
+    pub fn with_padding(mut self, padding: f32) -> Self {
+        self.inner_mut().padding = padding;
+        self
+    }
+}
+
+impl Layout for Dialog {
+    /// The title is the plate's own band, not a detached control label.
+    fn inline_label(&self) -> bool {
+        true
+    }
+}
+
+impl Paint for Dialog {
+    fn color(&self) -> [f32; 4] {
+        [0.0; 4]
+    }
+
+    fn sync_label(&mut self, label: &str) {
+        self.title = Some(label.to_string());
+    }
+
+    /// It paints its members itself, standing on its plate; the walk must not paint them
+    /// again.
+    fn paints_own_subtree(&self) -> bool {
+        true
+    }
+
+    /// Nothing without the context: the plate is where the members are.
+    fn paint(&self, _rect: Rect, _ctx: &mut PaintCtx) {}
+
+    fn paint_ui(&self, ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
+        if !self.open {
+            return;
+        }
+        if let Some(b) = self.backdrop {
+            ctx.quad(b, SCRIM);
+        }
+        let Some(plate) = self.plate(ui) else { return };
+        let r = crate::layout::menu_corner_radius().min(plate.height * 0.5);
+        let depth = crate::layout::bevel_width().min(plate.height * 0.2);
+        if crate::color::menu_color()[3] > 0.001 {
+            ctx.plate(plate, (r, r, r, r), &crate::scene::material::Material::menu(), depth);
+        } else {
+            let (plateau, radii) = crate::layout::carve_inside(plate, (r, r, r, r), depth);
+            ctx.boss(plateau, radii, depth);
+        }
+        if let Some(title) = self.title.as_deref().filter(|t| !t.is_empty()) {
+            let (fam, size) = self.title_font();
+            let color = crate::color::control_label_color_for_state(false, false);
+            let (x, y) = (plate.x + self.padding, plate.y + self.padding);
+            ctx.text_with(title.to_string(), x, y, size, color, Some(format!("{fam} {size}")), None);
+        }
+        for &id in &self.members {
+            if let Some(w) = ui.get_widget(id) {
+                crate::scene::painter::paint_root_into(ui, w, ctx);
+            }
+        }
+    }
+}
+
+impl Input for Dialog {
+    /// The plate takes a press nothing on it took, so it never falls to what is behind.
+    fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
+        self.open && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height
+    }
+
+    /// A press on the plate is not a drag of the window.
+    fn blocks_root_plate_drag(&self) -> bool {
+        self.open
+    }
+
+    fn a11y_role(&self) -> Option<accesskit::Role> {
+        Some(accesskit::Role::Dialog)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::{Button, Owned, TextBox};
+
+    /// A dialog traps the Tab walk among its members, covers what is behind it, and gives
+    /// focus back on close.
+    #[test]
+    fn a_dialog_traps_focus_covers_the_window_and_gives_focus_back() {
+        let mut ctx = UiContext::new();
+        let mut behind = Owned::new(Button::new(10.0, 10.0, 80.0, 24.0).with_label("Behind"));
+        let mut name = Owned::new(TextBox::new(String::new()).with_label("Name"));
+        name.set_rect(120.0, 120.0, 200.0, 24.0);
+        let mut ok = Owned::new(Button::new(120.0, 160.0, 80.0, 24.0).with_label("OK"));
+        let mut dialog = Owned::new(Dialog::new().with_label("Rename"));
+        ctx.register_host(&mut behind);
+        ctx.register_host(&mut dialog);
+        ctx.register_host(&mut name);
+        ctx.register_host(&mut ok);
+        ctx.set_focused_id(behind.base().id());
+
+        dialog.open(&mut ctx, vec![name.base().id(), ok.base().id()]);
+        assert_eq!(ctx.modal_owner(), Some(dialog.base().id()));
+        assert_eq!(ctx.focused_widget, Some(name.base().id()), "focus moves in");
+        ctx.focus_step(false);
+        assert_eq!(ctx.focused_widget, Some(ok.base().id()));
+        ctx.focus_step(false);
+        assert_eq!(ctx.focused_widget, Some(name.base().id()), "the walk wraps inside, never to Behind");
+        assert!(!behind.hit_test(20.0, 20.0, &ctx), "behind the dialog nothing hits");
+        assert!(ok.hit_test(130.0, 170.0, &ctx), "inside it does");
+
+        let plate = dialog.inner().plate(&ctx).expect("a plate round the members");
+        let (p, top) = (dialog.inner().padding(), dialog.inner().headroom());
+        assert_eq!((plate.x, plate.y), (120.0 - p, 120.0 - top), "the title band is above the members");
+        assert_eq!(dialog.rect(), (plate.x, plate.y, plate.width, plate.height), "opening fits it");
+        assert_eq!(ctx.tree.child_ids(dialog.base().id()), vec![name.base().id(), ok.base().id()]);
+
+        dialog.close(&mut ctx);
+        assert_eq!(ctx.modal_owner(), None);
+        assert_eq!(ctx.focused_widget, Some(behind.base().id()), "focus goes back");
+        assert!(behind.hit_test(20.0, 20.0, &ctx));
+        assert!(ctx.tree.child_ids(dialog.base().id()).is_empty(), "the members are unlinked");
+    }
+
+    /// The dialog paints its plate, then its members on it.
+    #[test]
+    fn a_dialog_paints_its_plate_then_its_members() {
+        use crate::scene::paint::Prim;
+        let mut ctx = UiContext::new();
+        let mut ok = Owned::new(Button::new(120.0, 160.0, 80.0, 24.0).with_label("OK"));
+        let mut dialog = Owned::new(Dialog::new().with_label("Rename"));
+        ctx.register_host(&mut dialog);
+        ctx.register_host(&mut ok);
+        let mut pc = PaintCtx::new();
+        crate::scene::painter::paint_root_into(&ctx, &*dialog, &mut pc);
+        assert!(pc.finish().items.is_empty(), "closed, it paints nothing");
+
+        dialog.open(&mut ctx, vec![ok.base().id()]);
+        let mut pc = PaintCtx::new();
+        crate::scene::painter::paint_root_into(&ctx, &*dialog, &mut pc);
+        let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
+        let texts: Vec<&str> = prims
+            .iter()
+            .filter_map(|p| if let Prim::Text { text, .. } = p { Some(text.as_str()) } else { None })
+            .collect();
+        assert_eq!(texts, ["Rename", "OK"], "the title, then the member on the plate");
+    }
+}
diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs
index d4314be..caaa728 100644
--- a/src/widget/container/mod.rs
+++ b/src/widget/container/mod.rs
@@ -7,6 +7,7 @@ pub mod spreadsheet;
 pub mod scroll_box;
 pub mod paginator;
 pub mod treelist;
+pub mod dialog;
 pub mod group;
 
 pub use container_layout::{ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout, ColumnsLayout, MosaicLayout, ReverseMosaicLayout};
@@ -18,4 +19,5 @@ pub use spreadsheet::{SheetColumn, Spreadsheet};
 pub use scroll_box::ScrollBox;
 pub use paginator::Paginator;
 pub use treelist::{TreeList, TreeElement};
+pub use dialog::Dialog;
 pub use group::{Group, GroupFrame};
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index b209aa3..fad06df 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -29,7 +29,7 @@ const PLATE_SHARE: f32 = 0.6;
 /// plate in it ([`Checkbox::box_plate`]): both fields with relief on; off,
 /// as the toggle's — a well is its frame, the hairline every well falls back
 /// to (lit by `hovered` / `focused`), and the plate a lit face in it.
-fn paint_box(ctx: &mut PaintCtx, well: &Field, plate: Option<&Field>, hovered: bool, focused: bool) {
+pub(crate) fn paint_box(ctx: &mut PaintCtx, well: &Field, plate: Option<&Field>, hovered: bool, focused: bool) {
     if crate::layout::control_relief() {
         ctx.field(well);
         if let Some(plate) = plate {
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index b3d315a..af80a86 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -1,5 +1,6 @@
 pub mod button;
 pub mod checkbox;
+pub mod radio_group;
 pub mod slider;
 pub mod slider2d;
 pub mod spinbox;
@@ -16,6 +17,7 @@ pub mod ramp_preview;
 
 pub use button::{Button, ButtonKind, PageButton};
 pub use checkbox::{Checkbox, Toggle};
+pub use radio_group::RadioGroup;
 pub use slider::{Slider, RangeSlider, ActiveThumb};
 pub use slider2d::Slider2D;
 pub use spinbox::Spinbox;
diff --git a/src/widget/input/radio_group.rs b/src/widget/input/radio_group.rs
new file mode 100644
index 0000000..cc53912
--- /dev/null
+++ b/src/widget/input/radio_group.rs
@@ -0,0 +1,361 @@
+//! `RadioGroup` — one of several options, chosen (`docs/rfc-accessibility-locale.md`,
+//! phase 3).
+//!
+//! Each option is the check box's well at a full corner (the DE's superellipse, so a
+//! squircle rather than a disc) with a lit bead, a sphere, standing in the chosen one, and
+//! its label after it — in a column, or a row with [`Adapted<RadioGroup>::with_horizontal`].
+//! The bead is round where the check box's plate is square, which is what tells the two
+//! apart at a glance (a flat host, which carries no sphere, draws it as a disc).
+//!
+//! **The keyboard.** The group is ONE stop in the Tab walk, a plate, as every set of
+//! exclusive choices is; the arrows move the choice (Down / Right the next, Up / Left the one
+//! before, wrapping; Home / End the ends), and the choice follows them — there is no option
+//! that has focus without being chosen. A press on an option chooses it.
+//!
+//! **A screen reader** sees a radio group whose children are radio buttons, the chosen one
+//! checked and focused while the group is, each one clickable
+//! ([`Input::a11y_items`] / [`Input::a11y_select_item`]).
+
+use crate::scene::layout::{Rect, Size};
+use crate::scene::paint::{Field, PaintCtx};
+use crate::widget::{ElementState, Event, Key, MouseButton, NamedKey};
+use crate::widget::model::EventCtx;
+use crate::widget::{colors, Adapted, Input, Layout, Paint};
+
+/// The gap between an option's box and its label.
+const LABEL_GAP: f32 = 8.0;
+/// The chosen option's bead, as a share of its well's side.
+const BEAD_SHARE: f32 = 0.42;
+/// The bead's body (linear): lighter than the well it stands in.
+const BEAD: [f32; 4] = [0.42, 0.45, 0.56, 1.0];
+
+#[derive(Debug, Clone)]
+pub struct RadioGroup {
+    options: Vec<String>,
+    selected: usize,
+    horizontal: bool,
+    focused: bool,
+    just_changed: bool,
+}
+
+impl RadioGroup {
+    /// A column of `options`, the first chosen.
+    pub fn new<S: Into<String>>(options: impl IntoIterator<Item = S>) -> Adapted<RadioGroup> {
+        Adapted::new(RadioGroup {
+            options: options.into_iter().map(Into::into).collect(),
+            selected: 0,
+            horizontal: false,
+            focused: false,
+            just_changed: false,
+        })
+    }
+
+    pub fn options(&self) -> &[String] {
+        &self.options
+    }
+
+    /// The chosen option's index.
+    pub fn selected(&self) -> usize {
+        self.selected
+    }
+
+    /// Choose option `idx` (clamped). Not reported as a change: the host set it.
+    pub fn set_selected(&mut self, idx: usize) {
+        self.selected = idx.min(self.options.len().saturating_sub(1));
+    }
+
+    /// Replace the options, keeping the choice where it still exists.
+    pub fn set_options<S: Into<String>>(&mut self, options: impl IntoIterator<Item = S>) {
+        self.options = options.into_iter().map(Into::into).collect();
+        self.set_selected(self.selected);
+    }
+
+    /// One option's height: a toggle's, the check box's side.
+    fn row_h() -> f32 {
+        crate::layout::toggle_height()
+    }
+
+    fn gap() -> f32 {
+        crate::layout::control_gap()
+    }
+
+    fn label_width(label: &str) -> f32 {
+        let (fam, size) = crate::layout::control_label_font_parsed();
+        crate::widget::display::measure_text_width(label, &fam, size)
+    }
+
+    /// Each option's rect (its box and its label) in `rect`.
+    pub fn option_rects(&self, rect: Rect) -> Vec<Rect> {
+        let (h, gap) = (Self::row_h(), Self::gap());
+        let mut x = rect.x;
+        self.options
+            .iter()
+            .enumerate()
+            .map(|(i, label)| {
+                if self.horizontal {
+                    let w = h + LABEL_GAP + Self::label_width(label);
+                    let r = Rect { x, y: rect.y, width: w, height: h };
+                    x += w + gap * 2.0;
+                    r
+                } else {
+                    Rect { x: rect.x, y: rect.y + i as f32 * (h + gap), width: rect.width, height: h }
+                }
+            })
+            .collect()
+    }
+
+    /// The option under (`px`, `py`).
+    fn option_at(&self, rect: Rect, px: f32, py: f32) -> Option<usize> {
+        self.option_rects(rect)
+            .iter()
+            .position(|r| px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height)
+    }
+
+    /// An option's box: the check box's well, round.
+    pub fn ring(option: Rect) -> Field {
+        let side = option.height.max(1.0);
+        let square = Rect { x: option.x, y: option.y, width: side, height: side };
+        let depth = crate::layout::bevel_width().min(side * 0.2);
+        let r = side * 0.5;
+        let (outline, _) = crate::layout::carve_inside(square, (r, r, r, r), depth);
+        // Round at the carved outline's own size, whatever the carve did to the corner.
+        let r = outline.width.min(outline.height) * 0.5;
+        Field::well(outline, (r, r, r, r), depth)
+    }
+
+    fn choose(&mut self, idx: usize) -> bool {
+        if idx >= self.options.len() || idx == self.selected {
+            return false;
+        }
+        self.selected = idx;
+        self.just_changed = true;
+        true
+    }
+}
+
+impl Adapted<RadioGroup> {
+    /// Lay the options out in a row instead of a column.
+    pub fn with_horizontal(mut self, horizontal: bool) -> Self {
+        self.inner_mut().horizontal = horizontal;
+        self
+    }
+
+    pub fn with_selected(mut self, idx: usize) -> Self {
+        self.inner_mut().set_selected(idx);
+        self
+    }
+}
+
+impl Layout for RadioGroup {
+    fn inline_label(&self) -> bool {
+        true
+    }
+
+    fn intrinsic_size(&self) -> Option<Size> {
+        let (h, gap, n) = (Self::row_h(), Self::gap(), self.options.len() as f32);
+        if self.horizontal {
+            let w: f32 = self.option_rects(Rect { x: 0.0, y: 0.0, width: 0.0, height: h }).iter().map(|r| r.width).sum::<f32>()
+                + gap * 2.0 * (n - 1.0).max(0.0);
+            Some(Size::new(w, h))
+        } else {
+            Some(Size::new(0.0, n * h + (n - 1.0).max(0.0) * gap))
+        }
+    }
+}
+
+impl Paint for RadioGroup {
+    fn color(&self) -> [f32; 4] {
+        [0.0; 4]
+    }
+
+    fn widget_font(&self) -> Option<String> {
+        Some(crate::layout::control_label_font())
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let (_, font_size) = crate::layout::control_label_font_parsed();
+        for (i, (option, label)) in self.option_rects(rect).into_iter().zip(&self.options).enumerate() {
+            let chosen = i == self.selected;
+            let lit = chosen && self.focused;
+            let ring = Self::ring(option).with_tint(lit.then(crate::scene::paint::ControlPlate::focus_tint));
+            super::checkbox::paint_box(ctx, &ring, None, false, lit);
+            if chosen {
+                let o = ring.rect;
+                let (cx, cy, r) = (o.x + o.width * 0.5, o.y + o.height * 0.5, o.width.min(o.height) * BEAD_SHARE * 0.5);
+                // The disc first, for a flat host that drops the sphere; the sphere covers it.
+                ctx.circle(cx, cy, r, BEAD);
+                ctx.sphere(cx, cy, r, &crate::scene::material::Material::from_fill(BEAD));
+            }
+            let ty = crate::layout::align_text_y(option.y, option.height, font_size, 0.0);
+            let lx = option.x + option.height + LABEL_GAP;
+            ctx.text_with(
+                label.clone(),
+                lx,
+                ty,
+                font_size,
+                colors::control_label_color_for_state(false, lit),
+                None,
+                Some([option.x, option.y, rect.x + rect.width.max(option.width), option.y + option.height]),
+            );
+        }
+    }
+}
+
+impl Input for RadioGroup {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Plate
+    }
+
+    fn a11y_role(&self) -> Option<accesskit::Role> {
+        Some(accesskit::Role::RadioGroup)
+    }
+
+    fn a11y_items(&self, rect: Rect) -> Vec<crate::a11y::A11yItem> {
+        self.option_rects(rect)
+            .into_iter()
+            .zip(&self.options)
+            .enumerate()
+            .map(|(i, (r, label))| crate::a11y::A11yItem {
+                role: accesskit::Role::RadioButton,
+                label: label.clone(),
+                rect: r,
+                toggled: Some(i == self.selected),
+                focused: self.focused && i == self.selected,
+            })
+            .collect()
+    }
+
+    fn a11y_select_item(&mut self, idx: usize) -> bool {
+        self.choose(idx)
+    }
+
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
+                // Already hit-gated by the adapter; a press between options chooses nothing.
+                match self.option_at(ectx.rect, *x, *y) {
+                    Some(i) => {
+                        self.choose(i);
+                        true
+                    }
+                    None => false,
+                }
+            }
+            Event::FocusIn => {
+                self.focused = true;
+                true
+            }
+            Event::FocusOut => {
+                self.focused = false;
+                true
+            }
+            Event::KeyInput(key_event) => {
+                if !self.focused || key_event.state != ElementState::Pressed || self.options.is_empty() {
+                    return false;
+                }
+                let n = self.options.len();
+                let target = match key_event.logical_key {
+                    Key::Named(NamedKey::ArrowDown) | Key::Named(NamedKey::ArrowRight) => (self.selected + 1) % n,
+                    Key::Named(NamedKey::ArrowUp) | Key::Named(NamedKey::ArrowLeft) => (self.selected + n - 1) % n,
+                    Key::Named(NamedKey::Home) => 0,
+                    Key::Named(NamedKey::End) => n - 1,
+                    // Space / Enter: the focused option is already the chosen one.
+                    Key::Named(NamedKey::Space) | Key::Named(NamedKey::Enter) => return true,
+                    _ => return false,
+                };
+                self.choose(target);
+                true
+            }
+            _ => false,
+        }
+    }
+
+    fn take_change(&mut self) -> bool {
+        std::mem::take(&mut self.just_changed)
+    }
+
+    /// The chosen option's label.
+    fn value_string(&self) -> Option<String> {
+        self.options.get(self.selected).cloned()
+    }
+
+    /// Choose by label, or by index.
+    fn set_value_string(&mut self, val: &str) -> bool {
+        let val = val.trim();
+        let idx = self.options.iter().position(|o| o == val).or_else(|| val.parse::<usize>().ok());
+        idx.is_some_and(|i| self.choose(i))
+    }
+
+    fn value(&self) -> i32 {
+        self.selected as i32
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::{UiContext, WidgetHost};
+
+    fn key(k: NamedKey) -> Event {
+        Event::KeyInput(crate::widget::KeyEvent {
+            logical_key: Key::Named(k),
+            state: ElementState::Pressed,
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+            alt: false,
+        })
+    }
+
+    /// The arrows move the choice, wrapping, and only while focused; a press chooses the
+    /// option under it.
+    #[test]
+    fn the_arrows_and_a_press_choose_an_option() {
+        let mut ctx = UiContext::new();
+        let mut g = RadioGroup::new(["Small", "Medium", "Large"]);
+        WidgetHost::set_rect(&mut g, 10.0, 10.0, 200.0, 200.0);
+        assert!(!g.handle_event(&key(NamedKey::ArrowDown), &mut ctx), "unfocused: not its key");
+        g.handle_event(&Event::FocusIn, &mut ctx);
+        g.handle_event(&key(NamedKey::ArrowDown), &mut ctx);
+        assert_eq!(g.inner().selected(), 1);
+        assert!(g.take_change());
+        g.handle_event(&key(NamedKey::ArrowUp), &mut ctx);
+        g.handle_event(&key(NamedKey::ArrowUp), &mut ctx);
+        assert_eq!(g.inner().selected(), 2, "wraps to the last");
+        g.handle_event(&key(NamedKey::Home), &mut ctx);
+        assert_eq!(g.inner().selected(), 0);
+
+        let third = g.inner().option_rects(Rect { x: 10.0, y: 10.0, width: 200.0, height: 200.0 })[2];
+        let (px, py) = (third.x + 4.0, third.y + third.height * 0.5);
+        g.handle_event(
+            &Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, local_x: px, local_y: py },
+            &mut ctx,
+        );
+        assert_eq!(g.inner().selected(), 2, "a press on the third chooses it");
+        assert_eq!(g.get_value_string().as_deref(), Some("Large"));
+    }
+
+    /// A reader sees a radio group of radio buttons, the chosen one checked; a click on one
+    /// chooses it.
+    #[test]
+    fn a_reader_sees_and_chooses_radio_buttons() {
+        let mut g = RadioGroup::new(["Small", "Medium"]);
+        WidgetHost::set_rect(&mut g, 0.0, 0.0, 200.0, 80.0);
+        let items = g.a11y_items();
+        assert_eq!(items.len(), 2);
+        assert_eq!((items[0].role, items[0].label.as_str(), items[0].toggled), (accesskit::Role::RadioButton, "Small", Some(true)));
+        assert_eq!(items[1].toggled, Some(false));
+        assert!(g.a11y_select_item(1));
+        assert_eq!(g.inner().selected(), 1);
+        assert!(g.take_change());
+        assert!(!g.a11y_select_item(1), "already chosen: no change");
+    }
+
+    /// Every option's box is round: the check box's well at a full corner.
+    #[test]
+    fn an_option_is_a_round_well() {
+        let ring = RadioGroup::ring(Rect { x: 0.0, y: 0.0, width: 200.0, height: 24.0 });
+        assert!((ring.radii.0 * 2.0 - ring.rect.width).abs() < 1e-3 && ring.rect.width == ring.rect.height);
+    }
+}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 174edf7..0f78eca 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -602,6 +602,16 @@ pub trait WidgetHost {
         false
     }
 
+    /// The parts a screen reader sees as nodes of their own (`Input::a11y_items`).
+    fn a11y_items(&self) -> Vec<crate::a11y::A11yItem> {
+        Vec::new()
+    }
+
+    /// An assistive tool clicked one of them (`Input::a11y_select_item`).
+    fn a11y_select_item(&mut self, _idx: usize) -> bool {
+        false
+    }
+
     fn corner_radii(&self) -> CornerRadii {
         let (r, (tl, tr, br, bl)) = self.corner_style();
         CornerRadii::new(
@@ -677,14 +687,14 @@ pub use self::owned::Owned;
 pub use self::core::{Widget, focus, hover_animation, clipboard, context_menu, clear_widget_references};
 pub use self::core::focus::link_parent_child;
 pub use self::input::{
-    Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, Slider, RangeSlider,
+    Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, RadioGroup, Slider, RangeSlider,
     ColorSelector, Finger, Trackpad, get_font_db, ActiveThumb, FontSelector,
     BevelPreview, bevel_ease, parse_bevel_knobs, RampPreview,
     ButtonStrip, KeybindRecorder, Ramp, RampKey, ColorRamp, ColorRampKey,
     format_ramp_spec, parse_ramp_spec
 };
 pub use self::container::{
-    Group, GroupFrame,
+    Dialog, Group, GroupFrame,
     ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,
     ColumnsLayout, MosaicLayout, ReverseMosaicLayout,
     ContentBg, ParametersBg,
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 935f1d0..1be8af0 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -498,6 +498,19 @@ pub trait Input {
         None
     }
 
+    /// The parts of this widget a screen reader should see as nodes of their own, under the
+    /// widget's node: a radio group's radio buttons. `rect` is the widget's content rect.
+    /// Default none.
+    fn a11y_items(&self, _rect: Rect) -> Vec<crate::a11y::A11yItem> {
+        Vec::new()
+    }
+
+    /// An assistive tool clicked item `idx` of [`a11y_items`](Input::a11y_items): do what a
+    /// press on it does, reported as a change. Returns whether anything changed.
+    fn a11y_select_item(&mut self, _idx: usize) -> bool {
+        false
+    }
+
     /// Set the value an assistive tool asked for (AT-SPI's `SetCurrentValue`), clamped to
     /// [`a11y_range`](Input::a11y_range), and mark it changed as a typed value would be, so
     /// the host's `take_change` reports it. Returns whether it changed. Default: not
@@ -1329,6 +1342,14 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
     fn a11y_set_value(&mut self, value: f64) -> bool {
         Input::a11y_set_value(&mut self.inner, value)
     }
+
+    fn a11y_items(&self) -> Vec<crate::a11y::A11yItem> {
+        Input::a11y_items(&self.inner, self.content_rect())
+    }
+
+    fn a11y_select_item(&mut self, idx: usize) -> bool {
+        Input::a11y_select_item(&mut self.inner, idx)
+    }
     fn solid_border(&self) -> Option<([f32; 4], f32)> {
         Paint::solid_border(&self.inner)
     }
diff --git a/src/widget/owned.rs b/src/widget/owned.rs
index 97a865b..f55f524 100644
--- a/src/widget/owned.rs
+++ b/src/widget/owned.rs
@@ -159,6 +159,8 @@ impl<W: WidgetHost + 'static> WidgetHost for Owned<W> {
     fn a11y_value(&self) -> Option<String> { self.widget.a11y_value() }
     fn a11y_range(&self) -> Option<(f64, f64, f64)> { self.widget.a11y_range() }
     fn a11y_set_value(&mut self, value: f64) -> bool { self.widget.a11y_set_value(value) }
+    fn a11y_items(&self) -> Vec<crate::a11y::A11yItem> { self.widget.a11y_items() }
+    fn a11y_select_item(&mut self, idx: usize) -> bool { self.widget.a11y_select_item(idx) }
     fn corner_radii(&self) -> CornerRadii { self.widget.corner_radii() }
 }