Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

features/accessibility.rs

//! Every accessibility prop in one window, and the fixture the platform
//! audit drives (`scripts/ax-audit.swift` on macOS, or a screen reader by
//! hand). Each control is built so that assistive technology can both
//! read it and change it, and see the change: the button's name counts
//! its presses, the sliders' values follow increment and decrement, and
//! both editors report the text they hold. The two sliders differ in one
//! row: `Volume` declares only its number, so a reader turns the position
//! into a percentage, while `Focus length` also declares a `value_text`
//! and is read as "25 minutes" (backlog F8).
//!
//! Run: cargo run -p kui-native --example accessibility
//!
//! With VoiceOver (⌘F5): VO-right walks the controls, VO-space presses
//! the button, and inside either editor the arrow keys read by character
//! and VO-arrows by word and line.
//!
//! With the keyboard alone (`docs/adr/0002-keyboard-focus-as-data.md`):
//! Tab walks every control in order — the button, the icon button, the
//! switch, the two sliders, the built-in editor, the app-owned editor — with a
//! ring around the focused one; Enter or Space presses a button or flips
//! the switch, the arrows move the slider, Escape lets go. The app-owned
//! editor is a key sink, so it keeps Tab; its declaration below takes
//! focus once, on the first frame, and never clobbers a Tab press.
//!
//! Four things here are **composites**
//! (`docs/adr/0007-composite-keyboard-patterns.md`): the tab list, the
//! theme radio group, the mailbox list and the Actions menu are **one**
//! Tab stop each, not one per item. Tab enters on the item that is
//! selected (or checked, or first), and inside it the arrow keys — both
//! pairs — move the selection, Home and End reach the ends, and typing a
//! name jumps to it. The tab list and the radio group *activate* as focus
//! moves, because that is what those two patterns mean on every platform;
//! the list and the menu leave activation to Enter or Space. Nothing
//! below declares any of that: the core derives a composite from a
//! container role whose items are focusable, so the tab list and the list
//! read exactly as they did before this and behave differently.
//!
//! The tab list reads as "General, tab, 1 of 3, selected" and the
//! disclosure below it as "Advanced, collapsed": `selected` says which of
//! a set is the current one (distinct from a switch being on), `expanded`
//! names a disclosure's state so a shut one can say it is shut, and "1 of
//! 3" is not declared at all — the core numbers the tabs a `tabList`
//! holds.
//!
//! The list under them is the other half of `selected`: a picked row
//! reads as selected where a tab reads as on, and macOS spells those two
//! differently (`AXSelected` against `AXValue`), which is what
//! `scripts/ax-audit.swift` is there to check.
//!
//! "Delete…" opens a modal dialog (`docs/adr/0003-modal-surfaces.md`):
//! Tab cannot leave it, nothing behind it clicks, VoiceOver announces a
//! modal dialog and stays inside it, and Escape or a click outside asks
//! it to close — the app decides, and focus returns to the button that
//! opened it. It opens on Cancel, because Cancel says `initialFocus`:
//! a destructive confirm should not be one habitual Enter away from
//! confirming.

use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{
    Align, App, EditOptions, FloatConfig, Key, Live, NodeSpec, Role, TextStyle, Ui, UiEvent, Value,
};

const DOC: &str = "hello world\nsecond line";

struct A11y {
    presses: u32,
    volume: f32,
    /// Minutes, in [5..60] — the pomodoro report's own range. Its slider
    /// says what the number *reads as*; the volume slider above says only
    /// the number, so the window carries both readings side by side.
    focus_min: f32,
    muted: bool,
    /// The custom editor's document and its caret (line, byte offset):
    /// the app owns the buffer, kui only learns where the caret sits.
    lines: Vec<String>,
    caret: (usize, usize),
    edit: Key,
    /// Whether the confirm dialog is declared this frame. Nothing else:
    /// the modal is the frame that declares it.
    dialog: bool,
    /// Which tab the tab list shows, and whether the disclosure below is
    /// open: `selected` and `expanded` are these two fields, read out.
    tab: usize,
    advanced: bool,
    /// The picked row of the list below — `selected` again, on the other
    /// role that carries it.
    row: usize,
    /// The checked radio of the theme group. `checked`, not `selected`:
    /// a radio is on or off the way a checkbox is, and the *group* is
    /// what makes it one of a set.
    theme: usize,
    /// Whether the Actions menu is declared this frame. Like `dialog`,
    /// nothing else: a menu is a modal float that the app stops
    /// declaring.
    menu: bool,
    /// The live region's message, and how many saves are behind it. This
    /// is state a view reads, like everything else here — the region is
    /// `live`, so a reader hears it *because it changed*, without being
    /// asked and without the app saying "now".
    saves: u32,
    /// The other half: something to say once, with nothing on screen
    /// holding it. `on_event` takes no `Ui`, so the handler leaves it
    /// here and the view announces it and clears it — the guard the core
    /// reports as `announcement-repeated` when an app forgets it
    /// (`docs/adr/0008-live-regions-and-announcements.md`).
    pending: Option<String>,
}

