refactor(runtime)!: public-API audit follow-ups — align to Ink, record decisions (#163)
* 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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# 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 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.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Public API contract & surface
|
||||
|
||||
What is — and isn't — part of `@vue-tui/runtime`'s public contract, and how the contract is
|
||||
tested. (Behavioral _divergences_ from Ink live in [[ink-divergences]]; this file is about the
|
||||
SHAPE of the public surface itself.)
|
||||
|
||||
## The contract = public exports **and their user-consumable types**
|
||||
|
||||
The public API is everything exported from the main barrel (`@vue-tui/runtime`): components,
|
||||
composables, entry points — **and their TYPES** (component prop types, composable return/options
|
||||
types, and named types such as `AriaRole`, `WindowSize`, `BoxProps`, `UseXReturn` / `UseXOptions`).
|
||||
|
||||
A type is **as much a part of the contract as runtime behavior**. If user code can name a type
|
||||
and annotate with it, renaming or removing it breaks that code at COMPILE time — exactly as
|
||||
severe as a runtime break. So a type rename/removal is a breaking change, and the type surface is
|
||||
the most important part of the contract to get right.
|
||||
|
||||
Because it is contract, it is **tested, not merely shipped**:
|
||||
|
||||
- `public-api.test.ts` snapshots the **exact** public value-export set — adding, removing, or
|
||||
renaming any runtime export fails it, so every surface change must be a deliberate edit there.
|
||||
Specific internal-only members (`measureText`, the screen-reader linearizer) are additionally
|
||||
tripwired, and internal-only _types_ (e.g. `ScreenReaderOptions`) are guarded with a compile-time
|
||||
`@ts-expect-error`. Type-only exports are erased at runtime, so the _type_ surface is guarded
|
||||
individually rather than exhaustively snapshotted.
|
||||
- Type-_safety_ behavior is established by RUNNING the type-checker against real usage (`tsc` for
|
||||
TSX, `vue-tsc` for templates), never assumed. See [[accessibility-api]] for a worked example —
|
||||
which aria spellings the compiler does and does not catch, proven with both tools.
|
||||
|
||||
## `/internal` is NOT the contract
|
||||
|
||||
`@vue-tui/runtime/internal` is an explicitly internal / advanced surface — host-node types
|
||||
(`TuiNode`, …), test-only helpers (`renderToStringWithScreenReader`), dev/HMR types (`DevState`,
|
||||
`DevErrorInfo`), kitty-controller internals, etc. It carries **no stability guarantee**, is not
|
||||
covered by `public-api.test.ts`, and may change freely between releases.
|
||||
|
||||
Placement rule for any export:
|
||||
|
||||
- A user-facing contract → the **main barrel** (and it is tested).
|
||||
- Needed only by tests or advanced integrators → **`/internal`**, never the main barrel.
|
||||
|
||||
Packaging/build internals (the `exports` field shape, `.mjs` paths, `dist` layout) are likewise
|
||||
**not** part of the behavioral/type contract and are not aligned to Ink — see the alignment-scope
|
||||
note in [[ink-divergences]].
|
||||
@@ -13,6 +13,33 @@ Reference baseline: Ink **v7.0.4** (commit
|
||||
`40b3a7578811fd616341ca4e31cc7748aeeff12f`). When bumping the target Ink version,
|
||||
re-validate every entry below against the new source.
|
||||
|
||||
## Why align to Ink — and when not to
|
||||
|
||||
Aligning to Ink is a **means, not an end**. Ink is a mature, battle-tested implementation, so
|
||||
matching its public surface and behavior lets vue-tui inherit years of bug-fixes and edge-case
|
||||
handling for free. That — reducing bugs by reusing proven behavior — is the entire point of
|
||||
alignment.
|
||||
|
||||
It follows that **alignment is not the top priority**. When Ink's behavior is itself a defect,
|
||||
is unreasonable, or is un-idiomatic for Vue, **conformance to Vue's philosophy and the plain
|
||||
reasonableness/correctness of the behavior outrank parity.** There vue-tui deliberately diverges,
|
||||
and records it here so the choice is conscious and blessed, not drift.
|
||||
|
||||
This guards against two opposite failure modes:
|
||||
|
||||
- **Blind alignment** — copying Ink even where Ink is wrong, or where matching would force
|
||||
un-Vue machinery, merely to match. (Rejected e.g. in the `useCursor` corner-zombie, the
|
||||
resolve-on-throw exit, and the paint-time invalid-input crash — Ink behaviors vue-tui treats
|
||||
as defects, not contracts.)
|
||||
- **Lazy divergence** — inventing a different behavior and rationalizing it as "Vue's way is
|
||||
better" with no genuine Vue-philosophy or correctness reason. Mere presence in this file is
|
||||
**not** a blessing; every kept divergence needs a real reason and a maintainer decision.
|
||||
|
||||
So the test for any difference is never just "does it match Ink?" but "is this the most
|
||||
reasonable, most Vue-idiomatic behavior — and where it diverges from Ink, is that because Ink is
|
||||
wrong or un-Vue, recorded with a maintainer decision?" Reasonableness and Vue idiom come first;
|
||||
alignment is simply the cheapest way to get there whenever Ink is already right.
|
||||
|
||||
## How to Classify a Divergence
|
||||
|
||||
Classify each divergence by the first rule that applies. The order matters: earlier
|
||||
@@ -109,20 +136,9 @@ remain compatible; vue-tui only adds accepted inputs, contexts, or capabilities.
|
||||
node is reached via `$el`. Because `<Box>` is a `defineComponent`, the `$el` path is in
|
||||
fact the **primary** path a normal `ref` on `<Box>` takes — the bare host-node ref is the
|
||||
rarer raw-host case. Supporting both is a strict superset that matches how Vue refs behave;
|
||||
a bare host-node ref still works identically to Ink.
|
||||
|
||||
### `renderToString` supports screen-reader mode
|
||||
|
||||
- **Ink:** `renderToString` has only a `columns` option; it always renders the non-SR
|
||||
(ANSI) frame. Ink's **live** `render()` does expose `isScreenReaderEnabled` (plus a full
|
||||
accessibility stack), so this is Ink choosing not to surface SR in the _string_ API, not a
|
||||
missing SR capability.
|
||||
- **vue-tui:** `renderToString` accepts `isScreenReaderEnabled?: boolean`. In SR mode it
|
||||
returns the linearized accessibility text (`renderScreenReaderOutput`) and prepends the
|
||||
linearized `<Static>` output, just as the non-SR path prepends the painted static frame.
|
||||
- **Why:** vue-tui already has a parity SR renderer for the live path. Surfacing it through
|
||||
the string API is a strict superset (default `false` is byte-identical to Ink) and keeps
|
||||
`<Static>` content in generated SR snapshots. Additive.
|
||||
a bare host-node ref still works identically to Ink. Maintainer decision (2026-06-13): KEEP
|
||||
— a reasonable Vue-idiomatic adoption (the component-instance ref is the natural Vue path;
|
||||
the bare host-node ref stays Ink-identical).
|
||||
|
||||
### Two apps sharing one stdin both receive input
|
||||
|
||||
@@ -311,6 +327,15 @@ current-props model, or API conventions.
|
||||
return `void`, plain `boolean`, or small unexported inline shapes — never an `XProps`
|
||||
type. `XProps` is reserved for component props (`BoxProps`/`TextProps`, derived via
|
||||
`ExtractPublicPropTypes`).
|
||||
- **Options types follow the same principle:** Ink names a composable's options type locally
|
||||
`Options` / `Props` and usually does **not** export it (e.g. `useAnimation`'s `Options` is
|
||||
internal — only the return `AnimationResult` is exported, `use-animation.ts:14,30`). vue-tui
|
||||
exports each composable's options type under VueUse's `UseXOptions` name: `UseInputOptions`,
|
||||
`UsePasteOptions`, `UseFocusOptions`, `UseAnimationOptions`. `useAnimation`'s options type
|
||||
originally shipped as `AnimationOptions` — the lone holdout — and was renamed to
|
||||
`UseAnimationOptions` (a hard rename, no alias) while the package is pre-1.0 (`0.0.x`, no
|
||||
stability promise yet). **Maintainer decision (2026-06-13): export composable options types
|
||||
as `UseXOptions`; renamed `AnimationOptions` → `UseAnimationOptions`.**
|
||||
- **Why:** the public surface should read like Vue code: named composable return types get a
|
||||
single convention (`UseXReturn`) instead of Ink's mix of `XProps`, result names, and bare
|
||||
names, and `XProps` keeps its Vue meaning (component props). Return shapes still mirror
|
||||
@@ -347,6 +372,26 @@ current-props model, or API conventions.
|
||||
Vue users expect for slot payloads. The rendered item/index values remain equivalent.
|
||||
Maintainer decision (2026-06-06): KEEP.
|
||||
|
||||
#### ARIA props are typed camelCase; kebab still works but is not type-checked
|
||||
|
||||
Full design, type-safety findings, and precedent survey: [[accessibility-api]].
|
||||
|
||||
- **Ink:** kebab string-literal prop keys (`'aria-label'`, `'aria-hidden'`, `'aria-role'` union,
|
||||
`'aria-state'` object); JSX keys never camelize.
|
||||
- **vue-tui:** the same vocabulary as typed **camelCase** props (`ariaLabel`/`ariaHidden`/
|
||||
`ariaRole`/`ariaState`; `AriaRole`/`AriaState` exported, identical to Ink's). Ink's kebab still
|
||||
works at runtime (Vue camelizes onto the declared prop), so `aria-role` ports unchanged.
|
||||
- **Why (Vue idiom + reasonableness > parity — see "Why align to Ink"):** Vue's `prop-name-casing`
|
||||
mandates camelCase, and — run-verified with `tsc`/`vue-tsc` — **camelCase is the only spelling
|
||||
type-checked** (value/typo/compound mistakes compile-error in both TSX and templates), while
|
||||
kebab `aria-*` is not (Vue/Volar treat it as a global attr). So `ariaRole` is the type-safe
|
||||
spelling and `aria-role` the runtime-only porting escape; the rejected kebab-only `$attrs`
|
||||
alternative loses typing + Boolean coercion for nothing the checker doesn't already give.
|
||||
Maintainer decision (2026-06-14): KEEP.
|
||||
- **Edges:** a future compound aria word must be declared as its mechanical camelize
|
||||
(`ariaHaspopup`, not `ariaHasPopup`) or folded into `ariaState`; `aria-hidden` is modeled
|
||||
boolean (bare → true), but the string `aria-hidden="false"` wrongly hides (recorded edge).
|
||||
|
||||
## Intentional Divergence Choices
|
||||
|
||||
These divergences are deliberate, but they are not strict supersets and are not primarily
|
||||
@@ -429,7 +474,7 @@ different runtime behavior, ownership rule, or out-of-contract handling.
|
||||
an Ink app holding a `useInput` already does not). The "render and auto-exit" pattern
|
||||
(Ink's inline-output use) is `rawMode: 'auto'`. Tests: `raw-mode-lifecycle.test.tsx`
|
||||
(`'always'` holds raw with no input hook; `'auto'` stays cooked; no mid-session
|
||||
oscillation).
|
||||
oscillation). Maintainer decision (2026-06-13): KEEP.
|
||||
|
||||
### `useCursor()` re-asserts the declared caret every commit (persistent declaration)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user