refactor(runtime)!: public-API audit follow-ups — align to Ink, record decisions (#163)
* refactor(runtime)!: rename AnimationOptions to UseAnimationOptions
Align the useAnimation options type with VueUse's UseXOptions convention, matching its sibling composable options bags (UseInputOptions / UsePasteOptions / UseFocusOptions) and the already-correct UseAnimationReturn. Hard rename, no deprecated alias — done while the package is pre-1.0 (0.0.x), so no stability break.
Recorded under "Public composable naming follows Vue conventions" in .agents/docs/ink-divergences.md. Surfaced by the public-API audit.
BREAKING CHANGE: the exported type AnimationOptions is renamed to UseAnimationOptions; update `import { type AnimationOptions }` to `import { type UseAnimationOptions }`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: tighten public API to Ink + record aria decision & alignment principle
Public-API audit follow-ups. Where vue-tui had drifted from Ink with no real Vue reason, align to Ink; reduce speculative surface; and record decisions in .agents/docs/ink-divergences.md.
- renderToString: drop the public `isScreenReaderEnabled` option (Ink's public renderToString is layout-only). The SR-capable variant moves to `@vue-tui/runtime/internal` as `renderToStringWithScreenReader` for the accessibility test suite; SR output is unchanged.
- useTerminalSize -> useWindowSize: drop the invented name + alias, align to Ink's `useWindowSize`. The reactive ref return shape is unchanged (shallowRef divergence still applies).
- DevState/DevErrorInfo: move from the public barrel to `@vue-tui/runtime/internal` (internal HMR types, no public consumer; Ink exposes no HMR types).
- docs(divergences): add a standing "Why align to Ink — and when not to" principle (alignment is a means to reduce bugs, not an end; Vue idiom + reasonableness outrank parity); record the aria-props camelCase decision with its run-verified type-safety boundary; stamp the rawMode-default and measureElement-$el entries with their KEEP decisions.
BREAKING CHANGE: removed public exports `useTerminalSize`, `DevState`, `DevErrorInfo`, and `renderToString`'s `isScreenReaderEnabled` option. Use `useWindowSize`; import HMR types from `@vue-tui/runtime/internal`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: move renderScreenReaderOutput to /internal-only
The screen-reader linearizer (ported from Ink's internal
`renderNodeToScreenReaderOutput`) was exported from the public barrel, but it
was never usefully public: its only parameter type `TuiNode` and the
node-construction primitives needed to build one are not public, so a public
consumer could not name or construct the argument. Ink keeps its counterpart
module-internal; we match that.
`renderScreenReaderOutput` + `ScreenReaderOptions` now live only in
`@vue-tui/runtime/internal` (already re-exported there). The live SR machinery
(render, the internal renderToStringWithScreenReader, the <Static> channel)
imports from the source module and is unaffected; public SR output is reached
via the mount `isScreenReaderEnabled` option.
public-api.test.ts: drop it from the public-members list; add a runtime guard
(absent from public, present on /internal) plus a compile-time @ts-expect-error
guard that the `ScreenReaderOptions` type cannot be re-added to the public
barrel.
Docs: new .agents/docs/accessibility-api.md (aria + SR design) and
api-contract.md (public surface = exports + their user-consumable types;
/internal is not the contract); resolve the open item and cross-link from
ink-divergences.md.
BREAKING CHANGE: renderScreenReaderOutput and ScreenReaderOptions are no longer
exported from @vue-tui/runtime; import from @vue-tui/runtime/internal if needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(runtime-tests): snapshot the exact public value-export set
Upgrade public-api.test.ts from "documented members present + targeted
negatives" to an exhaustive snapshot of the exact runtime value-export surface
of `@vue-tui/runtime`: adding, removing, or renaming any value export now fails
the test, so every public-surface change must be a deliberate edit to the list.
Type-only exports are erased at runtime and cannot be enumerated, so the type
surface stays guarded individually (the `@ts-expect-error` ScreenReaderOptions
guard); api-contract.md is updated to state this boundary precisely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
# Accessibility (ARIA + screen-reader) API
|
||||||
|
|
||||||
|
How vue-tui exposes ARIA and renders screen-reader (SR) output, and why. Companion to the terse
|
||||||
|
blessed entries in [[ink-divergences]] (aria props are camelCase; `renderToString` is layout-only;
|
||||||
|
`useWindowSize`); this file keeps the _why_ and the researched / run-verified findings the ledger
|
||||||
|
entries deliberately omit, so they are not re-derived expensively. The exported aria types are
|
||||||
|
part of the public contract — see [[api-contract]].
|
||||||
|
|
||||||
|
## The constraint that shapes everything
|
||||||
|
|
||||||
|
vue-tui renders to a terminal — **no DOM, no browser accessibility tree.** To support screen
|
||||||
|
readers it must READ aria semantics off its own components and GENERATE the linearized SR text
|
||||||
|
itself (e.g. `<Box aria-role="checkbox" aria-state={{ checked: true }}>Accept</Box>` →
|
||||||
|
`"(checked) checkbox: Accept"`). So aria values must reach the framework as something it can read
|
||||||
|
at the component boundary — i.e. **component props** — exactly like Ink, which is also a no-DOM
|
||||||
|
renderer. The mainstream Vue a11y pattern (let `aria-*` fall through to the DOM as kebab
|
||||||
|
attributes and let the browser interpret them) is structurally unavailable here: there is no DOM
|
||||||
|
to receive them and no browser to read them.
|
||||||
|
|
||||||
|
## Naming: typed camelCase props (kebab works at runtime)
|
||||||
|
|
||||||
|
- Props are camelCase — `ariaLabel` / `ariaHidden` / `ariaRole` / `ariaState` — with exported
|
||||||
|
`AriaRole` (union) and `AriaState` (object) types, field-for-field identical to Ink's.
|
||||||
|
- Ink uses kebab string-literal prop KEYS (`'aria-role'`). vue-tui does not, because Vue's prop
|
||||||
|
convention is camelCase AND camelCase is the only spelling the type-checker validates (below).
|
||||||
|
- Ink's kebab spelling still works at runtime: Vue camelizes an incoming kebab attribute onto the
|
||||||
|
declared prop, and `node-ops` accepts both `aria-role` and `ariaRole` keys on the host node. So
|
||||||
|
`aria-role` ports from Ink/HTML unchanged — it is the runtime-compatible escape, not the
|
||||||
|
type-safe path.
|
||||||
|
- This is a Vue-idiom + reasonableness choice, **not parity** (Ink is kebab). See the
|
||||||
|
"Why align to Ink — and when not to" principle in [[ink-divergences]].
|
||||||
|
|
||||||
|
## Type-safety boundary (run-verified: `tsc` + `vue-tsc`)
|
||||||
|
|
||||||
|
camelCase is the ONLY spelling that is compile-checked, and it is checked in both authoring
|
||||||
|
contexts:
|
||||||
|
|
||||||
|
- **TSX (`tsc`)** and **templates (`vue-tsc`)**: a bad value (`ariaRole="notarole"`), a typo
|
||||||
|
(`ariaRol`), or an unknown / compound-misspelled name all produce a COMPILE ERROR.
|
||||||
|
- **kebab `aria-*` is NOT compile-checked** in either context: Vue/Volar treat `aria-*` (and
|
||||||
|
`data-*`) as always-valid global attributes, so they bypass prop-matching. Control proving the
|
||||||
|
hole is `aria-*`-specific (not general fallthrough): a non-aria kebab like `border-style` IS
|
||||||
|
checked — Volar camelizes it to `borderStyle` and validates the value.
|
||||||
|
|
||||||
|
→ **The type-safe spelling is camelCase**; `aria-role` is the runtime-only porting escape the
|
||||||
|
compiler cannot guard. To reproduce: a scratch `.tsx` run through the package `tsc`, and a scratch
|
||||||
|
`.vue` run through `vue-tsc` (the repo has no vue-tsc — install it in an isolated dir), importing
|
||||||
|
`Box` from the built dist, asserting which mis-writes error.
|
||||||
|
|
||||||
|
## The compound-word pit (and the rule)
|
||||||
|
|
||||||
|
camelCase↔kebab is ambiguous for COMPOUND aria words: a human writes `ariaHasPopup`, but Vue's
|
||||||
|
`camelize` derives `ariaHaspopup` from the canonical `aria-haspopup` (the long-open vuejs/core
|
||||||
|
#5477). The current single-word vocabulary (role / label / hidden / state) camelizes losslessly,
|
||||||
|
so the pit is **latent, not live**.
|
||||||
|
|
||||||
|
**Rule:** any future compound aria word must be declared as the mechanical camelize of the kebab
|
||||||
|
name (`ariaHaspopup`, never the human-natural `ariaHasPopup`) or folded into the typed `ariaState`
|
||||||
|
object — never bridged by relying on auto-camelize. In TSX, and in templates via the camelCase
|
||||||
|
spelling, TS catches a wrong compound name; a kebab compound in a template is silent, so camelCase
|
||||||
|
is the guarded path. The cross-field consensus (see precedents) is the same: **never auto-camelize
|
||||||
|
an aria round-trip.**
|
||||||
|
|
||||||
|
## `aria-hidden` modeling
|
||||||
|
|
||||||
|
ARIA's `aria-hidden` is a tristate enumerated STRING (`true` / `false` / `undefined`, default
|
||||||
|
`undefined` = visible) — not a boolean; `aria-hidden="false"` explicitly means _visible_. Ink
|
||||||
|
models it as a plain `boolean` (bare → true) and vue-tui follows that for ergonomics. Known edge
|
||||||
|
(run-verified): the literal string `aria-hidden="false"` currently HIDES (Boolean-prop coercion
|
||||||
|
sees the non-empty string as truthy) where ARIA says visible; bare / `={true}` hide, and
|
||||||
|
`={false}` / omitted are correctly visible. Fixable with an explicit normalize if it ever matters.
|
||||||
|
|
||||||
|
## SR rendering architecture
|
||||||
|
|
||||||
|
- **Live path:** `app.mount({ isScreenReaderEnabled })` (or `INK_SCREEN_READER=true`) makes each
|
||||||
|
commit emit the linearized SR text instead of the ANSI frame.
|
||||||
|
- **`renderToString`:** public, **layout-only** (matches Ink). Its SR-capable variant is
|
||||||
|
`renderToStringWithScreenReader` in `@vue-tui/runtime/internal`, used by the accessibility test
|
||||||
|
suite — the public string API does not surface SR (Ink also keeps its SR-string rendering
|
||||||
|
test-internal).
|
||||||
|
- **`renderScreenReaderOutput(node)`:** the linearizer that walks the host tree's
|
||||||
|
`internal_accessibility`. **`/internal`-only** (maintainer decision 2026-06-14). Ink keeps its
|
||||||
|
counterpart (`renderNodeToScreenReaderOutput`) module-internal and never exports it; we match
|
||||||
|
that. It was never usefully public anyway — its only parameter type (`TuiNode`) and the
|
||||||
|
node-construction primitives needed to build one are not in the public barrel, so a public
|
||||||
|
consumer could not name or construct the argument. No example/README/user path used it; the live
|
||||||
|
SR machinery (`render`, the internal `renderToStringWithScreenReader`, the `<Static>` channel)
|
||||||
|
imports it from the source module, unaffected. Public SR output is reached via the `mount`
|
||||||
|
`isScreenReaderEnabled` option, not by calling this directly.
|
||||||
|
|
||||||
|
## Precedents (condensed) — cross-field consensus
|
||||||
|
|
||||||
|
How other systems shape an aria API, surveyed when settling vue-tui's:
|
||||||
|
|
||||||
|
- **React / Ink:** kebab string-literal prop keys, typed union/object; JSX keys never camelize, so
|
||||||
|
there is no round-trip to disagree. (vue-tui can copy the SHAPE, not the mechanism — Vue
|
||||||
|
camelizes.)
|
||||||
|
- **AccessKit** (drives egui; the strongest other no-DOM precedent): abandons strings for a typed
|
||||||
|
`Role` enum + typed state methods — no kebab to convert at all.
|
||||||
|
- **Vue a11y libraries** (Reka UI / Headless UI / Vuetify): kebab `aria-*` as fallthrough
|
||||||
|
attributes onto the DOM, never declared props — relies on a DOM + browser, so unavailable here.
|
||||||
|
- **Web Components / HTML reflection / Lit:** dual surface bridged by an EXPLICIT curated map
|
||||||
|
(`aria-haspopup` ↔ `ariaHasPopup`, `aria-posinset` ↔ `ariaPosInSet`), never auto-camelize — the
|
||||||
|
platform's own answer to the compound problem, and proof that naive remove-dash-uppercase is
|
||||||
|
wrong.
|
||||||
|
- **WAI-ARIA spec:** aria names are all-lowercase single tokens (`aria-haspopup`, not
|
||||||
|
`aria-has-popup`); `aria-hidden` etc. are tristate strings, not booleans.
|
||||||
|
|
||||||
|
**Consensus across all of them: never auto-camelize an aria round-trip.** vue-tui satisfies it for
|
||||||
|
single-word props (lossless) and the compound-word rule above preserves it.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Public API contract & surface
|
||||||
|
|
||||||
|
What is — and isn't — part of `@vue-tui/runtime`'s public contract, and how the contract is
|
||||||
|
tested. (Behavioral _divergences_ from Ink live in [[ink-divergences]]; this file is about the
|
||||||
|
SHAPE of the public surface itself.)
|
||||||
|
|
||||||
|
## The contract = public exports **and their user-consumable types**
|
||||||
|
|
||||||
|
The public API is everything exported from the main barrel (`@vue-tui/runtime`): components,
|
||||||
|
composables, entry points — **and their TYPES** (component prop types, composable return/options
|
||||||
|
types, and named types such as `AriaRole`, `WindowSize`, `BoxProps`, `UseXReturn` / `UseXOptions`).
|
||||||
|
|
||||||
|
A type is **as much a part of the contract as runtime behavior**. If user code can name a type
|
||||||
|
and annotate with it, renaming or removing it breaks that code at COMPILE time — exactly as
|
||||||
|
severe as a runtime break. So a type rename/removal is a breaking change, and the type surface is
|
||||||
|
the most important part of the contract to get right.
|
||||||
|
|
||||||
|
Because it is contract, it is **tested, not merely shipped**:
|
||||||
|
|
||||||
|
- `public-api.test.ts` snapshots the **exact** public value-export set — adding, removing, or
|
||||||
|
renaming any runtime export fails it, so every surface change must be a deliberate edit there.
|
||||||
|
Specific internal-only members (`measureText`, the screen-reader linearizer) are additionally
|
||||||
|
tripwired, and internal-only _types_ (e.g. `ScreenReaderOptions`) are guarded with a compile-time
|
||||||
|
`@ts-expect-error`. Type-only exports are erased at runtime, so the _type_ surface is guarded
|
||||||
|
individually rather than exhaustively snapshotted.
|
||||||
|
- Type-_safety_ behavior is established by RUNNING the type-checker against real usage (`tsc` for
|
||||||
|
TSX, `vue-tsc` for templates), never assumed. See [[accessibility-api]] for a worked example —
|
||||||
|
which aria spellings the compiler does and does not catch, proven with both tools.
|
||||||
|
|
||||||
|
## `/internal` is NOT the contract
|
||||||
|
|
||||||
|
`@vue-tui/runtime/internal` is an explicitly internal / advanced surface — host-node types
|
||||||
|
(`TuiNode`, …), test-only helpers (`renderToStringWithScreenReader`), dev/HMR types (`DevState`,
|
||||||
|
`DevErrorInfo`), kitty-controller internals, etc. It carries **no stability guarantee**, is not
|
||||||
|
covered by `public-api.test.ts`, and may change freely between releases.
|
||||||
|
|
||||||
|
Placement rule for any export:
|
||||||
|
|
||||||
|
- A user-facing contract → the **main barrel** (and it is tested).
|
||||||
|
- Needed only by tests or advanced integrators → **`/internal`**, never the main barrel.
|
||||||
|
|
||||||
|
Packaging/build internals (the `exports` field shape, `.mjs` paths, `dist` layout) are likewise
|
||||||
|
**not** part of the behavioral/type contract and are not aligned to Ink — see the alignment-scope
|
||||||
|
note in [[ink-divergences]].
|
||||||
@@ -13,6 +13,33 @@ Reference baseline: Ink **v7.0.4** (commit
|
|||||||
`40b3a7578811fd616341ca4e31cc7748aeeff12f`). When bumping the target Ink version,
|
`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);
|
||||||
|
|||||||
+4
-7
@@ -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;
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user