* refactor(runtime)!: rename AnimationOptions to UseAnimationOptions
Align the useAnimation options type with VueUse's UseXOptions convention, matching its sibling composable options bags (UseInputOptions / UsePasteOptions / UseFocusOptions) and the already-correct UseAnimationReturn. Hard rename, no deprecated alias — done while the package is pre-1.0 (0.0.x), so no stability break.
Recorded under "Public composable naming follows Vue conventions" in .agents/docs/ink-divergences.md. Surfaced by the public-API audit.
BREAKING CHANGE: the exported type AnimationOptions is renamed to UseAnimationOptions; update `import { type AnimationOptions }` to `import { type UseAnimationOptions }`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: tighten public API to Ink + record aria decision & alignment principle
Public-API audit follow-ups. Where vue-tui had drifted from Ink with no real Vue reason, align to Ink; reduce speculative surface; and record decisions in .agents/docs/ink-divergences.md.
- renderToString: drop the public `isScreenReaderEnabled` option (Ink's public renderToString is layout-only). The SR-capable variant moves to `@vue-tui/runtime/internal` as `renderToStringWithScreenReader` for the accessibility test suite; SR output is unchanged.
- useTerminalSize -> useWindowSize: drop the invented name + alias, align to Ink's `useWindowSize`. The reactive ref return shape is unchanged (shallowRef divergence still applies).
- DevState/DevErrorInfo: move from the public barrel to `@vue-tui/runtime/internal` (internal HMR types, no public consumer; Ink exposes no HMR types).
- docs(divergences): add a standing "Why align to Ink — and when not to" principle (alignment is a means to reduce bugs, not an end; Vue idiom + reasonableness outrank parity); record the aria-props camelCase decision with its run-verified type-safety boundary; stamp the rawMode-default and measureElement-$el entries with their KEEP decisions.
BREAKING CHANGE: removed public exports `useTerminalSize`, `DevState`, `DevErrorInfo`, and `renderToString`'s `isScreenReaderEnabled` option. Use `useWindowSize`; import HMR types from `@vue-tui/runtime/internal`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: move renderScreenReaderOutput to /internal-only
The screen-reader linearizer (ported from Ink's internal
`renderNodeToScreenReaderOutput`) was exported from the public barrel, but it
was never usefully public: its only parameter type `TuiNode` and the
node-construction primitives needed to build one are not public, so a public
consumer could not name or construct the argument. Ink keeps its counterpart
module-internal; we match that.
`renderScreenReaderOutput` + `ScreenReaderOptions` now live only in
`@vue-tui/runtime/internal` (already re-exported there). The live SR machinery
(render, the internal renderToStringWithScreenReader, the <Static> channel)
imports from the source module and is unaffected; public SR output is reached
via the mount `isScreenReaderEnabled` option.
public-api.test.ts: drop it from the public-members list; add a runtime guard
(absent from public, present on /internal) plus a compile-time @ts-expect-error
guard that the `ScreenReaderOptions` type cannot be re-added to the public
barrel.
Docs: new .agents/docs/accessibility-api.md (aria + SR design) and
api-contract.md (public surface = exports + their user-consumable types;
/internal is not the contract); resolve the open item and cross-link from
ink-divergences.md.
BREAKING CHANGE: renderScreenReaderOutput and ScreenReaderOptions are no longer
exported from @vue-tui/runtime; import from @vue-tui/runtime/internal if needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(runtime-tests): snapshot the exact public value-export set
Upgrade public-api.test.ts from "documented members present + targeted
negatives" to an exhaustive snapshot of the exact runtime value-export surface
of `@vue-tui/runtime`: adding, removing, or renaming any value export now fails
the test, so every public-surface change must be a deliberate edit to the list.
Type-only exports are erased at runtime and cannot be enumerated, so the type
surface stays guarded individually (the `@ts-expect-error` ScreenReaderOptions
guard); api-contract.md is updated to state this boundary precisely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7.1 KiB
Accessibility (ARIA + screen-reader) API
How vue-tui exposes ARIA and renders screen-reader (SR) output, and why. Companion to the terse
blessed entries in ink-divergences (aria props are camelCase; renderToString is layout-only;
useWindowSize); this file keeps the why and the researched / run-verified findings the ledger
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 exportedAriaRole(union) andAriaState(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-opsaccepts botharia-roleandariaRolekeys on the host node. Soaria-roleports 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 treataria-*(anddata-*) as always-valid global attributes, so they bypass prop-matching. Control proving the hole isaria-*-specific (not general fallthrough): a non-aria kebab likeborder-styleIS checked — Volar camelizes it toborderStyleand 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 })(orINK_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 isrenderToStringWithScreenReaderin@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'sinternal_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 internalrenderToStringWithScreenReader, the<Static>channel) imports it from the source module, unaffected. Public SR output is reached via themountisScreenReaderEnabledoption, 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
Roleenum + 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, notaria-has-popup);aria-hiddenetc. 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.