Accessibility (Screen Reader) Support Design: Kirikiri Z Integration¶
Status: Phases A and B implemented (2026-10-04). Character-, word- and line-level reading of input fields (Elements Phase 5) is also in. Verified on real hardware on Windows (SDL3 / WINVER), macOS and Linux. iOS / Android (Phase D) were added on 2026-10-22 (iOS verified on the Simulator, Android on a device; the plan and verification record is TODO-a11y-mobile.md at the repository root, in Japanese). The "Usage" section below describes the actual interface. The sections after it are design-time records, and some details differ from the implementation (the differences are listed under "Usage"). The Elements-side design is in external/elements/docs/accessibility.md (referred to in this document as the "core design"). This document covers only the krkrz-side integration and the REPL / Agent extensions. Paths are relative to src/core/.
Usage guide: Screen reader. A working example is the core demo data/a11y.
API reference: ElementsDialog (announce / setGameA11y / a11yLayers and more), Agent (a11yTree / a11yLog / a11yAction), Layer (members you add to a Layer).
Usage (implemented)¶
| Interface | Description |
|---|---|
| Build | KRKRZ_USE_A11Y (ON by default for regular builds on Windows / macOS / Linux / iOS / Android; the wrappers can turn it off: "a11y": false in krkrz_ios's ios-config.json, or removing "a11y" from krkrz_android's cmake.plugins). Links Elements' elements_a11y_accesskit and defines KRKRZ_HAS_A11Y. Even when OFF, getting the tree, performing actions and the speech log still work (nothing is exposed to the OS) |
| Window | Attached to the main window on the first PaintOverlay (for the main window's device). The SDL3 build uses accesskit_host::attach_sdl; WINVER calls attach on the HWND (both via SetWindowSubclass, attached after the window is shown). On iOS it attaches to the SDL window's root view and re-attaches when SDL replaces that view with a GL / Metal one (SyncA11y calls refresh_native_view every frame). On Android there is no window to attach to: the wrapper's surface View receives the queries on the UI thread and passes them on through sdl3/environ/A11yAndroid.cpp. Does nothing until a screen reader connects |
| Game slot | Placed when the OS interface is opened (permanent). It sits below dialogs and is hidden while a modal dialog is shown. An empty live region (status) is placed from the start, and announce changes its content (screen readers may not read a live region that appears later already containing text) |
| Dialogs | Each ElementsDialogManager instance becomes a slot. Stacking order, modality and position (present_scale / present_off_x/y) are synchronized at the end of PaintOverlay. The screen JSON's "a11y" (core design §4) takes effect as is |
ElementsDialog.announce(text[, assertive]) |
Makes the screen reader speak (via the live region of the frontmost dialog, or the game slot's live region if there is no dialog). assertive=true requests an interruption (how it is handled depends on the screen reader). Passing the same text repeatedly still reads it. Accepted even when no screen reader is connected (the latest state is delivered once one connects) |
ElementsDialog.setGameA11y(nodes[, focus]) |
Puts the game's own UI (choices / menus / settings screens drawn on Layers) into the accessibility tree. See "Game nodes" below |
ElementsDialog.clearGameA11y() |
Removes all game nodes (the slot and live region remain) |
ElementsDialog.onGameA11yAction(id, action, arg) |
Event that receives AT actions on game nodes (assign a function from script) |
ElementsDialog.a11yLayers |
Automatically puts the main window's Layers into the accessibility tree (default false). See "Automatic Layers" below |
ElementsDialog.a11yActive |
Whether an OS screen reader or similar is connected (read-only). Becomes true when a screen reader first queries the tree (on Windows, when the window comes to the front / gains focus; still false right after startup) |
ElementsDialog.onA11yActiveChanged(active) |
Called when a11yActive changes (assign a function from script; called outside rendering, and immediately even on a static WINVER screen). Do not cache the "read the text if connected" decision; use this event or check a11yActive right before speaking |
ElementsDialog.a11yMode |
"auto" (default) / "off" (not exposed to the OS) |
ElementsDialog.a11yLabel |
Name of the accessibility tree root (the window). If empty, the name of the frontmost screen |
Agent.a11yTree() |
The accessibility tree (JSON string). {"dialogs":[{"index","screen","modal","tree"}],"game":{"hidden","tree"}}. game is the game slot (null if none; hidden indicates whether it is hidden under a modal dialog) |
Agent.a11yLog([since]) |
The speech log %[lines, next]. Accumulated only while the REPL is running. announce calls are recorded here too |
Agent.a11yAction(node, action[, arg]) |
Performs an action through the same path as a screen reader (click / focus / increment / decrement / set_value). Searches from the frontmost dialog, and also searches game nodes if there is no modal dialog |
| REPL | .a11y / .a11ylog [N] / .a11ydo <node> <action> [arg] / .say <text> (common to file / web / socket / console) |
Input fields¶
Dialog input fields (input_box / text_box) can be read by character, word and line by a screen reader, and caret movement can be followed (UIA TextPattern / AT-SPI Text and EditableText / macOS AXSelectedTextRange and AXStringForRange). The screen reader can also move the caret and selection. Nothing is required from script.
- When only the caret moves without the value changing, the speech log shows
[caret] x((line end)at the end of a line,(end)at the end of the text); when text is selected, it shows[selected] <text>. - IME composition strings (not yet committed) are not handled. The OS IME itself conveys the composing text and candidates to the screen reader, so exposing them from the application as well would make them read twice. Input fields hold only committed text (core design §6.5).
- The game's
EditLayer(a11yLayers) andtext_inputinsetGameA11yonly expose the value; there is no character-level reading.
Game nodes (setGameA11y)¶
UI drawn on Layers is outside Elements, so the script passes a table of nodes. Each call replaces the whole set, and the engine computes the differences.
ElementsDialog.onGameA11yAction = function(id, action, arg) {
// action: "click" / "focus" / "increment" / "decrement" / "set_value" (arg が値)
// focus を受けたら、 ゲーム側のフォーカスを動かして setGameA11y を呼び直す
};
ElementsDialog.setGameA11y([
%[ id:"choices", role:"list", name:"選択肢" ],
%[ id:"c1", role:"list_item", name:"北へ行く", parent:"choices", rect:[100,400,600,48] ],
%[ id:"c2", role:"list_item", name:"南へ行く", parent:"choices", rect:[100,460,600,48] ],
%[ id:"vol", role:"slider", name:"音量", value:"50%", num_value:50, num_min:0, num_max:100 ],
], "c1");
| Key | Description |
|---|---|
id |
Required. A unique string. Agent.a11yAction / .a11ydo also refer to nodes by this id |
role |
button / toggle_button / check_box / radio_button / tab / menu_item / list / list_item / slider / spin_button / text_input / label / heading / image / status / group (group if omitted) |
name / value / description |
Name / value / supplementary description to be read |
states |
An array or "focusable,checked". focusable / disabled / checked / selected / expanded / read_only. focused is determined by the second argument |
rect |
[x, y, w, h] in primary layer coordinates. Optional (a node without it gets the rectangle enclosing its children). The engine converts for window scaling and letterboxing |
parent |
The parent's id. Top level if omitted |
num_value / num_min / num_max / num_step |
Numeric values for slider / spin_button |
- Available actions are determined by the role. Pressable roles (button / check_box / list_item ...) get click; slider / spin_button get increment / decrement / set_value; text_input gets set_value. All accept focus, and a
disablednode accepts only focus. - AT actions arrive at
onGameA11yActionoutside rendering (called on the main thread regardless of which thread they come from). The engine does not move the focus or change values on its own. The script changes the game state and callssetGameA11yagain. - Stacking order: the game slot is below dialogs. However, when no dialog is receiving keys and the focus is on the game side, it is raised to the front (because the screen reader takes the focus from the topmost slot). It is hidden while a modal dialog is shown.
- Main window only.
Automatic Layers (a11yLayers)¶
Setting ElementsDialog.a11yLayers = true traverses the main window's primary layer and its descendants and adds nodes to the game slot. The default is false (behavior is unchanged unless you opt in). If the default is ever changed to true, users who build their trees with setGameA11y without using this feature must be notified in advance.
Included:
- Layers in the focus chain (
focusableandjoinFocusChain). The name ishint; the role is button (CheckBoxLayeris check_box,EditLayeris text_input) - Layers that have
a11yNameora11yRole(they need not be focusable; useful for headings and descriptive text) - Layers that draw an ElementsPanel. The widgets in the panel are placed beneath it, and actions are delivered to the panel
Excluded: Layers whose visible is false and their children. Layers whose a11yHidden is true and their children (set it on layers you expose yourself via setGameA11y).
Members you add to a Layer (all optional):
| Member | Description |
|---|---|
a11yName / a11yRole / a11yValue / a11yDescription |
Name / role (same names as in setGameA11y) / value / supplementary description |
a11yStates |
An array or "checked,selected". disabled is also derived from enabled |
a11yHidden |
If true, the whole subtree is excluded |
onA11yAction(action, arg) |
Receives AT actions. Returning false also runs the default handling |
- Default handling: focus calls
layer.focus(); click focuses the layer and then sends the Enter key (keyboard-operable widgets can be pressed this way). Value changes (increment / set_value, etc.) are received only throughonA11yAction. - Focus:
window.focusedLayer. However, when a focus is passed tosetGameA11y, that takes precedence. - Order:
setGameA11ynodes come first, followed by the automatic Layers (in Layer stacking order). The id islayer:<Layer.name>(duplicate names get#2,#3...). - Read frequency: at most once every 150 ms (immediately when the focused Layer changes). Reading happens only while an AT is connected or while the REPL speech log is being collected, and is done outside rendering (in the continuous event). If you implement
a11y*as property getters, keep them lightweight.Agent.a11yTree/Agent.a11yActionre-read on the spot. - On WINVER, while an AT is connected, the continuous event keeps running for this re-reading (it also wakes up when idle).
Changes since the design:
- The TJS interface is not a new
Accessibilityclass but static members of the existingElementsDialog(in the same form aslanguageand others). - WINVER also leaves
TTVPWindowForm::Procuntouched and subclasses the HWND afterward (Elements'accesskit_host::attach). - Coordinates: coordinates on the drawing surface (the renderer's surface) are converted to OS units by the ratio between the surface and the window's actual size. On macOS this is points ×
backingScaleFactor(2 on Retina even if the SDL window is not high-DPI; Elements'accesskit_host::native_scale); on Linux, window coordinates. - The Phase B game source was split into two:
setGameA11y, where the script passes a node table, anda11yLayers, which reads the Layer focus chain automatically (including ElementsPanel). Phase C has not been started. The KAG3 extension ("KAG integration" below) will not be done (because KAG3 itself is not going to be extended further; KAG-based titles support accessibility by callingsetGameA11y/announcefrom their own scripts).
Verified (automatic Layers): on Windows (SDL3 / WINVER) / macOS / Linux, we verified focus-chain Layers, overriding with a11yName / a11yRole, exclusion via a11yHidden and invisibility, ElementsPanel contents, combined use with setGameA11y (ordering and focus precedence), and click (Enter) and onA11yAction from the AT / Agent.a11yAction.
Verified: on both the SDL3 and WINVER builds, we opened data/elements_gallery and checked the tree (names, roles, values, states) with an external UI Automation client, as well as the REPL's .a11ydo / .say / .a11ylog. For game nodes, we likewise verified the tree and coordinates (including letterbox), UIA Invoke / SelectionItem.Select / RangeValue.SetValue → onGameA11yAction, hiding while a modal is shown, announce when there is no dialog, and operation on a WINVER screen whose rendering has stopped.
1. Current state (investigation results)¶
- There is no accessibility, UIA or TTS code. No WndProc handles
WM_GETOBJECT; it falls through to DefWindowProc. - All Elements UI is drawn offscreen.
- For
ElementsDialog,tTVPElementsDialogManagermanagesoverlay_sessions in z-order, andPaintOverlay(common/visual/elements/ElementsDialogManager.cpp) composites them onto the game frame. ElementsPaneldraws into a Layer's bitmap.- The only exception is
showModalJson(json,title,w,h), which creates a dedicated OS window (SDL:SDLElementsModalRunner.cpp; WINVER:ModalWndProcinWinElementsModalRunner.cpp). - Hooks into the native window:
- WINVER:
TTVPWindowForm::Proc(win32/environ/WindowFormUnit.cpp) callsDeliverMessageToReceiverfirst.WM_GETOBJECTcan be caught here. - SDL3: the window is created in
sdl3/environ/form.cpp. The HWND can be obtained viaSDL_PROP_WINDOW_WIN32_HWND_POINTER(there is a precedent in the same file). The properties for macOS / Linux are unused. - The existing introspection of internal structures is insufficient as a substitute for a tree.
Agent.dialogs()/Agent.dialogTree(i)useoverlay_session::list_widgets(). They return only a flat(id,type,value)for widgets that have anid, with no names, rectangles or hierarchy.- To be replaced by the core design's
a11y_snapshot(). - Layers have a complete focus chain consisting of
focusable/joinFocusChain/focused/hint/onFocus. KAG'sButtonLayer/MessageLayerlinks /CheckBoxLayer/EditLayeruse it. This is in scope for Phase B. - The REPL is
tTVPReplThread::ProcessLineincommon/utils/REPL.cpp, shared by the file / web / socket / console channels..dlg/.clickare implemented by rewriting the input intoAgent.*calls.
2. Architecture¶
┌──────────── tTVPAccessibility (新規, common/visual/accessibility/) ─────────────┐
OS AT ⇄ accesskit_host (Elements L3) │
│ slot 0 : ゲーム本体 source (tTVPGameA11ySource: 場面名・Layer フォーカス連鎖) │
│ slot 1..N : ElementsDialogManager の各 overlay_session (z 順, modal 連動) │
└──────────────────────────────────────────────────────────────────────────────┘
TJS: Accessibility クラス (announce / mode / active / onActive)
REPL: Agent.a11yTree / a11yLog / a11yAction, .a11y / .a11ylog / .say
(In the diagram: slot 0 is the game source (tTVPGameA11ySource: scene name and Layer focus chain); slots 1..N are each overlay_session of ElementsDialogManager (in z-order, tied to modality).)
KRKRZ_USE_A11Y(default: same asKRKRZ_USE_ELEMENTS) is linked to Elements'ELEMENTS_A11Y_ACCESSKIT. OFF for the LIB variant, NX and similar.- Even when OFF,
Agent.a11yTree()alone works with Elements L0–L2 (so it can be tested without AccessKit).
3. Attaching to the window¶
| Build | Attachment |
|---|---|
| SDL3 (Win / mac / Linux) | accesskit_host::attach_sdl(window) right after the window is created in form.cpp. For Linux, focus and bounds are notified via window events in SDL_AppEvent |
| WINVER | When TTVPWindowForm::Proc receives WM_GETOBJECT, call on_wm_getobject(); on WM_ACTIVATE, call on_window_focus(). Handled directly in the core, not through a plugin message receiver |
Dedicated window of showModalJson |
Phase C. Attach a separate accesskit_host to the dedicated window as well (the run_modal side uses the same processing as the engine's Win32 / SDL host) |
- There can be multiple main windows (multiple
Windows can be created). Therefore anaccesskit_hostis held per Window, and each Elements instance registers with the host of "the Window it is drawn on". - The root node's name is
Window.caption.
4. Making ElementsDialog a source (ElementsDialogManager)¶
- Call
add_source/remove_sourcewhen an instance is created / destroyed. When the z-order,modaloractivechanges, synchronizeset_modaland z. - Coordinates: right after
PaintOverlayfinalizeslast_rectin surface logical coordinates, callset_transformwith the surface → client pixel transform. This transform is shared with the IME transform inGetTextInputArea. - Connect
overlay_session::set_a11y_dirty_callbacktoinvalidate(). ElementsPanel(drawn on a Layer) is handled in Phase B. It is attached as a child of the game source, following the Layer's coordinates and visibility.
5. TJS API¶
class Accessibility // static
{
property mode; // "auto"(既定: AT 接続時のみ) / "on" / "off"
property active; // read-only: OS の AT が接続中か
property gameLabel; // ゲーム本体ノードの名前 (場面名など)
function announce(text, assertive=false, window=void);
// event
onActiveChanged(active); // System.addEventListener 相当で受ける (TVPPostEvent)
}
- An
"a11y"key written in ElementsDialog JSON / Dictionary (core design §4.1) takes effect as is, with no extra work. - When
activeis false,announceonly records to the speech log and returns immediately.
KAG integration (opt-in script): not done¶
This was a design-time proposal. Since KAG3 is no longer going to be extended, it will not be added to
script/KAG3. KAG-based titles pass the text toElementsDialog.announceand expose choices and the like viasetGameA11yfrom their own scripts (some titles actually do this).
KAG's own .tjs files are not modified. An extension system/A11yKAG.tjs is provided and enabled with kag.a11y.messages = true.
- When
MessageLayerfinishes displaying a line or a page, the accumulated text is passed toAccessibility.announce(name + "「" + text + "」"). It uses the same text that is pushed toHistoryLayer, so ruby and tags are already removed. - When the focus moves to a choice (link), its
hintis read. Once the Layer chain becomes a source in Phase B it will be read automatically, so this is a stopgap until then. - Lines with voice can be skipped depending on a setting:
kag.a11y.skipVoiced
6. REPL / Agent¶
The existing dialogTree is kept as is.
| Addition | Implementation |
|---|---|
Agent.a11yTree([window]) |
Returns the combined tree (all slots) held by the Window's host as a Dictionary in the format of core design §5. Specifying index returns only that dialog's source |
Agent.a11yLog([since]) |
An array of speech log entries (an approximate one-line form for focus, value changes, live regions and announce) |
Agent.a11yAction(nodeId, action[, arg]) |
Performs the action through the same path as an action from an AT (accesskit_host → source::perform) |
.a11y / .a11ylog / .say <text> |
Added to the rewrite block in ProcessLine, and added to the help lines. Usable on all channels: file / web / socket / console |
web /a11y, SSE /sub/a11y |
Native routes of ReplWebServer. The panel-style UI shares the tree view and speech log view with the A11y tab of elements_console |
Unlike Agent.dialogClick, Agent.a11yAction executes without going through synthesized keys. This makes it possible to test the AT action path itself.
7. Phases¶
| Phase | Content |
|---|---|
| A (done) | Attaching to the SDL3 / WINVER main window, making ElementsDialog a source, the TJS interface (static members of ElementsDialog), REPL additions |
| B (done) | The game source (setGameA11y, and a11yLayers, which reads the Layer focus chain / hint / focusedLayer / ElementsPanel). The KAG extension script will not be done |
| C | The dedicated window of showModalJson, multiple Windows (verification on real macOS / Linux hardware was completed together with A / B) |
| D (done) | Connecting to iOS (VoiceOver) / Android (TalkBack) (2026-10-22). The plan and verification record is TODO-a11y-mobile.md at the repository root (Japanese) |
Character-, word- and line-level reading of input fields is done on the Elements side (core design §6.5). IME composition strings are intentionally not handled (see "Input fields" above). What remains is Phase C. The index is the umbrella's TODO.md.