impl A11y {
    fn new() -> Self {
        Self {
            presses: 0,
            volume: 3.0,
            focus_min: 25.0,
            muted: false,
            lines: vec!["fn main() {".into(), "    greet()".into(), "}".into()],
            caret: (1, 4),
            edit: Key::ROOT,
            dialog: false,
            tab: 0,
            advanced: false,
            row: 1,
            theme: 1,
            menu: false,
            saves: 0,
            pending: None,
        }
    }

    /// The line the caret is on, clamped into the document.
    fn caret_line(&self) -> usize {
        self.caret.0.min(self.lines.len().saturating_sub(1))
    }
}

impl App for A11y {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        let text = TextStyle::new(14.0).color(t.fg);
        // The label colour for the hand-built buttons below: readable on
        // whatever `button_spec` actually carries, which is the rule
        // `widgets::button_with` applies. Not `t.on_accent` — none of
        // them declares `accent`, so their background stays the stock
        // blue however the OS's accent is set, and a light accent's
        // black label would land on that blue.
        let on_button =
            widgets::readable_on(widgets::button_spec(&ui.theme(), &ui.metrics()).style.bg);
        // No pad or gap on the root: the scrolling column below owns
        // both, so the scrollbar rides the window edge rather than
        // floating inside a margin.
        ui.open(NodeSpec::column().fill().bg(t.bg));
        // The drawn titlebar is part of the fixture: the audit checks that
        // a titlebar named like the window is read once, not twice.
        widgets::titlebar(ui, "kui — accessibility");

