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, `40b3a7578811fd616341ca4e31cc7748aeeff12f`). When bumping the target Ink version,
re-validate every entry below against the new source. 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 ## How to Classify a Divergence
Classify each divergence by the first rule that applies. The order matters: earlier 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 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 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; 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. 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;
### `renderToString` supports screen-reader mode the bare host-node ref stays Ink-identical).
- **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.
### Two apps sharing one stdin both receive input ### 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` return `void`, plain `boolean`, or small unexported inline shapes — never an `XProps`
type. `XProps` is reserved for component props (`BoxProps`/`TextProps`, derived via type. `XProps` is reserved for component props (`BoxProps`/`TextProps`, derived via
`ExtractPublicPropTypes`). `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 - **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 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 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. Vue users expect for slot payloads. The rendered item/index values remain equivalent.
Maintainer decision (2026-06-06): KEEP. 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 ## Intentional Divergence Choices
These divergences are deliberate, but they are not strict supersets and are not primarily 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 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` (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 (`'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) ### `useCursor()` re-asserts the declared caret every commit (persistent declaration)
@@ -1,6 +1,6 @@
import { defineComponent, nextTick, shallowRef, type FunctionalComponent } from "vue"; import { defineComponent, nextTick, shallowRef, type FunctionalComponent } from "vue";
import { describe, expect, test } from "vite-plus/test"; 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 { render } from "@vue-tui/testing";
import { import {
createRoot, createRoot,
@@ -9,6 +9,7 @@ import {
createTextLeaf, createTextLeaf,
attachYoga, attachYoga,
renderScreenReaderOutput, renderScreenReaderOutput,
renderToStringWithScreenReader as renderToString,
type AppContext, type AppContext,
} from "@vue-tui/runtime/internal"; } from "@vue-tui/runtime/internal";
import { import {
@@ -1,7 +1,8 @@
import { defineComponent, shallowRef, nextTick, h } from "vue"; import { defineComponent, shallowRef, nextTick, h } from "vue";
import { test } from "vite-plus/test"; import { test } from "vite-plus/test";
import { render } from "@vue-tui/testing"; 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_BLUE = "\x1b[44m";
const BG_CYAN = "\x1b[46m"; const BG_CYAN = "\x1b[46m";
@@ -7,7 +7,7 @@ import { PassThrough } from "node:stream";
import process from "node:process"; import process from "node:process";
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test"; 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 { function makeTtyStream(columns: number): NodeJS.WriteStream {
const s = new PassThrough() as unknown as 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 // 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 // no rows, resolveSize() calls terminal-size, which — after we zero out the real
// process.stdout/stderr dimensions — resolves rows from process.env.LINES. // 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 stdout = makeTtyStream(0);
const stderr = makeTtyStream(0); const stderr = makeTtyStream(0);
const stdin = makeFakeStdin(); const stdin = makeFakeStdin();
@@ -50,7 +50,7 @@ test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES
let capturedRows = -1; let capturedRows = -1;
const App = defineComponent(() => { const App = defineComponent(() => {
const { rows } = useTerminalSize(); const { rows } = useWindowSize();
capturedRows = rows.value; capturedRows = rows.value;
return () => <Text>{String(rows.value)}</Text>; return () => <Text>{String(rows.value)}</Text>;
}); });
@@ -2,7 +2,7 @@ import { PassThrough } from "node:stream";
import { defineComponent, onScopeDispose } from "vue"; import { defineComponent, onScopeDispose } from "vue";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing"; 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) // A TTY-like writable that we control directly (columns/rows + resize listeners)
// — the @vue-tui/testing render() helper hides the underlying stdout, but the // — the @vue-tui/testing render() helper hides the underlying stdout, but the
@@ -31,9 +31,9 @@ function makeFakeStdin(): NodeJS.ReadStream {
return s; return s;
} }
test("useTerminalSize reacts to resize event", async () => { test("useWindowSize reacts to resize event", async () => {
const App = defineComponent(() => { const App = defineComponent(() => {
const { columns, rows } = useTerminalSize(); const { columns, rows } = useWindowSize();
return () => ( return () => (
<Text> <Text>
{columns.value}x{rows.value} {columns.value}x{rows.value}
@@ -48,9 +48,9 @@ test("useTerminalSize reacts to resize event", async () => {
expect(lastFrame()).toContain("120x40"); expect(lastFrame()).toContain("120x40");
}); });
test("useTerminalSize returns initial terminal dimensions", async () => { test("useWindowSize returns initial terminal dimensions", async () => {
const App = defineComponent(() => { const App = defineComponent(() => {
const { columns, rows } = useTerminalSize(); const { columns, rows } = useWindowSize();
return () => ( return () => (
<Text> <Text>
{columns.value}x{rows.value} {columns.value}x{rows.value}
@@ -62,10 +62,10 @@ test("useTerminalSize returns initial terminal dimensions", async () => {
expect(lastFrame()).toContain("100x40"); 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 // After unmount, further resize events should not cause errors
const App = defineComponent(() => { const App = defineComponent(() => {
const { columns, rows } = useTerminalSize(); const { columns, rows } = useWindowSize();
return () => ( return () => (
<Text> <Text>
{columns.value}x{rows.value} {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(); 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 App = defineComponent(() => {
const { columns, rows } = useTerminalSize(); const { columns, rows } = useWindowSize();
return () => ( return () => (
<Text> <Text>
{columns.value}x{rows.value} {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 () => { test("multiple consecutive resizes all take effect", async () => {
const App = defineComponent(() => { const App = defineComponent(() => {
const { columns, rows } = useTerminalSize(); const { columns, rows } = useWindowSize();
return () => ( return () => (
<Text> <Text>
{columns.value}x{rows.value} {columns.value}x{rows.value}
@@ -145,7 +145,7 @@ test("multiple consecutive resizes all take effect", async () => {
test("terminal width decrease triggers rerender", async () => { test("terminal width decrease triggers rerender", async () => {
const App = defineComponent(() => { const App = defineComponent(() => {
const { columns } = useTerminalSize(); const { columns } = useWindowSize();
return () => <Text>{columns.value}</Text>; return () => <Text>{columns.value}</Text>;
}); });
@@ -158,7 +158,7 @@ test("terminal width decrease triggers rerender", async () => {
test("terminal width increase triggers rerender", async () => { test("terminal width increase triggers rerender", async () => {
const App = defineComponent(() => { const App = defineComponent(() => {
const { columns } = useTerminalSize(); const { columns } = useWindowSize();
return () => <Text>{columns.value}</Text>; return () => <Text>{columns.value}</Text>;
}); });
@@ -173,9 +173,9 @@ test("resize listener is cleaned up via onScopeDispose", async () => {
let disposeCalled = false; let disposeCalled = false;
const App = defineComponent(() => { 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. // we also register one to verify the scope is properly disposed on unmount.
useTerminalSize(); useWindowSize();
onScopeDispose(() => { onScopeDispose(() => {
disposeCalled = true; 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, // 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 // resolveSize() falls through to the terminal-size package / 80 default, so the
// captured value must be a positive number (never 0). // 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 stdout = makeTtyStream(0, 24);
const stderr = makeTtyStream(0, 24); const stderr = makeTtyStream(0, 24);
const stdin = makeFakeStdin(); const stdin = makeFakeStdin();
let capturedColumns = -1; let capturedColumns = -1;
const App = defineComponent(() => { const App = defineComponent(() => {
const { columns } = useTerminalSize(); const { columns } = useWindowSize();
capturedColumns = columns.value; capturedColumns = columns.value;
return () => <Text>{String(columns.value)}</Text>; 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"). // 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). // 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 stdout = makeTtyStream(80, 24);
const stderr = makeTtyStream(80, 24); const stderr = makeTtyStream(80, 24);
const stdin = makeFakeStdin(); const stdin = makeFakeStdin();
@@ -227,7 +227,7 @@ test("useTerminalSize resize listener returns to baseline on unmount", async ()
const baseline = stdout.listenerCount("resize"); const baseline = stdout.listenerCount("resize");
const App = defineComponent(() => { const App = defineComponent(() => {
const { columns, rows } = useTerminalSize(); const { columns, rows } = useWindowSize();
return () => ( return () => (
<Text> <Text>
{columns.value}x{rows.value} {columns.value}x{rows.value}
@@ -1,7 +1,7 @@
import { defineComponent, nextTick, ref, shallowRef, watchEffect, watchPostEffect } from "vue"; import { defineComponent, nextTick, ref, shallowRef, watchEffect, watchPostEffect } from "vue";
import { describe, expect, test } from "vite-plus/test"; import { describe, expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing"; 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", () => { describe("useBoxMetrics", () => {
test("returns layout dimensions after render", async () => { test("returns layout dimensions after render", async () => {
@@ -379,7 +379,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
const App = defineComponent(() => { const App = defineComponent(() => {
const boxRef = ref(null); const boxRef = ref(null);
const { width } = useBoxMetrics(boxRef); const { width } = useBoxMetrics(boxRef);
useTerminalSize(); useWindowSize();
return () => ( return () => (
<Box ref={boxRef}> <Box ref={boxRef}>
<Text>Width: {width.value}</Text> <Text>Width: {width.value}</Text>
@@ -407,7 +407,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
}); });
const { height } = useBoxMetrics(trackedRef); const { height } = useBoxMetrics(trackedRef);
useTerminalSize(); useWindowSize();
return () => ( return () => (
<Box flexDirection="column"> <Box flexDirection="column">
@@ -636,7 +636,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
const App = defineComponent(() => { const App = defineComponent(() => {
const boxRef = ref(null); const boxRef = ref(null);
useBoxMetrics(boxRef); useBoxMetrics(boxRef);
useTerminalSize(); useWindowSize();
return () => ( return () => (
<Box ref={boxRef}> <Box ref={boxRef}>
<Text>Hello</Text> <Text>Hello</Text>
@@ -1,7 +1,8 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing"; 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 // 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 // `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 { expect, test } from "vite-plus/test";
import * as api from "@vue-tui/runtime"; import * as api from "@vue-tui/runtime";
import * as internalApi from "@vue-tui/runtime/internal";
test("public API exposes documented members", () => { // The EXACT public runtime (value) export surface of `@vue-tui/runtime`. The test below snapshots
for (const k of [ // 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 // Entry point
"createApp", "createApp",
// Components // Components
"Box", "Box",
"Text",
"Newline", "Newline",
"Spacer", "Spacer",
"Static", "Static",
"Text",
"Transform", "Transform",
// Composables // Composables
"useAnimation",
"useApp", "useApp",
"useInput", "useBoxMetrics",
"useCursor",
"useFocus", "useFocus",
"useFocusManager", "useFocusManager",
"useInput",
"useIsScreenReaderEnabled",
"usePaste",
"useStderr",
"useStdin", "useStdin",
"useStdout", "useStdout",
"useStderr",
"useTerminalSize",
"useWindowSize", "useWindowSize",
"useCursor",
"useIsScreenReaderEnabled",
"useAnimation",
"useBoxMetrics",
"measureElement", "measureElement",
"usePaste",
// Rendering // Rendering
"renderToString", "renderToString",
"renderScreenReaderOutput",
// Kitty keyboard // Kitty keyboard
"kittyFlags", "kittyFlags",
"kittyModifiers", "kittyModifiers",
]) { ];
expect(api).toHaveProperty(k);
}
});
test("useWindowSize is an alias for useTerminalSize", () => { test("public API surface is exactly the documented value-export set", () => {
expect(api.useWindowSize).toBe(api.useTerminalSize); 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 // 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("measureText");
expect(api).not.toHaveProperty("measureTextNatural"); 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, useStderr,
useCursor, useCursor,
usePaste, usePaste,
useTerminalSize, useWindowSize,
useAnimation, useAnimation,
useBoxMetrics, useBoxMetrics,
} from "@vue-tui/runtime"; } from "@vue-tui/runtime";
@@ -650,7 +650,7 @@ describe("renderToString", () => {
// StdinContext + a no-op AnimationScheduler (render-to-string.ts:93-96). The // StdinContext + a no-op AnimationScheduler (render-to-string.ts:93-96). The
// existing suite covers useInput/useApp/useFocus/useFocusManager/useStdin/ // existing suite covers useInput/useApp/useFocus/useFocusManager/useStdin/
// useStdout/useStderr. These pin the remaining terminal composables — // 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 // rendering a component which CALLS them degrades to inert values instead of
// throwing (they must still return a string). // throwing (they must still return a string).
describe("terminal composables degrade to no-ops (do not throw)", () => { describe("terminal composables degrade to no-ops (do not throw)", () => {
@@ -681,11 +681,11 @@ describe("renderToString", () => {
expect(pasted).toBe(""); expect(pasted).toBe("");
}); });
test("useTerminalSize does not throw in renderToString", () => { test("useWindowSize does not throw in renderToString", () => {
const App = defineComponent(() => { const App = defineComponent(() => {
// Resolves dimensions from ctx.stdout (process.stdout in the no-op // Resolves dimensions from ctx.stdout (process.stdout in the no-op
// context) with the terminal-size fallback; never throws. // 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>; return () => <Text>size {columns.value > 0 && rows.value > 0 ? "ok" : "fallback"}</Text>;
}); });
const output = renderToString(App); const output = renderToString(App);
@@ -726,7 +726,7 @@ describe("renderToString", () => {
const { setCursorPosition } = useCursor(); const { setCursorPosition } = useCursor();
setCursorPosition({ x: 1, y: 0 }); setCursorPosition({ x: 1, y: 0 });
usePaste(() => {}); usePaste(() => {});
useTerminalSize(); useWindowSize();
const { frame } = useAnimation({ interval: 30 }); const { frame } = useAnimation({ interval: 30 });
const boxRef = shallowRef(null); const boxRef = shallowRef(null);
useBoxMetrics(boxRef); useBoxMetrics(boxRef);
@@ -14,7 +14,7 @@ import {
} from "../animation-scheduler.ts"; } from "../animation-scheduler.ts";
import { AnimationSchedulerKey } from "../context.ts"; import { AnimationSchedulerKey } from "../context.ts";
export interface AnimationOptions { export interface UseAnimationOptions {
/** /**
* Time between ticks in milliseconds. * Time between ticks in milliseconds.
* *
@@ -85,7 +85,7 @@ export interface UseAnimationReturn {
* </template> * </template>
* ``` * ```
*/ */
export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn { export function useAnimation(options: UseAnimationOptions = {}): UseAnimationReturn {
const frame = shallowRef(0); const frame = shallowRef(0);
const time = shallowRef(0); const time = shallowRef(0);
const delta = shallowRef(0); const delta = shallowRef(0);
@@ -7,8 +7,8 @@ import { AppContextKey } from "../context.ts";
* (`columns`/`rows`, not `width`/`height`). * (`columns`/`rows`, not `width`/`height`).
* *
* Note the Vue-vs-React shape: Ink's `useWindowSize()` returns a `WindowSize` * Note the Vue-vs-React shape: Ink's `useWindowSize()` returns a `WindowSize`
* snapshot, whereas vue-tui's `useWindowSize()` / `useTerminalSize()` return * snapshot, whereas vue-tui's `useWindowSize()` returns reactive **refs** of
* reactive **refs** of these dimensions * these dimensions
* (`{ columns: ShallowRef<number>; rows: ShallowRef<number> }`). The data shape * (`{ columns: ShallowRef<number>; rows: ShallowRef<number> }`). The data shape
* is the same; the reactivity wrapper is the framework difference. * 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); 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 initial = resolveSize(ctx.stdout);
const columns = shallowRef(initial.columns); const columns = shallowRef(initial.columns);
const rows = shallowRef(initial.rows); const rows = shallowRef(initial.rows);
@@ -51,6 +51,3 @@ export function useTerminalSize(): { columns: ShallowRef<number>; rows: ShallowR
onScopeDispose(() => ctx.stdout.off("resize", onResize)); onScopeDispose(() => ctx.stdout.off("resize", onResize));
return { columns, rows }; 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 { useStdin, type UseStdinReturn } from "./composables/useStdin.ts";
export { useStdout, type UseStdoutReturn } from "./composables/useStdout.ts"; export { useStdout, type UseStdoutReturn } from "./composables/useStdout.ts";
export { useStderr, type UseStderrReturn } from "./composables/useStderr.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 { useCursor, type CursorPosition } from "./composables/useCursor.ts";
export { useIsScreenReaderEnabled } from "./composables/useIsScreenReaderEnabled.ts"; export { useIsScreenReaderEnabled } from "./composables/useIsScreenReaderEnabled.ts";
export { export {
useAnimation, useAnimation,
type AnimationOptions, type UseAnimationOptions,
type UseAnimationReturn, type UseAnimationReturn,
} from "./composables/useAnimation.ts"; } from "./composables/useAnimation.ts";
export { export {
@@ -44,8 +44,6 @@ export {
type BoxMetrics, type BoxMetrics,
type UseBoxMetricsReturn, type UseBoxMetricsReturn,
} from "./composables/useBoxMetrics.ts"; } from "./composables/useBoxMetrics.ts";
export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.ts";
export type { DevState, DevErrorInfo } from "./hmr.ts";
export { export {
kittyFlags, kittyFlags,
kittyModifiers, kittyModifiers,
@@ -55,3 +53,7 @@ export {
// `measureText` / `measureTextNatural` are deliberately NOT re-exported: Ink keeps // `measureText` / `measureTextNatural` are deliberately NOT re-exported: Ink keeps
// its `measure-text` module internal, and so do we. They remain internal helpers // its `measure-text` module internal, and so do we. They remain internal helpers
// (yoga.ts uses `measureTextNatural`). See .agents/docs/ink-divergences.md. // (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, type TuiNode,
} from "./host/nodes.ts"; } from "./host/nodes.ts";
export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.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 type { AppContext } from "./context.ts";
export { export {
createKittyKeyboardController, createKittyKeyboardController,
+30
View File
@@ -28,6 +28,16 @@ export interface RenderToStringOptions {
* @default 80 * @default 80
*/ */
columns?: number; 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 * Enable screen reader mode. When enabled, the output is plain text
* suitable for screen readers (no ANSI styling, with role/state annotations). * suitable for screen readers (no ANSI styling, with role/state annotations).
@@ -58,6 +68,26 @@ export interface RenderToStringOptions {
* caller after cleanup. * caller after cleanup.
*/ */
export function renderToString(component: Component, options?: RenderToStringOptions): string { 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 columns = options?.columns ?? 80;
const isScreenReaderEnabled = options?.isScreenReaderEnabled ?? false; const isScreenReaderEnabled = options?.isScreenReaderEnabled ?? false;
+1 -1
View File
@@ -45,7 +45,7 @@ import {
import { devState, DevStateKey, initHmrBridge } from "./hmr.ts"; import { devState, DevStateKey, initHmrBridge } from "./hmr.ts";
import { createDevOverlayWrapper } from "./overlay.ts"; import { createDevOverlayWrapper } from "./overlay.ts";
import { ErrorOverview, messageForNonError } from "./components/error-overview.ts"; import { ErrorOverview, messageForNonError } from "./components/error-overview.ts";
import { resolveSize } from "./composables/useTerminalSize.ts"; import { resolveSize } from "./composables/useWindowSize.ts";
export interface MountOptions { export interface MountOptions {
stdout?: NodeJS.WriteStream; stdout?: NodeJS.WriteStream;