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:
Yunfei He
2026-06-14 01:40:57 +08:00
committed by GitHub
parent dab5125c90
commit 0b61ff2bf4
17 changed files with 355 additions and 102 deletions
+110
View File
@@ -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.
+44
View File
@@ -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]].
+60 -15
View File
@@ -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)
@@ -1,6 +1,6 @@
import { defineComponent, nextTick, shallowRef, type FunctionalComponent } from "vue";
import { describe, expect, test } from "vite-plus/test";
import { renderToString, Box, Text, Transform, Newline, Static, createApp } from "@vue-tui/runtime";
import { Box, Text, Transform, Newline, Static, createApp } from "@vue-tui/runtime";
import { render } from "@vue-tui/testing";
import {
createRoot,
@@ -9,6 +9,7 @@ import {
createTextLeaf,
attachYoga,
renderScreenReaderOutput,
renderToStringWithScreenReader as renderToString,
type AppContext,
} from "@vue-tui/runtime/internal";
import {
@@ -1,7 +1,8 @@
import { defineComponent, shallowRef, nextTick, h } from "vue";
import { test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, renderToString } from "@vue-tui/runtime";
import { Box, Text } from "@vue-tui/runtime";
import { renderToStringWithScreenReader as renderToString } from "@vue-tui/runtime/internal";
const BG_BLUE = "\x1b[44m";
const BG_CYAN = "\x1b[46m";
@@ -7,7 +7,7 @@ import { PassThrough } from "node:stream";
import process from "node:process";
import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { createApp, Text, useTerminalSize } from "@vue-tui/runtime";
import { createApp, Text, useWindowSize } from "@vue-tui/runtime";
function makeTtyStream(columns: number): NodeJS.WriteStream {
const s = new PassThrough() as unknown as NodeJS.WriteStream;
@@ -36,7 +36,7 @@ function makeFakeStdin(): NodeJS.ReadStream {
// when stdout.rows is missing"). With the mount stdout reporting columns 0 and
// no rows, resolveSize() calls terminal-size, which — after we zero out the real
// process.stdout/stderr dimensions — resolves rows from process.env.LINES.
test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES when stdout.rows is missing", async () => {
test.sequential("useWindowSize falls back to terminal-size rows from env.LINES when stdout.rows is missing", async () => {
const stdout = makeTtyStream(0);
const stderr = makeTtyStream(0);
const stdin = makeFakeStdin();
@@ -50,7 +50,7 @@ test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES
let capturedRows = -1;
const App = defineComponent(() => {
const { rows } = useTerminalSize();
const { rows } = useWindowSize();
capturedRows = rows.value;
return () => <Text>{String(rows.value)}</Text>;
});
@@ -2,7 +2,7 @@ import { PassThrough } from "node:stream";
import { defineComponent, onScopeDispose } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, createApp, Text, useTerminalSize } from "@vue-tui/runtime";
import { Box, createApp, Text, useWindowSize } from "@vue-tui/runtime";
// A TTY-like writable that we control directly (columns/rows + resize listeners)
// — the @vue-tui/testing render() helper hides the underlying stdout, but the
@@ -31,9 +31,9 @@ function makeFakeStdin(): NodeJS.ReadStream {
return s;
}
test("useTerminalSize reacts to resize event", async () => {
test("useWindowSize reacts to resize event", async () => {
const App = defineComponent(() => {
const { columns, rows } = useTerminalSize();
const { columns, rows } = useWindowSize();
return () => (
<Text>
{columns.value}x{rows.value}
@@ -48,9 +48,9 @@ test("useTerminalSize reacts to resize event", async () => {
expect(lastFrame()).toContain("120x40");
});
test("useTerminalSize returns initial terminal dimensions", async () => {
test("useWindowSize returns initial terminal dimensions", async () => {
const App = defineComponent(() => {
const { columns, rows } = useTerminalSize();
const { columns, rows } = useWindowSize();
return () => (
<Text>
{columns.value}x{rows.value}
@@ -62,10 +62,10 @@ test("useTerminalSize returns initial terminal dimensions", async () => {
expect(lastFrame()).toContain("100x40");
});
test("useTerminalSize removes resize listener on unmount", async () => {
test("useWindowSize removes resize listener on unmount", async () => {
// After unmount, further resize events should not cause errors
const App = defineComponent(() => {
const { columns, rows } = useTerminalSize();
const { columns, rows } = useWindowSize();
return () => (
<Text>
{columns.value}x{rows.value}
@@ -82,9 +82,9 @@ test("useTerminalSize removes resize listener on unmount", async () => {
await expect(terminal.resize(60, 20)).resolves.toBeUndefined();
});
test("useTerminalSize does not crash when resize fires after unmount", async () => {
test("useWindowSize does not crash when resize fires after unmount", async () => {
const App = defineComponent(() => {
const { columns, rows } = useTerminalSize();
const { columns, rows } = useWindowSize();
return () => (
<Text>
{columns.value}x{rows.value}
@@ -122,7 +122,7 @@ test("layout responds to terminal width change", async () => {
test("multiple consecutive resizes all take effect", async () => {
const App = defineComponent(() => {
const { columns, rows } = useTerminalSize();
const { columns, rows } = useWindowSize();
return () => (
<Text>
{columns.value}x{rows.value}
@@ -145,7 +145,7 @@ test("multiple consecutive resizes all take effect", async () => {
test("terminal width decrease triggers rerender", async () => {
const App = defineComponent(() => {
const { columns } = useTerminalSize();
const { columns } = useWindowSize();
return () => <Text>{columns.value}</Text>;
});
@@ -158,7 +158,7 @@ test("terminal width decrease triggers rerender", async () => {
test("terminal width increase triggers rerender", async () => {
const App = defineComponent(() => {
const { columns } = useTerminalSize();
const { columns } = useWindowSize();
return () => <Text>{columns.value}</Text>;
});
@@ -173,9 +173,9 @@ test("resize listener is cleaned up via onScopeDispose", async () => {
let disposeCalled = false;
const App = defineComponent(() => {
// useTerminalSize registers an onScopeDispose listener internally;
// useWindowSize registers an onScopeDispose listener internally;
// we also register one to verify the scope is properly disposed on unmount.
useTerminalSize();
useWindowSize();
onScopeDispose(() => {
disposeCalled = true;
});
@@ -193,14 +193,14 @@ test("resize listener is cleaned up via onScopeDispose", async () => {
// count when stdout.columns is 0"). When the mount stdout reports columns 0,
// resolveSize() falls through to the terminal-size package / 80 default, so the
// captured value must be a positive number (never 0).
test("useTerminalSize falls back to a positive column count when stdout.columns is 0", async () => {
test("useWindowSize falls back to a positive column count when stdout.columns is 0", async () => {
const stdout = makeTtyStream(0, 24);
const stderr = makeTtyStream(0, 24);
const stdin = makeFakeStdin();
let capturedColumns = -1;
const App = defineComponent(() => {
const { columns } = useTerminalSize();
const { columns } = useWindowSize();
capturedColumns = columns.value;
return () => <Text>{String(columns.value)}</Text>;
});
@@ -217,9 +217,9 @@ test("useTerminalSize falls back to a positive column count when stdout.columns
});
// Mirrors Ink terminal-resize.tsx:43-64 ("removes resize listener on unmount").
// The resize listener count must grow by mounting a useTerminalSize component
// The resize listener count must grow by mounting a useWindowSize component
// and return exactly to baseline after unmount (no leaked listener).
test("useTerminalSize resize listener returns to baseline on unmount", async () => {
test("useWindowSize resize listener returns to baseline on unmount", async () => {
const stdout = makeTtyStream(80, 24);
const stderr = makeTtyStream(80, 24);
const stdin = makeFakeStdin();
@@ -227,7 +227,7 @@ test("useTerminalSize resize listener returns to baseline on unmount", async ()
const baseline = stdout.listenerCount("resize");
const App = defineComponent(() => {
const { columns, rows } = useTerminalSize();
const { columns, rows } = useWindowSize();
return () => (
<Text>
{columns.value}x{rows.value}
@@ -1,7 +1,7 @@
import { defineComponent, nextTick, ref, shallowRef, watchEffect, watchPostEffect } from "vue";
import { describe, expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, useBoxMetrics, measureElement, useTerminalSize } from "@vue-tui/runtime";
import { Box, Text, useBoxMetrics, measureElement, useWindowSize } from "@vue-tui/runtime";
describe("useBoxMetrics", () => {
test("returns layout dimensions after render", async () => {
@@ -379,7 +379,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
const App = defineComponent(() => {
const boxRef = ref(null);
const { width } = useBoxMetrics(boxRef);
useTerminalSize();
useWindowSize();
return () => (
<Box ref={boxRef}>
<Text>Width: {width.value}</Text>
@@ -407,7 +407,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
});
const { height } = useBoxMetrics(trackedRef);
useTerminalSize();
useWindowSize();
return () => (
<Box flexDirection="column">
@@ -636,7 +636,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
const App = defineComponent(() => {
const boxRef = ref(null);
useBoxMetrics(boxRef);
useTerminalSize();
useWindowSize();
return () => (
<Box ref={boxRef}>
<Text>Hello</Text>
@@ -1,7 +1,8 @@
import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text, renderToString, useIsScreenReaderEnabled } from "@vue-tui/runtime";
import { Text, useIsScreenReaderEnabled } from "@vue-tui/runtime";
import { renderToStringWithScreenReader as renderToString } from "@vue-tui/runtime/internal";
// NOTE: tests that auto-detect SR via the process-GLOBAL env var
// `INK_SCREEN_READER` live in use-screen-reader-env.sequential.test.tsx (the
@@ -1,46 +1,47 @@
import { expect, test } from "vite-plus/test";
import * as api from "@vue-tui/runtime";
import * as internalApi from "@vue-tui/runtime/internal";
test("public API exposes documented members", () => {
for (const k of [
// The EXACT public runtime (value) export surface of `@vue-tui/runtime`. The test below snapshots
// it exhaustively: adding, removing, or renaming ANY value export fails — so every change to the
// public surface must be a deliberate edit here. Keep grouped + alphabetical-within-group for
// readable diffs. NOTE: type-only exports are erased at runtime and cannot be enumerated this way;
// they are guarded individually with `@ts-expect-error` (see the `ScreenReaderOptions` guard
// below). The type surface is therefore not exhaustively snapshotted.
const PUBLIC_VALUE_EXPORTS = [
// Entry point
"createApp",
// Components
"Box",
"Text",
"Newline",
"Spacer",
"Static",
"Text",
"Transform",
// Composables
"useAnimation",
"useApp",
"useInput",
"useBoxMetrics",
"useCursor",
"useFocus",
"useFocusManager",
"useInput",
"useIsScreenReaderEnabled",
"usePaste",
"useStderr",
"useStdin",
"useStdout",
"useStderr",
"useTerminalSize",
"useWindowSize",
"useCursor",
"useIsScreenReaderEnabled",
"useAnimation",
"useBoxMetrics",
"measureElement",
"usePaste",
// Rendering
"renderToString",
"renderScreenReaderOutput",
// Kitty keyboard
"kittyFlags",
"kittyModifiers",
]) {
expect(api).toHaveProperty(k);
}
});
];
test("useWindowSize is an alias for useTerminalSize", () => {
expect(api.useWindowSize).toBe(api.useTerminalSize);
test("public API surface is exactly the documented value-export set", () => {
expect(Object.keys(api).sort()).toEqual([...PUBLIC_VALUE_EXPORTS].sort());
});
// Ink keeps its `measure-text` module internal and does not re-export it. vue-tui
@@ -51,3 +52,22 @@ test("does not expose internal text-measurement helpers (Ink keeps them internal
expect(api).not.toHaveProperty("measureText");
expect(api).not.toHaveProperty("measureTextNatural");
});
// `renderScreenReaderOutput` is the screen-reader linearizer — internal SR machinery,
// not a public API. Ink keeps its counterpart (`renderNodeToScreenReaderOutput`)
// module-internal and never re-exports it; we match that. It was never usefully
// callable from the public barrel anyway: its only parameter type (`TuiNode`) and the
// node-construction primitives needed to build one live only in
// `@vue-tui/runtime/internal`. It moves there. See .agents/docs/accessibility-api.md.
test("does not expose the screen-reader linearizer publicly (Ink keeps it internal)", () => {
expect(api).not.toHaveProperty("renderScreenReaderOutput");
expect(internalApi).toHaveProperty("renderScreenReaderOutput");
});
// Compile-time guard for the TYPE half of the contract (types are erased at runtime, so this
// can't be an `expect()`): `ScreenReaderOptions` is internal-only too. Importing it from the
// PUBLIC barrel must NOT type-check — if it is ever re-added there, this `@ts-expect-error` goes
// unused and `tsc --noEmit` fails. Same idiom as the prop-type fixtures in integration/pty/fixtures.
// It DOES type-check from `/internal`, which the runtime guard above already proves is the home.
// @ts-expect-error - ScreenReaderOptions is exported only from @vue-tui/runtime/internal
export type _ScreenReaderOptionsIsInternalOnly = import("@vue-tui/runtime").ScreenReaderOptions;
@@ -18,7 +18,7 @@ import {
useStderr,
useCursor,
usePaste,
useTerminalSize,
useWindowSize,
useAnimation,
useBoxMetrics,
} from "@vue-tui/runtime";
@@ -650,7 +650,7 @@ describe("renderToString", () => {
// StdinContext + a no-op AnimationScheduler (render-to-string.ts:93-96). The
// existing suite covers useInput/useApp/useFocus/useFocusManager/useStdin/
// useStdout/useStderr. These pin the remaining terminal composables —
// useCursor, usePaste, useTerminalSize, useAnimation, useBoxMetrics — so that
// useCursor, usePaste, useWindowSize, useAnimation, useBoxMetrics — so that
// rendering a component which CALLS them degrades to inert values instead of
// throwing (they must still return a string).
describe("terminal composables degrade to no-ops (do not throw)", () => {
@@ -681,11 +681,11 @@ describe("renderToString", () => {
expect(pasted).toBe("");
});
test("useTerminalSize does not throw in renderToString", () => {
test("useWindowSize does not throw in renderToString", () => {
const App = defineComponent(() => {
// Resolves dimensions from ctx.stdout (process.stdout in the no-op
// context) with the terminal-size fallback; never throws.
const { columns, rows } = useTerminalSize();
const { columns, rows } = useWindowSize();
return () => <Text>size {columns.value > 0 && rows.value > 0 ? "ok" : "fallback"}</Text>;
});
const output = renderToString(App);
@@ -726,7 +726,7 @@ describe("renderToString", () => {
const { setCursorPosition } = useCursor();
setCursorPosition({ x: 1, y: 0 });
usePaste(() => {});
useTerminalSize();
useWindowSize();
const { frame } = useAnimation({ interval: 30 });
const boxRef = shallowRef(null);
useBoxMetrics(boxRef);
@@ -14,7 +14,7 @@ import {
} from "../animation-scheduler.ts";
import { AnimationSchedulerKey } from "../context.ts";
export interface AnimationOptions {
export interface UseAnimationOptions {
/**
* Time between ticks in milliseconds.
*
@@ -85,7 +85,7 @@ export interface UseAnimationReturn {
* </template>
* ```
*/
export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn {
export function useAnimation(options: UseAnimationOptions = {}): UseAnimationReturn {
const frame = shallowRef(0);
const time = shallowRef(0);
const delta = shallowRef(0);
@@ -7,8 +7,8 @@ import { AppContextKey } from "../context.ts";
* (`columns`/`rows`, not `width`/`height`).
*
* Note the Vue-vs-React shape: Ink's `useWindowSize()` returns a `WindowSize`
* snapshot, whereas vue-tui's `useWindowSize()` / `useTerminalSize()` return
* reactive **refs** of these dimensions
* snapshot, whereas vue-tui's `useWindowSize()` returns reactive **refs** of
* these dimensions
* (`{ columns: ShallowRef<number>; rows: ShallowRef<number> }`). The data shape
* is the same; the reactivity wrapper is the framework difference.
*/
@@ -36,9 +36,9 @@ export function resolveSize(stdout: NodeJS.WriteStream): WindowSize {
};
}
export function useTerminalSize(): { columns: ShallowRef<number>; rows: ShallowRef<number> } {
export function useWindowSize(): { columns: ShallowRef<number>; rows: ShallowRef<number> } {
const ctx = inject(AppContextKey);
if (!ctx) throw new Error("useTerminalSize() must be called inside a vue-tui render tree");
if (!ctx) throw new Error("useWindowSize() must be called inside a vue-tui render tree");
const initial = resolveSize(ctx.stdout);
const columns = shallowRef(initial.columns);
const rows = shallowRef(initial.rows);
@@ -51,6 +51,3 @@ export function useTerminalSize(): { columns: ShallowRef<number>; rows: ShallowR
onScopeDispose(() => ctx.stdout.off("resize", onResize));
return { columns, rows };
}
/** Alias for `useTerminalSize`. */
export const useWindowSize = useTerminalSize;
+6 -4
View File
@@ -30,12 +30,12 @@ export { useFocusManager } from "./composables/useFocusManager.ts";
export { useStdin, type UseStdinReturn } from "./composables/useStdin.ts";
export { useStdout, type UseStdoutReturn } from "./composables/useStdout.ts";
export { useStderr, type UseStderrReturn } from "./composables/useStderr.ts";
export { useTerminalSize, useWindowSize, type WindowSize } from "./composables/useTerminalSize.ts";
export { useWindowSize, type WindowSize } from "./composables/useWindowSize.ts";
export { useCursor, type CursorPosition } from "./composables/useCursor.ts";
export { useIsScreenReaderEnabled } from "./composables/useIsScreenReaderEnabled.ts";
export {
useAnimation,
type AnimationOptions,
type UseAnimationOptions,
type UseAnimationReturn,
} from "./composables/useAnimation.ts";
export {
@@ -44,8 +44,6 @@ export {
type BoxMetrics,
type UseBoxMetricsReturn,
} from "./composables/useBoxMetrics.ts";
export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.ts";
export type { DevState, DevErrorInfo } from "./hmr.ts";
export {
kittyFlags,
kittyModifiers,
@@ -55,3 +53,7 @@ export {
// `measureText` / `measureTextNatural` are deliberately NOT re-exported: Ink keeps
// its `measure-text` module internal, and so do we. They remain internal helpers
// (yoga.ts uses `measureTextNatural`). See .agents/docs/ink-divergences.md.
// `renderScreenReaderOutput` / `ScreenReaderOptions` are likewise NOT public: Ink keeps
// its SR linearizer (`renderNodeToScreenReaderOutput`) module-internal, and it was never
// usefully callable from here (its `TuiNode` argument type isn't public). It lives in
// `@vue-tui/runtime/internal`. See .agents/docs/accessibility-api.md.
+2
View File
@@ -10,6 +10,8 @@ export {
type TuiNode,
} from "./host/nodes.ts";
export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.ts";
export { renderToStringWithScreenReader } from "./render-to-string.ts";
export type { DevState, DevErrorInfo } from "./hmr.ts";
export type { AppContext } from "./context.ts";
export {
createKittyKeyboardController,
+30
View File
@@ -28,6 +28,16 @@ export interface RenderToStringOptions {
* @default 80
*/
columns?: number;
}
/**
* Options for the internal, screen-reader-capable render-to-string used by the
* accessibility test suite. NOT part of the public API: Ink likewise keeps
* screen-reader string rendering out of its public `renderToString` (layout
* only) and reaches it through a private test helper. Exposed via
* `@vue-tui/runtime/internal` as `renderToStringWithScreenReader`.
*/
interface RenderToStringInternalOptions extends RenderToStringOptions {
/**
* Enable screen reader mode. When enabled, the output is plain text
* suitable for screen readers (no ANSI styling, with role/state annotations).
@@ -58,6 +68,26 @@ export interface RenderToStringOptions {
* caller after cleanup.
*/
export function renderToString(component: Component, options?: RenderToStringOptions): string {
return renderToStringInternal(component, options);
}
/**
* Screen-reader-capable variant of {@link renderToString}, for the accessibility
* test suite only (exported from `@vue-tui/runtime/internal`). The public
* `renderToString` is layout-only, matching Ink, which keeps screen-reader
* string rendering in a private test helper rather than its public API.
*/
export function renderToStringWithScreenReader(
component: Component,
options?: RenderToStringInternalOptions,
): string {
return renderToStringInternal(component, options);
}
function renderToStringInternal(
component: Component,
options?: RenderToStringInternalOptions,
): string {
const columns = options?.columns ?? 80;
const isScreenReaderEnabled = options?.isScreenReaderEnabled ?? false;
+1 -1
View File
@@ -45,7 +45,7 @@ import {
import { devState, DevStateKey, initHmrBridge } from "./hmr.ts";
import { createDevOverlayWrapper } from "./overlay.ts";
import { ErrorOverview, messageForNonError } from "./components/error-overview.ts";
import { resolveSize } from "./composables/useTerminalSize.ts";
import { resolveSize } from "./composables/useWindowSize.ts";
export interface MountOptions {
stdout?: NodeJS.WriteStream;