        // Everything but the chrome scrolls. Every control below is in one
        // column, and a column that overflows squeezes its children —
        // squeezed rows are exactly what a fixture must not show when the
        // point of it is that they read correctly. Scrolling keeps them
        // their own size on a window too short for all of them, and gives
        // assistive technology a scroll view it can move (`ScrollIntoView`
        // arrives as an access request and the core applies it).
        //
        // The modal and the latency HUD stay outside it: both are floats,
        // and a float inside a clipping container is clipped by it.
        let mut sink = Key::ROOT;
        ui.with_keyed(
            "content",
            NodeSpec::column().fill().scroll_y().gap(10.0).pad(14.0),
            |ui| {
                // A heading: named by the text inside it, which is then read as
                // part of it rather than as a label of its own.
                ui.text_in(
                    NodeSpec::row().role(Role::Heading),
                    "Controls",
                    TextStyle::new(20.0).color(t.fg),
                );

                // A tab list: `selected` is which one the view shows, and every
                // tab reports the state so a reader can say which is on. Nothing
                // here says "1 of 3" — the core counts what the list holds.
                ui.with_keyed("tabs", NodeSpec::row().role(Role::TabList).gap(4.0), |ui| {
                    for (i, name) in ["General", "Network", "About"].iter().enumerate() {
                        let on = i == self.tab;
                        ui.text_in_keyed(
                            name,
                            NodeSpec::row()
                                .role(Role::Tab)
                                .selected(on)
                                .on_click(Value::Int(i as i64))
                                .pad_xy(10.0, 6.0)
                                .bg(if on { t.accent } else { t.surface })
                                .radius(6.0),
                            name,
                            TextStyle::new(13.0).color(if on { t.on_accent } else { t.muted }),
                        );
                    }
                });

                // A disclosure: `expanded` names its state, so a reader says
                // "collapsed" rather than nothing at all when it is shut.
                ui.text_in_keyed(
                    "advanced",
                    widgets::button_spec(&ui.theme(), &ui.metrics())
                        .expanded(self.advanced)
                        .on_click("advanced")
                        .label("Advanced"),
                    if self.advanced {
                        "▾ Advanced"
                    } else {
                        "▸ Advanced"
                    },
                    TextStyle::new(widgets::BUTTON_TEXT).color(on_button),
                );
                if self.advanced {
                    ui.text_in(
                        NodeSpec::row().pad_xy(10.0, 6.0).bg(t.sunken).radius(6.0),
                        "Nothing here yet.",
                        text,
                    );
                }

                // A radio group: the one pattern whose arrows *must* also check
                // the radio they land on, which is why the group is here at all.
                // The stock group (`docs/adr/0034-stock-controls-over-the-roles.md`)
                // is the `radioGroup` container; each stock `radio` says
                // `checked`, and the group's `row` spec is what tells the
                // platform the set is laid out horizontally. Nothing declares a
                // Tab stop or an arrow key — the group holds focusable radios,
                // and that is a composite.
                ui.text_in(
                    NodeSpec::row().role(Role::Heading),
                    "Theme",
                    TextStyle::new(15.0).color(t.fg),
                );
                widgets::radio_group_with(ui, "Theme", NodeSpec::row().gap(16.0), |ui| {
                    for (i, name) in ["Light", "Dark", "Auto"].iter().enumerate() {
                        widgets::radio(ui, name, i == self.theme, format!("theme{i}"));
                    }
                });

                // A list whose rows can be picked. A row is not named by its
                // content the way a button is — it is a container of content, and
                // giving it a label as well would have it read twice — so its
                // text child is what a reader announces. Nothing here says "2 of
                // 3" either: the core numbers the rows it holds.
                ui.with_keyed(
                    "mailboxes",
                    NodeSpec::column().role(Role::List).gap(2.0),
                    |ui| {
                        for (i, name) in ["Inbox", "Drafts", "Sent"].iter().enumerate() {
                            let on = i == self.row;
                            ui.text_in_keyed(
                                name,
                                NodeSpec::row()
                                    .role(Role::ListItem)
                                    .selected(on)
                                    .focusable()
                                    .on_click(Value::str(format!("row{i}")))
                                    .width(200.0)
                                    .pad_xy(10.0, 5.0)
                                    .bg(if on { t.accent_pressed } else { t.surface })
                                    .radius(4.0),
                                name,
                                TextStyle::new(13.0).color(if on { t.on_accent } else { t.muted }),
                            );
                        }
                    },
                );

                // A button named by its content, so a press is visible through
                // the accessibility API alone. Keyed by hand: `widgets::button`
                // keys a node by its text, and this text changes on every press —
                // a re-keyed node is a new node, which drops keyboard focus and
                // leaves a screen reader's cursor on an element that no longer
                // exists.
                ui.text_in_keyed(
                    "count",
                    widgets::button_spec(&ui.theme(), &ui.metrics()).on_click("press"),
                    &format!("count {}", self.presses),
                    TextStyle::new(widgets::BUTTON_TEXT).color(on_button),
                );

                // An icon button: nothing to read inside, so it needs a label.
                ui.text_in_keyed(
                    "save",
                    widgets::button_spec(&ui.theme(), &ui.metrics())
                        .on_click("save")
                        .label("Save"),
                    "⌘",
                    TextStyle::new(15.0).color(on_button),
                );

                // A one-off announcement: nothing on screen says "Copied", and
                // nothing should — a reader hears it, everyone else sees the
                // button they just pressed. Announced here rather than in
                // `on_event` because `App::on_event` takes no `Ui`; the `take`
                // is the guard, since a view runs every frame.
                if let Some(msg) = self.pending.take() {
                    ui.announce(&msg, Live::Polite);
                }
                widgets::button(ui, "Copy", Value::str("copy"));

                // The live region. A plain box, so without the row it would be
                // elided and its text would be read only when asked for; with it
                // the box stays in the tree as a group, its liveness reaches the
                // text inside, and a reader announces the count each time it
                // changes. `polite` waits for a pause — `assertive` would
                // interrupt, which a save confirmation has not earned.
                ui.text_in_keyed(
                    "status",
                    NodeSpec::row()
                        .live(Live::Polite)
                        .pad_xy(10.0, 5.0)
                        .bg(t.sunken)
                        .radius(4.0),
                    &if self.saves == 0 {
                        "No changes saved".to_string()
                    } else if self.saves == 1 {
                        "Saved 1 change".to_string()
                    } else {
                        format!("Saved {} changes", self.saves)
                    },
                    text,
                );

                // The button that opens the modal below.
                widgets::button(ui, "Delete…", Value::str("open-confirm"));

                // A menu, opened from a button: `modal` plus `role="menu"`, which
                // is the shape a context menu takes. It floats below its trigger,
                // so the plain box around the pair is what the float anchors to —
                // a button is read as one control, and a menu declared inside it
                // would be read as part of that control rather than as a menu.
                // Focus enters it, the arrows move between its items *without*
                // running them (a menu that ran whatever you passed over would be
                // unusable), and Enter, Space or Escape ends it.
                ui.with(NodeSpec::column(), |ui| {
                    widgets::button(ui, "Actions ▾", Value::str("open-menu"));
                    if self.menu {
                        ui.with_keyed(
                            "menu",
                            NodeSpec::column()
                                // Left-aligned under the button and clamped
                                // into the window: `below()` alone centres a
                                // float on its anchor — right for a tooltip,
                                // which is what it is for — so a 180-wide
                                // menu under a 90-wide button near the left
                                // edge starts outside the window, and without
                                // `fit` nothing pulls it back. A menu belongs
                                // under the left edge of the control that
                                // opened it, and `fit` is the in-window
                                // answer ADR 0004 decision 11 keeps: this one
                                // does fit, once it is placed properly.
                                .float(
                                    FloatConfig::below()
                                        .at(Align::Start, Align::End)
                                        .self_at(Align::Start, Align::Start)
                                        .fit(),
                                )
                                .modal("menu")
                                .role(Role::Menu)
                                .label("Actions")
                                .width(180.0)
                                .pad(4.0)
                                .gap(2.0)
                                .bg(t.surface)
                                .border(1.0, t.accent)
                                .radius(6.0),
                            |ui| {
                                for name in ["Rename", "Duplicate", "Archive"] {
                                    ui.text_in_keyed(
                                        name,
                                        NodeSpec::row()
                                            .role(Role::MenuItem)
                                            .on_click(Value::str(format!("menu:{name}")))
                                            .grow_width()
                                            .pad_xy(8.0, 5.0)
                                            .radius(4.0)
                                            .focus_bg(t.accent_soft),
                                        name,
                                        TextStyle::new(13.0).color(t.fg),
                                    );
                                }
                            },
                        );
                    }
                });

                // A switch: `checked` is the state assistive technology reads,
                // and its text is its name.
                widgets::switch(ui, "Mute", self.muted, "mute");

                // A stock slider: the value, the range and the step are data,
                // and every way of moving it — the pointer, the arrows, the
                // Page keys, Home and End, a reader's increment and decrement —
                // arrives as one `change` event proposing the new value.
                let m = ui.metrics();
                widgets::slider(ui, "Volume", self.volume, 0.0, 10.0, 1.0, "volume");

                // The same control, saying what its position *reads as*.
                // With only `value_now` and the range a reader has to
                // invent a reading and says a percentage — 25 in [5..60]
                // is "36 percent", which is what the pomodoro report hit
                // (backlog F8). `value_text` is the reading itself, and it
                // replaces the number rather than joining it. It is not
                // the `label`: the name of the control does not change
                // when its value does.
                // Five minutes a step, which is the step a reader's increment
                // and the arrows both take.
                widgets::slider_with(
                    ui,
                    "Focus length",
                    widgets::slider_spec(&m)
                        .value_now(self.focus_min)
                        .value_min(5.0)
                        .value_max(60.0)
                        .value_step(5.0)
                        .value_text(format!("{} minutes", self.focus_min as i32))
                        .on_change("focus"),
                    None,
                );

                // A built-in editor: the core owns the buffer, so its runs, caret
                // and selection come out of the edit store, and a screen reader's
                // selection and text requests are applied for you.
                ui.text_in(
                    NodeSpec::row().role(Role::Heading),
                    "Built-in editor",
                    TextStyle::new(15.0).color(t.fg),
                );
                self.edit = ui.text_edit(
                    "doc",
                    DOC,
                    &EditOptions {
                        multiline: true,
                        style: text,
                        ..Default::default()
                    },
                    NodeSpec::column()
                        .size(280.0, 56.0)
                        .pad(8.0)
                        .bg(t.sunken)
                        .radius(6.0)
                        .clip()
                        .label("Notes"),
                );

                // An editor the app owns: the sink says what it is, each drawn
                // row is a line of its text, and the caret rides along as a byte
                // offset. Text requests come back as `access` events.
                ui.text_in(
                    NodeSpec::row().role(Role::Heading),
                    "App-owned editor",
                    TextStyle::new(15.0).color(t.fg),
                );
                let (lines, caret) = (&self.lines, self.caret);
                sink = ui.with_keyed(
                    "code",
                    NodeSpec::column()
                        .width(280.0)
                        .pad(8.0)
                        .bg(t.sunken)
                        .radius(6.0)
                        .on_key("code")
                        .role(Role::MultilineTextInput)
                        .label("Source"),
                    |ui| {
                        ui.with(NodeSpec::row().gap(8.0), |ui| {
                            // Decoration: line numbers are not part of the text.
                            ui.with(NodeSpec::column().role(Role::None), |ui| {
                                for i in 0..lines.len() {
                                    ui.text(
                                        &format!("{}", i + 1),
                                        TextStyle::new(12.0).mono().color(t.faint),
                                    );
                                }
                            });
                            ui.with(NodeSpec::column(), |ui| {
                                for (i, line) in lines.iter().enumerate() {
                                    let mut row = NodeSpec::row().role(Role::Line);
                                    if caret.0 == i {
                                        row = row.caret(caret.1.min(line.len()) as u32);
                                    }
                                    ui.text_in_keyed(
                                        &format!("l{i}"),
                                        row,
                                        line,
                                        TextStyle::new(13.0).mono().color(text.color_or_default()),
                                    );
                                }
                            });
                        });
                    },
                );
            },
        );
        ui.take_key_focus(sink);

