Files
vue-tui/.agents/docs/accessibility-api.md
T
Yunfei He c4b001c12f docs(divergences): broaden into an Ink relationship record; strengthen the alignment-is-a-means principle (#204)
ink-divergences.md used to record only divergences from Ink. Broaden it into the
single record point for the *whole* Ink relationship: it now also records deliberate
alignments (intentional sameness) as first-class entries, not just differences.

- Reframe the title + intro: three relationship kinds (deliberate alignments,
  intentional divergences, non-behavioral notes), each a conscious decision.
- Elevate + strengthen the governing principle: aligning to Ink is only a means to
  reduce bugs, never the goal — correctness and Vue philosophy outrank parity, and
  "because Ink does it" is never on its own a justification.
- Add a first-class "Deliberate Alignments" section; promote the two deliberate-
  sameness records (commit-timing Ink-alignment, literal-tab measure-vs-paint, the
  latter keeping its existing [VOUCHED @hyf0]) out of Non-Behavioral Notes into it.
- Restructure the classification flow to split deliberate match vs deliberate
  divergence first ("How to Classify an Entry").
- Update AGENTS.md guidance and the two stale cross-references to the renamed
  headings (accessibility-api.md + the in-file ARIA entry).

No existing divergence entry's substance changed; no [VOUCHED] stamp was added or
removed (AI cannot self-bless). Reviewed by Codex + an independent reviewer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:08:32 +08:00

113 lines
7.3 KiB
Markdown

# Accessibility (ARIA + screen-reader) API
How vue-tui exposes ARIA and renders screen-reader (SR) output, and why. Companion to the
aria-camelCase vouched entry in [ink-divergences](./ink-divergences.md); `renderToString` being layout-only and the
`useWindowSize` name are now Ink parity (not divergences), and the reactive-refs return shape is
covered by the shallowRef entry there. This file keeps the _why_ and the researched / run-verified
findings those entries deliberately omit, so they are not re-derived expensively. The exported aria
types are part of the public contract — see [api-contract](./api-contract.md).
## The constraint that shapes everything
vue-tui renders to a terminal — **no DOM, no browser accessibility tree.** To support screen
readers it must READ aria semantics off its own components and GENERATE the linearized SR text
itself (e.g. `<Box aria-role="checkbox" aria-state={{ checked: true }}>Accept</Box>`
`"(checked) checkbox: Accept"`). So aria values must reach the framework as something it can read
at the component boundary — i.e. **component props** — exactly like Ink, which is also a no-DOM
renderer. The mainstream Vue a11y pattern (let `aria-*` fall through to the DOM as kebab
attributes and let the browser interpret them) is structurally unavailable here: there is no DOM
to receive them and no browser to read them.
## Naming: typed camelCase props (kebab works at runtime)
- Props are camelCase — `ariaLabel` / `ariaHidden` / `ariaRole` / `ariaState` — with exported
`AriaRole` (union) and `AriaState` (object) types, field-for-field identical to Ink's.
- Ink uses kebab string-literal prop KEYS (`'aria-role'`). vue-tui does not, because Vue's prop
convention is camelCase AND camelCase is the only spelling the type-checker validates (below).
- Ink's kebab spelling still works at runtime: Vue camelizes an incoming kebab attribute onto the
declared prop, and `node-ops` accepts both `aria-role` and `ariaRole` keys on the host node. So
`aria-role` ports from Ink/HTML unchanged — it is the runtime-compatible escape, not the
type-safe path.
- This is a Vue-idiom + reasonableness choice, **not parity** (Ink is kebab). See the
"The governing principle: correctness first, alignment is only a means" section in
[ink-divergences](./ink-divergences.md).
## Type-safety boundary (run-verified: `tsc` + `vue-tsc`)
camelCase is the ONLY spelling that is compile-checked, and it is checked in both authoring
contexts:
- **TSX (`tsc`)** and **templates (`vue-tsc`)**: a bad value (`ariaRole="notarole"`), a typo
(`ariaRol`), or an unknown / compound-misspelled name all produce a COMPILE ERROR.
- **kebab `aria-*` is NOT compile-checked** in either context: Vue/Volar treat `aria-*` (and
`data-*`) as always-valid global attributes, so they bypass prop-matching. Control proving the
hole is `aria-*`-specific (not general fallthrough): a non-aria kebab like `border-style` IS
checked — Volar camelizes it to `borderStyle` and validates the value.
**The type-safe spelling is camelCase**; `aria-role` is the runtime-only porting escape the
compiler cannot guard. To reproduce: a scratch `.tsx` run through the package `tsc`, and a scratch
`.vue` run through `vue-tsc` (the repo has no vue-tsc — install it in an isolated dir), importing
`Box` from the built dist, asserting which mis-writes error.
## The compound-word pit (and the rule)
camelCase↔kebab is ambiguous for COMPOUND aria words: a human writes `ariaHasPopup`, but Vue's
`camelize` derives `ariaHaspopup` from the canonical `aria-haspopup` (the long-open vuejs/core
#5477). The current single-word vocabulary (role / label / hidden / state) camelizes losslessly,
so the pit is **latent, not live**.
**Rule:** any future compound aria word must be declared as the mechanical camelize of the kebab
name (`ariaHaspopup`, never the human-natural `ariaHasPopup`) or folded into the typed `ariaState`
object — never bridged by relying on auto-camelize. In TSX, and in templates via the camelCase
spelling, TS catches a wrong compound name; a kebab compound in a template is silent, so camelCase
is the guarded path. The cross-field consensus (see precedents) is the same: **never auto-camelize
an aria round-trip.**
## `aria-hidden` modeling
ARIA's `aria-hidden` is a tristate enumerated STRING (`true` / `false` / `undefined`, default
`undefined` = visible) — not a boolean; `aria-hidden="false"` explicitly means _visible_. Ink
models it as a plain `boolean` (bare → true) and vue-tui follows that for ergonomics. Known edge
(run-verified): the literal string `aria-hidden="false"` currently HIDES (Boolean-prop coercion
sees the non-empty string as truthy) where ARIA says visible; bare / `={true}` hide, and
`={false}` / omitted are correctly visible. Fixable with an explicit normalize if it ever matters.
## SR rendering architecture
- **Live path:** `app.mount({ isScreenReaderEnabled })` (or `INK_SCREEN_READER=true`) makes each
commit emit the linearized SR text instead of the ANSI frame.
- **`renderToString`:** public, **layout-only** (matches Ink). Its SR-capable variant is
`renderToStringWithScreenReader` in `@vue-tui/runtime/internal`, used by the accessibility test
suite — the public string API does not surface SR (Ink also keeps its SR-string rendering
test-internal).
- **`renderScreenReaderOutput(node)`:** the linearizer that walks the host tree's
`internal_accessibility`. **`/internal`-only** [VOUCHED @hyf0]. Ink keeps its
counterpart (`renderNodeToScreenReaderOutput`) module-internal and never exports it; we match
that. It was never usefully public anyway — its only parameter type (`TuiNode`) and the
node-construction primitives needed to build one are not in the public barrel, so a public
consumer could not name or construct the argument. No example/README/user path used it; the live
SR machinery (`render`, the internal `renderToStringWithScreenReader`, the `<Static>` channel)
imports it from the source module, unaffected. Public SR output is reached via the `mount`
`isScreenReaderEnabled` option, not by calling this directly.
## Precedents (condensed) — cross-field consensus
How other systems shape an aria API, surveyed when settling vue-tui's:
- **React / Ink:** kebab string-literal prop keys, typed union/object; JSX keys never camelize, so
there is no round-trip to disagree. (vue-tui can copy the SHAPE, not the mechanism — Vue
camelizes.)
- **AccessKit** (drives egui; the strongest other no-DOM precedent): abandons strings for a typed
`Role` enum + typed state methods — no kebab to convert at all.
- **Vue a11y libraries** (Reka UI / Headless UI / Vuetify): kebab `aria-*` as fallthrough
attributes onto the DOM, never declared props — relies on a DOM + browser, so unavailable here.
- **Web Components / HTML reflection / Lit:** dual surface bridged by an EXPLICIT curated map
(`aria-haspopup``ariaHasPopup`, `aria-posinset``ariaPosInSet`), never auto-camelize — the
platform's own answer to the compound problem, and proof that naive remove-dash-uppercase is
wrong.
- **WAI-ARIA spec:** aria names are all-lowercase single tokens (`aria-haspopup`, not
`aria-has-popup`); `aria-hidden` etc. are tristate strings, not booleans.
**Consensus across all of them: never auto-camelize an aria round-trip.** vue-tui satisfies it for
single-word props (lossless) and the compound-word rule above preserves it.