Files
vue-tui/.agents/docs/accessibility-api.md
T
Yunfei He f847e17c81 docs(runtime): finish the useWindowSize rename in docs; tidy contract guards (#164)
Follow-up cleanup for the 7 confirmed findings from a review of #163. The
dominant theme: #163 hard-renamed the public composable useTerminalSize ->
useWindowSize (no alias) but left stale references to the dead name in
user-facing docs.

- README.md + packages/runtime/README.md: the composable tables named the
  removed `useTerminalSize()` (root README even framed the sole real export
  `useWindowSize` as an "Ink-compat alias" — now inverted). Point both at
  `useWindowSize()`.
- .agents/docs/ink-divergences.md: two vue-tui-side references to
  `useTerminalSize` (the shallowRef "object of refs" example and the
  "composables throw outside a render tree" list) -> `useWindowSize`. The
  Ink-side `useWindowSize -> WindowSize` naming example is left unchanged.
- .agents/docs/accessibility-api.md: the intro cited three "blessed entries"
  but only aria-camelCase is one; `renderToString` layout-only and the
  `useWindowSize` name are now Ink parity, not divergences. Reword.
- .agents/docs/api-contract.md: tighten the `/internal` wording — the test
  does assert one tripwire on `/internal`, so "not covered by
  public-api.test.ts" was imprecise.
- public-api.test.ts / render-to-string.test.tsx: the public renderToString
  dropped the `isScreenReaderEnabled` option but (unlike the sibling
  `ScreenReaderOptions` type) had no compile-time guard. Replace an obscure,
  fmt-fragile type-indexing guard with a readable call-site `@ts-expect-error`
  in render-to-string.test.tsx; re-adding the option to the public
  RenderToStringOptions makes the directive unused and fails `tsc --noEmit`.
- Rename terminal-size.test.tsx / .sequential.test.tsx ->
  window-size.test.tsx / .sequential.test.tsx to match the migrated symbol.

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

7.2 KiB

Accessibility (ARIA + screen-reader) API

How vue-tui exposes ARIA and renders screen-reader (SR) output, and why. Companion to the aria-camelCase blessed entry in ink-divergences; 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.

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 "Why align to Ink — and when not to" principle in ink-divergences.

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 (maintainer decision 2026-06-14). 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.