        // The modal, declared last so it floats over everything. One row
        // makes it modal:
        // focus enters it, Tab cannot leave, nothing behind it takes
        // input, and a screen reader announces a dialog and stays inside.
        if self.dialog {
            ui.with_keyed(
                "confirm",
                NodeSpec::column()
                    .float(FloatConfig::viewport().inside(Align::Center, Align::Center))
                    .modal("confirm")
                    .label("Delete note")
                    .width(260.0)
                    .gap(10.0)
                    .pad(14.0)
                    .bg(t.surface)
                    .border(1.0, t.accent)
                    .radius(8.0),
                |ui| {
                    ui.text("Delete this note?", TextStyle::new(15.0).color(t.fg));
                    ui.with(NodeSpec::row().gap(8.0), |ui| {
                        // Where focus lands when the dialog opens, said
                        // rather than inherited from declaration order: a
                        // destructive confirm opens on its safe option, so
                        // Enter out of habit cancels. Without the row the
                        // ring's first node wins, which is Cancel here only
                        // because Cancel happens to be declared first — and
                        // that is exactly the thing an app should not have
                        // to keep true by hand.
                        ui.text_in_keyed(
                            "Cancel",
                            widgets::button_spec(&ui.theme(), &ui.metrics())
                                .on_click("cancel")
                                .initial_focus(),
                            "Cancel",
                            TextStyle::new(widgets::BUTTON_TEXT).color(on_button),
                        );
                        widgets::button(ui, "Delete", Value::str("delete"));
                    });
                },
            );
        }
        ui.close();
    }

    fn on_event(&mut self, ev: UiEvent) {
        let payload = &ev.payload;
        match payload.as_str() {
            Some("press") => {
                self.presses += 1;
                println!("press -> {}", self.presses);
                return;
            }
            Some("save") => {
                // Changes the live region's text; nothing announces.
                self.saves += 1;
                println!("save -> {}", self.saves);
                return;
            }
            Some("copy") => {
                // Changes nothing on screen, so the announcement is the
                // only thing a reader gets.
                self.pending = Some("Copied to clipboard".to_string());
                println!("copy");
                return;
            }
            // Opening the dialog is a field the view reads; closing it is
            // the same field. The core moves focus into it and hands
            // focus back to this button when it goes away.
            Some("open-confirm") => {
                self.dialog = true;
                return;
            }
            Some("open-menu") => {
                self.menu = true;
                return;
            }
            Some("cancel") => {
                self.dialog = false;
                println!("cancelled");
                return;
            }
            Some("delete") => {
                self.dialog = false;
                println!("deleted");
                return;
            }
            Some("advanced") => {
                self.advanced = !self.advanced;
                println!("advanced -> {}", self.advanced);
                return;
            }
            Some("mute") => {
                self.muted = !self.muted;
                println!("mute -> {}", self.muted);
                return;
            }
            _ => {}
        }
        // A menu item runs and the menu goes away — the app closes it,
        // as it opened it. Prefixed like the rows, for the same reason.
        if let Some(item) = payload.as_str().and_then(|s| s.strip_prefix("menu:")) {
            self.menu = false;
            println!("menu -> {item}");
            return;
        }
        // The radios. Arrow keys reach here too, unchanged: moving focus
        // inside a radio group emits the radio's own click payload, which
        // is the whole of what decision 11 buys an app.
        if let Some(i) = payload
            .as_str()
            .and_then(|s| s.strip_prefix("theme"))
            .and_then(|s| s.parse::<usize>().ok())
        {
            self.theme = i;
            println!("theme -> {}", self.theme);
            return;
        }
        // The rows carry their ordinal in a tagged string, so they do not
        // collide with the tabs' plain indices.
        if let Some(i) = payload
            .as_str()
            .and_then(|s| s.strip_prefix("row"))
            .and_then(|s| s.parse::<usize>().ok())
        {
            self.row = i;
            println!("row -> {}", self.row);
            return;
        }
        // The tabs carry their index as the payload.
        if let Some(i) = payload.as_int() {
            self.tab = i as usize;
            println!("tab -> {}", self.tab);
            return;
        }
        // Escape, or a click outside the dialog: the core asks, the app
        // decides. A dialog holding unsaved work could ask again here.
        if payload.get_str("kind") == Some("dismiss") {
            let reason = payload.get_str("reason").unwrap_or("");
            // Two modals now, so the tag says which one asked to go.
            let which = payload.get_str("tag").unwrap_or("");
            println!("dismiss {which} ({reason})");
            match which {
                "menu" => self.menu = false,
                _ => self.dialog = false,
            }
            return;
        }
        // The sliders: the core proposes a value — stepped, clamped and
        // snapped already — and which slider moved is its own tag. The
        // reading the next frame declares is what a reader announces.
        if payload.get_str("kind") == Some("change") {
            let value = payload.get_float("value").unwrap_or(0.0) as f32;
            match payload.get_str("tag") {
                Some("focus") => {
                    self.focus_min = value;
                    println!("focus length -> {} minutes", self.focus_min as i32);
                }
                _ => {
                    self.volume = value;
                    println!("volume -> {}", self.volume);
                }
            }
            return;
        }
        if payload.get_str("kind") != Some("access") {
            return;
        }
        let action = payload.get_str("action").unwrap_or("");
        match action {
            // The app-owned editor: line ordinals among the rows it drew
            // (all of them here), byte offsets into their text.
            "setTextSelection" => {
                if let Some(f) = payload.get("focus") {
                    let line = f.get_int("line").unwrap_or(0) as usize;
                    let offset = f.get_int("offset").unwrap_or(0) as usize;
                    self.caret = (line, offset);
                    println!("caret -> {line}:{offset}");
                }
            }
            "replaceSelectedText" | "setValue" => {
                let text = payload.get_str("text").unwrap_or("");
                if action == "setValue" {
                    self.lines = text.split('\n').map(String::from).collect();
                    self.caret = (0, 0);
                } else {
                    let line = self.caret_line();
                    let at = self.caret.1.min(self.lines[line].len());
                    self.lines[line].insert_str(at, text);
                    self.caret = (line, at + text.len());
                }
                println!("text -> {:?}", self.lines);
            }
            _ => {}
        }
    }
}

impl Example for A11y {
    /// Tall enough for most of the controls; the rest are a scroll away,
    /// because the column holding them scrolls (see `view`). Custom
    /// chrome, so the drawn titlebar the audit checks is there.
    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default()
            .size(560.0, 820.0)
            .custom_titlebar()
    }

    /// The fixture is the window the audit walks, and nothing else: no
    /// dock, no sink of the harness's, no focus it took. `--dock side`
    /// puts the readout beside it for a look by hand.
    fn dock(&self) -> kui_devtools::Dock {
        kui_devtools::Dock::Off
    }
}

kui_devtools::main!(A11y::new());