diff --git a/.agents/docs/accessibility-api.md b/.agents/docs/accessibility-api.md
new file mode 100644
index 0000000..b44fb03
--- /dev/null
+++ b/.agents/docs/accessibility-api.md
@@ -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. `Accept` →
+`"(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 `` 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.
diff --git a/.agents/docs/api-contract.md b/.agents/docs/api-contract.md
new file mode 100644
index 0000000..055cc52
--- /dev/null
+++ b/.agents/docs/api-contract.md
@@ -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]].
diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md
index b579160..aef09fa 100644
--- a/.agents/docs/ink-divergences.md
+++ b/.agents/docs/ink-divergences.md
@@ -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 `` is a `defineComponent`, the `$el` path is in
fact the **primary** path a normal `ref` on `` 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 `` 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
- `` 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)
diff --git a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx
index 774b47f..034afae 100644
--- a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx
+++ b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx
@@ -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 {
diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx
index 9ec2cd1..7401281 100644
--- a/packages/runtime-tests/integration/components/background-color.test.tsx
+++ b/packages/runtime-tests/integration/components/background-color.test.tsx
@@ -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";
diff --git a/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx b/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx
index 04c8845..1b4e062 100644
--- a/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx
+++ b/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx
@@ -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 () => {String(rows.value)};
});
diff --git a/packages/runtime-tests/integration/composables/terminal-size.test.tsx b/packages/runtime-tests/integration/composables/terminal-size.test.tsx
index 2b053a6..9213f0d 100644
--- a/packages/runtime-tests/integration/composables/terminal-size.test.tsx
+++ b/packages/runtime-tests/integration/composables/terminal-size.test.tsx
@@ -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 () => (
{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 () => (
{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 () => (
{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 () => (
{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 () => (
{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 () => {columns.value};
});
@@ -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 () => {columns.value};
});
@@ -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 () => {String(columns.value)};
});
@@ -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 () => (
{columns.value}x{rows.value}
diff --git a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx
index 27d152a..4e8c901 100644
--- a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx
+++ b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx
@@ -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 () => (
Width: {width.value}
@@ -407,7 +407,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
});
const { height } = useBoxMetrics(trackedRef);
- useTerminalSize();
+ useWindowSize();
return () => (
@@ -636,7 +636,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
const App = defineComponent(() => {
const boxRef = ref(null);
useBoxMetrics(boxRef);
- useTerminalSize();
+ useWindowSize();
return () => (
Hello
diff --git a/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx b/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx
index 49fed5b..73f760a 100644
--- a/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx
+++ b/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx
@@ -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
diff --git a/packages/runtime-tests/integration/public-api.test.ts b/packages/runtime-tests/integration/public-api.test.ts
index 8325021..4598850 100644
--- a/packages/runtime-tests/integration/public-api.test.ts
+++ b/packages/runtime-tests/integration/public-api.test.ts
@@ -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 [
- // Entry point
- "createApp",
- // Components
- "Box",
- "Text",
- "Newline",
- "Spacer",
- "Static",
- "Transform",
- // Composables
- "useApp",
- "useInput",
- "useFocus",
- "useFocusManager",
- "useStdin",
- "useStdout",
- "useStderr",
- "useTerminalSize",
- "useWindowSize",
- "useCursor",
- "useIsScreenReaderEnabled",
- "useAnimation",
- "useBoxMetrics",
- "measureElement",
- "usePaste",
- // Rendering
- "renderToString",
- "renderScreenReaderOutput",
- // Kitty keyboard
- "kittyFlags",
- "kittyModifiers",
- ]) {
- expect(api).toHaveProperty(k);
- }
-});
+// 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",
+ "Newline",
+ "Spacer",
+ "Static",
+ "Text",
+ "Transform",
+ // Composables
+ "useAnimation",
+ "useApp",
+ "useBoxMetrics",
+ "useCursor",
+ "useFocus",
+ "useFocusManager",
+ "useInput",
+ "useIsScreenReaderEnabled",
+ "usePaste",
+ "useStderr",
+ "useStdin",
+ "useStdout",
+ "useWindowSize",
+ "measureElement",
+ // Rendering
+ "renderToString",
+ // Kitty keyboard
+ "kittyFlags",
+ "kittyModifiers",
+];
-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;
diff --git a/packages/runtime-tests/integration/render-to-string.test.tsx b/packages/runtime-tests/integration/render-to-string.test.tsx
index f7837d8..25e4611 100644
--- a/packages/runtime-tests/integration/render-to-string.test.tsx
+++ b/packages/runtime-tests/integration/render-to-string.test.tsx
@@ -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 () => size {columns.value > 0 && rows.value > 0 ? "ok" : "fallback"};
});
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);
diff --git a/packages/runtime/src/composables/useAnimation.ts b/packages/runtime/src/composables/useAnimation.ts
index 8bc4301..169e21b 100644
--- a/packages/runtime/src/composables/useAnimation.ts
+++ b/packages/runtime/src/composables/useAnimation.ts
@@ -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 {
*
* ```
*/
-export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn {
+export function useAnimation(options: UseAnimationOptions = {}): UseAnimationReturn {
const frame = shallowRef(0);
const time = shallowRef(0);
const delta = shallowRef(0);
diff --git a/packages/runtime/src/composables/useTerminalSize.ts b/packages/runtime/src/composables/useWindowSize.ts
similarity index 81%
rename from packages/runtime/src/composables/useTerminalSize.ts
rename to packages/runtime/src/composables/useWindowSize.ts
index ac0bf22..153a524 100644
--- a/packages/runtime/src/composables/useTerminalSize.ts
+++ b/packages/runtime/src/composables/useWindowSize.ts
@@ -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; rows: ShallowRef }`). 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; rows: ShallowRef } {
+export function useWindowSize(): { columns: ShallowRef; rows: ShallowRef } {
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; rows: ShallowR
onScopeDispose(() => ctx.stdout.off("resize", onResize));
return { columns, rows };
}
-
-/** Alias for `useTerminalSize`. */
-export const useWindowSize = useTerminalSize;
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 2fb75f1..a4dd148 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -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.
diff --git a/packages/runtime/src/internal.ts b/packages/runtime/src/internal.ts
index b368915..880d2eb 100644
--- a/packages/runtime/src/internal.ts
+++ b/packages/runtime/src/internal.ts
@@ -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,
diff --git a/packages/runtime/src/render-to-string.ts b/packages/runtime/src/render-to-string.ts
index 92b07e9..e9df454 100644
--- a/packages/runtime/src/render-to-string.ts
+++ b/packages/runtime/src/render-to-string.ts
@@ -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;
diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts
index 8dd6eac..96e3f69 100644
--- a/packages/runtime/src/render.ts
+++ b/packages/runtime/src/render.ts
@@ -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;