diff --git a/.agents/docs/accessibility-api.md b/.agents/docs/accessibility-api.md new file mode 100644 index 0000000..b44fb03 --- /dev/null +++ b/.agents/docs/accessibility-api.md @@ -0,0 +1,110 @@ +# Accessibility (ARIA + screen-reader) API + +How vue-tui exposes ARIA and renders screen-reader (SR) output, and why. Companion to the terse +blessed entries in [[ink-divergences]] (aria props are camelCase; `renderToString` is layout-only; +`useWindowSize`); this file keeps the _why_ and the researched / run-verified findings the ledger +entries deliberately omit, so they are not re-derived expensively. The exported aria types are +part of the public contract — see [[api-contract]]. + +## The constraint that shapes everything + +vue-tui renders to a terminal — **no DOM, no browser accessibility tree.** To support screen +readers it must READ aria semantics off its own components and GENERATE the linearized SR text +itself (e.g. `Accept` → +`"(checked) checkbox: Accept"`). So aria values must reach the framework as something it can read +at the component boundary — i.e. **component props** — exactly like Ink, which is also a no-DOM +renderer. The mainstream Vue a11y pattern (let `aria-*` fall through to the DOM as kebab +attributes and let the browser interpret them) is structurally unavailable here: there is no DOM +to receive them and no browser to read them. + +## Naming: typed camelCase props (kebab works at runtime) + +- Props are camelCase — `ariaLabel` / `ariaHidden` / `ariaRole` / `ariaState` — with exported + `AriaRole` (union) and `AriaState` (object) types, field-for-field identical to Ink's. +- Ink uses kebab string-literal prop KEYS (`'aria-role'`). vue-tui does not, because Vue's prop + convention is camelCase AND camelCase is the only spelling the type-checker validates (below). +- Ink's kebab spelling still works at runtime: Vue camelizes an incoming kebab attribute onto the + declared prop, and `node-ops` accepts both `aria-role` and `ariaRole` keys on the host node. So + `aria-role` ports from Ink/HTML unchanged — it is the runtime-compatible escape, not the + type-safe path. +- This is a Vue-idiom + reasonableness choice, **not parity** (Ink is kebab). See the + "Why align to Ink — and when not to" principle in [[ink-divergences]]. + +## Type-safety boundary (run-verified: `tsc` + `vue-tsc`) + +camelCase is the ONLY spelling that is compile-checked, and it is checked in both authoring +contexts: + +- **TSX (`tsc`)** and **templates (`vue-tsc`)**: a bad value (`ariaRole="notarole"`), a typo + (`ariaRol`), or an unknown / compound-misspelled name all produce a COMPILE ERROR. +- **kebab `aria-*` is NOT compile-checked** in either context: Vue/Volar treat `aria-*` (and + `data-*`) as always-valid global attributes, so they bypass prop-matching. Control proving the + hole is `aria-*`-specific (not general fallthrough): a non-aria kebab like `border-style` IS + checked — Volar camelizes it to `borderStyle` and validates the value. + +→ **The type-safe spelling is camelCase**; `aria-role` is the runtime-only porting escape the +compiler cannot guard. To reproduce: a scratch `.tsx` run through the package `tsc`, and a scratch +`.vue` run through `vue-tsc` (the repo has no vue-tsc — install it in an isolated dir), importing +`Box` from the built dist, asserting which mis-writes error. + +## The compound-word pit (and the rule) + +camelCase↔kebab is ambiguous for COMPOUND aria words: a human writes `ariaHasPopup`, but Vue's +`camelize` derives `ariaHaspopup` from the canonical `aria-haspopup` (the long-open vuejs/core +#5477). The current single-word vocabulary (role / label / hidden / state) camelizes losslessly, +so the pit is **latent, not live**. + +**Rule:** any future compound aria word must be declared as the mechanical camelize of the kebab +name (`ariaHaspopup`, never the human-natural `ariaHasPopup`) or folded into the typed `ariaState` +object — never bridged by relying on auto-camelize. In TSX, and in templates via the camelCase +spelling, TS catches a wrong compound name; a kebab compound in a template is silent, so camelCase +is the guarded path. The cross-field consensus (see precedents) is the same: **never auto-camelize +an aria round-trip.** + +## `aria-hidden` modeling + +ARIA's `aria-hidden` is a tristate enumerated STRING (`true` / `false` / `undefined`, default +`undefined` = visible) — not a boolean; `aria-hidden="false"` explicitly means _visible_. Ink +models it as a plain `boolean` (bare → true) and vue-tui follows that for ergonomics. Known edge +(run-verified): the literal string `aria-hidden="false"` currently HIDES (Boolean-prop coercion +sees the non-empty string as truthy) where ARIA says visible; bare / `={true}` hide, and +`={false}` / omitted are correctly visible. Fixable with an explicit normalize if it ever matters. + +## SR rendering architecture + +- **Live path:** `app.mount({ isScreenReaderEnabled })` (or `INK_SCREEN_READER=true`) makes each + commit emit the linearized SR text instead of the ANSI frame. +- **`renderToString`:** public, **layout-only** (matches Ink). Its SR-capable variant is + `renderToStringWithScreenReader` in `@vue-tui/runtime/internal`, used by the accessibility test + suite — the public string API does not surface SR (Ink also keeps its SR-string rendering + test-internal). +- **`renderScreenReaderOutput(node)`:** the linearizer that walks the host tree's + `internal_accessibility`. **`/internal`-only** (maintainer decision 2026-06-14). Ink keeps its + counterpart (`renderNodeToScreenReaderOutput`) module-internal and never exports it; we match + that. It was never usefully public anyway — its only parameter type (`TuiNode`) and the + node-construction primitives needed to build one are not in the public barrel, so a public + consumer could not name or construct the argument. No example/README/user path used it; the live + SR machinery (`render`, the internal `renderToStringWithScreenReader`, the `` channel) + imports it from the source module, unaffected. Public SR output is reached via the `mount` + `isScreenReaderEnabled` option, not by calling this directly. + +## Precedents (condensed) — cross-field consensus + +How other systems shape an aria API, surveyed when settling vue-tui's: + +- **React / Ink:** kebab string-literal prop keys, typed union/object; JSX keys never camelize, so + there is no round-trip to disagree. (vue-tui can copy the SHAPE, not the mechanism — Vue + camelizes.) +- **AccessKit** (drives egui; the strongest other no-DOM precedent): abandons strings for a typed + `Role` enum + typed state methods — no kebab to convert at all. +- **Vue a11y libraries** (Reka UI / Headless UI / Vuetify): kebab `aria-*` as fallthrough + attributes onto the DOM, never declared props — relies on a DOM + browser, so unavailable here. +- **Web Components / HTML reflection / Lit:** dual surface bridged by an EXPLICIT curated map + (`aria-haspopup` ↔ `ariaHasPopup`, `aria-posinset` ↔ `ariaPosInSet`), never auto-camelize — the + platform's own answer to the compound problem, and proof that naive remove-dash-uppercase is + wrong. +- **WAI-ARIA spec:** aria names are all-lowercase single tokens (`aria-haspopup`, not + `aria-has-popup`); `aria-hidden` etc. are tristate strings, not booleans. + +**Consensus across all of them: never auto-camelize an aria round-trip.** vue-tui satisfies it for +single-word props (lossless) and the compound-word rule above preserves it. diff --git a/.agents/docs/api-contract.md b/.agents/docs/api-contract.md new file mode 100644 index 0000000..055cc52 --- /dev/null +++ b/.agents/docs/api-contract.md @@ -0,0 +1,44 @@ +# Public API contract & surface + +What is — and isn't — part of `@vue-tui/runtime`'s public contract, and how the contract is +tested. (Behavioral _divergences_ from Ink live in [[ink-divergences]]; this file is about the +SHAPE of the public surface itself.) + +## The contract = public exports **and their user-consumable types** + +The public API is everything exported from the main barrel (`@vue-tui/runtime`): components, +composables, entry points — **and their TYPES** (component prop types, composable return/options +types, and named types such as `AriaRole`, `WindowSize`, `BoxProps`, `UseXReturn` / `UseXOptions`). + +A type is **as much a part of the contract as runtime behavior**. If user code can name a type +and annotate with it, renaming or removing it breaks that code at COMPILE time — exactly as +severe as a runtime break. So a type rename/removal is a breaking change, and the type surface is +the most important part of the contract to get right. + +Because it is contract, it is **tested, not merely shipped**: + +- `public-api.test.ts` snapshots the **exact** public value-export set — adding, removing, or + renaming any runtime export fails it, so every surface change must be a deliberate edit there. + Specific internal-only members (`measureText`, the screen-reader linearizer) are additionally + tripwired, and internal-only _types_ (e.g. `ScreenReaderOptions`) are guarded with a compile-time + `@ts-expect-error`. Type-only exports are erased at runtime, so the _type_ surface is guarded + individually rather than exhaustively snapshotted. +- Type-_safety_ behavior is established by RUNNING the type-checker against real usage (`tsc` for + TSX, `vue-tsc` for templates), never assumed. See [[accessibility-api]] for a worked example — + which aria spellings the compiler does and does not catch, proven with both tools. + +## `/internal` is NOT the contract + +`@vue-tui/runtime/internal` is an explicitly internal / advanced surface — host-node types +(`TuiNode`, …), test-only helpers (`renderToStringWithScreenReader`), dev/HMR types (`DevState`, +`DevErrorInfo`), kitty-controller internals, etc. It carries **no stability guarantee**, is not +covered by `public-api.test.ts`, and may change freely between releases. + +Placement rule for any export: + +- A user-facing contract → the **main barrel** (and it is tested). +- Needed only by tests or advanced integrators → **`/internal`**, never the main barrel. + +Packaging/build internals (the `exports` field shape, `.mjs` paths, `dist` layout) are likewise +**not** part of the behavioral/type contract and are not aligned to Ink — see the alignment-scope +note in [[ink-divergences]]. diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index b579160..aef09fa 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -13,6 +13,33 @@ Reference baseline: Ink **v7.0.4** (commit `40b3a7578811fd616341ca4e31cc7748aeeff12f`). When bumping the target Ink version, re-validate every entry below against the new source. +## Why align to Ink — and when not to + +Aligning to Ink is a **means, not an end**. Ink is a mature, battle-tested implementation, so +matching its public surface and behavior lets vue-tui inherit years of bug-fixes and edge-case +handling for free. That — reducing bugs by reusing proven behavior — is the entire point of +alignment. + +It follows that **alignment is not the top priority**. When Ink's behavior is itself a defect, +is unreasonable, or is un-idiomatic for Vue, **conformance to Vue's philosophy and the plain +reasonableness/correctness of the behavior outrank parity.** There vue-tui deliberately diverges, +and records it here so the choice is conscious and blessed, not drift. + +This guards against two opposite failure modes: + +- **Blind alignment** — copying Ink even where Ink is wrong, or where matching would force + un-Vue machinery, merely to match. (Rejected e.g. in the `useCursor` corner-zombie, the + resolve-on-throw exit, and the paint-time invalid-input crash — Ink behaviors vue-tui treats + as defects, not contracts.) +- **Lazy divergence** — inventing a different behavior and rationalizing it as "Vue's way is + better" with no genuine Vue-philosophy or correctness reason. Mere presence in this file is + **not** a blessing; every kept divergence needs a real reason and a maintainer decision. + +So the test for any difference is never just "does it match Ink?" but "is this the most +reasonable, most Vue-idiomatic behavior — and where it diverges from Ink, is that because Ink is +wrong or un-Vue, recorded with a maintainer decision?" Reasonableness and Vue idiom come first; +alignment is simply the cheapest way to get there whenever Ink is already right. + ## How to Classify a Divergence Classify each divergence by the first rule that applies. The order matters: earlier @@ -109,20 +136,9 @@ remain compatible; vue-tui only adds accepted inputs, contexts, or capabilities. node is reached via `$el`. Because `` is a `defineComponent`, the `$el` path is in fact the **primary** path a normal `ref` on `` takes — the bare host-node ref is the rarer raw-host case. Supporting both is a strict superset that matches how Vue refs behave; - a bare host-node ref still works identically to Ink. - -### `renderToString` supports screen-reader mode - -- **Ink:** `renderToString` has only a `columns` option; it always renders the non-SR - (ANSI) frame. Ink's **live** `render()` does expose `isScreenReaderEnabled` (plus a full - accessibility stack), so this is Ink choosing not to surface SR in the _string_ API, not a - missing SR capability. -- **vue-tui:** `renderToString` accepts `isScreenReaderEnabled?: boolean`. In SR mode it - returns the linearized accessibility text (`renderScreenReaderOutput`) and prepends the - linearized `` output, just as the non-SR path prepends the painted static frame. -- **Why:** vue-tui already has a parity SR renderer for the live path. Surfacing it through - the string API is a strict superset (default `false` is byte-identical to Ink) and keeps - `` content in generated SR snapshots. Additive. + a bare host-node ref still works identically to Ink. Maintainer decision (2026-06-13): KEEP + — a reasonable Vue-idiomatic adoption (the component-instance ref is the natural Vue path; + the bare host-node ref stays Ink-identical). ### Two apps sharing one stdin both receive input @@ -311,6 +327,15 @@ current-props model, or API conventions. return `void`, plain `boolean`, or small unexported inline shapes — never an `XProps` type. `XProps` is reserved for component props (`BoxProps`/`TextProps`, derived via `ExtractPublicPropTypes`). +- **Options types follow the same principle:** Ink names a composable's options type locally + `Options` / `Props` and usually does **not** export it (e.g. `useAnimation`'s `Options` is + internal — only the return `AnimationResult` is exported, `use-animation.ts:14,30`). vue-tui + exports each composable's options type under VueUse's `UseXOptions` name: `UseInputOptions`, + `UsePasteOptions`, `UseFocusOptions`, `UseAnimationOptions`. `useAnimation`'s options type + originally shipped as `AnimationOptions` — the lone holdout — and was renamed to + `UseAnimationOptions` (a hard rename, no alias) while the package is pre-1.0 (`0.0.x`, no + stability promise yet). **Maintainer decision (2026-06-13): export composable options types + as `UseXOptions`; renamed `AnimationOptions` → `UseAnimationOptions`.** - **Why:** the public surface should read like Vue code: named composable return types get a single convention (`UseXReturn`) instead of Ink's mix of `XProps`, result names, and bare names, and `XProps` keeps its Vue meaning (component props). Return shapes still mirror @@ -347,6 +372,26 @@ current-props model, or API conventions. Vue users expect for slot payloads. The rendered item/index values remain equivalent. Maintainer decision (2026-06-06): KEEP. +#### ARIA props are typed camelCase; kebab still works but is not type-checked + +Full design, type-safety findings, and precedent survey: [[accessibility-api]]. + +- **Ink:** kebab string-literal prop keys (`'aria-label'`, `'aria-hidden'`, `'aria-role'` union, + `'aria-state'` object); JSX keys never camelize. +- **vue-tui:** the same vocabulary as typed **camelCase** props (`ariaLabel`/`ariaHidden`/ + `ariaRole`/`ariaState`; `AriaRole`/`AriaState` exported, identical to Ink's). Ink's kebab still + works at runtime (Vue camelizes onto the declared prop), so `aria-role` ports unchanged. +- **Why (Vue idiom + reasonableness > parity — see "Why align to Ink"):** Vue's `prop-name-casing` + mandates camelCase, and — run-verified with `tsc`/`vue-tsc` — **camelCase is the only spelling + type-checked** (value/typo/compound mistakes compile-error in both TSX and templates), while + kebab `aria-*` is not (Vue/Volar treat it as a global attr). So `ariaRole` is the type-safe + spelling and `aria-role` the runtime-only porting escape; the rejected kebab-only `$attrs` + alternative loses typing + Boolean coercion for nothing the checker doesn't already give. + Maintainer decision (2026-06-14): KEEP. +- **Edges:** a future compound aria word must be declared as its mechanical camelize + (`ariaHaspopup`, not `ariaHasPopup`) or folded into `ariaState`; `aria-hidden` is modeled + boolean (bare → true), but the string `aria-hidden="false"` wrongly hides (recorded edge). + ## Intentional Divergence Choices These divergences are deliberate, but they are not strict supersets and are not primarily @@ -429,7 +474,7 @@ different runtime behavior, ownership rule, or out-of-contract handling. an Ink app holding a `useInput` already does not). The "render and auto-exit" pattern (Ink's inline-output use) is `rawMode: 'auto'`. Tests: `raw-mode-lifecycle.test.tsx` (`'always'` holds raw with no input hook; `'auto'` stays cooked; no mid-session - oscillation). + oscillation). Maintainer decision (2026-06-13): KEEP. ### `useCursor()` re-asserts the declared caret every commit (persistent declaration) diff --git a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx index 774b47f..034afae 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx @@ -1,6 +1,6 @@ import { defineComponent, nextTick, shallowRef, type FunctionalComponent } from "vue"; import { describe, expect, test } from "vite-plus/test"; -import { renderToString, Box, Text, Transform, Newline, Static, createApp } from "@vue-tui/runtime"; +import { Box, Text, Transform, Newline, Static, createApp } from "@vue-tui/runtime"; import { render } from "@vue-tui/testing"; import { createRoot, @@ -9,6 +9,7 @@ import { createTextLeaf, attachYoga, renderScreenReaderOutput, + renderToStringWithScreenReader as renderToString, type AppContext, } from "@vue-tui/runtime/internal"; import { diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx index 9ec2cd1..7401281 100644 --- a/packages/runtime-tests/integration/components/background-color.test.tsx +++ b/packages/runtime-tests/integration/components/background-color.test.tsx @@ -1,7 +1,8 @@ import { defineComponent, shallowRef, nextTick, h } from "vue"; import { test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text, renderToString } from "@vue-tui/runtime"; +import { Box, Text } from "@vue-tui/runtime"; +import { renderToStringWithScreenReader as renderToString } from "@vue-tui/runtime/internal"; const BG_BLUE = "\x1b[44m"; const BG_CYAN = "\x1b[46m"; diff --git a/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx b/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx index 04c8845..1b4e062 100644 --- a/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx +++ b/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx @@ -7,7 +7,7 @@ import { PassThrough } from "node:stream"; import process from "node:process"; import { defineComponent } from "vue"; import { expect, test } from "vite-plus/test"; -import { createApp, Text, useTerminalSize } from "@vue-tui/runtime"; +import { createApp, Text, useWindowSize } from "@vue-tui/runtime"; function makeTtyStream(columns: number): NodeJS.WriteStream { const s = new PassThrough() as unknown as NodeJS.WriteStream; @@ -36,7 +36,7 @@ function makeFakeStdin(): NodeJS.ReadStream { // when stdout.rows is missing"). With the mount stdout reporting columns 0 and // no rows, resolveSize() calls terminal-size, which — after we zero out the real // process.stdout/stderr dimensions — resolves rows from process.env.LINES. -test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES when stdout.rows is missing", async () => { +test.sequential("useWindowSize falls back to terminal-size rows from env.LINES when stdout.rows is missing", async () => { const stdout = makeTtyStream(0); const stderr = makeTtyStream(0); const stdin = makeFakeStdin(); @@ -50,7 +50,7 @@ test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES let capturedRows = -1; const App = defineComponent(() => { - const { rows } = useTerminalSize(); + const { rows } = useWindowSize(); capturedRows = rows.value; return () => {String(rows.value)}; }); diff --git a/packages/runtime-tests/integration/composables/terminal-size.test.tsx b/packages/runtime-tests/integration/composables/terminal-size.test.tsx index 2b053a6..9213f0d 100644 --- a/packages/runtime-tests/integration/composables/terminal-size.test.tsx +++ b/packages/runtime-tests/integration/composables/terminal-size.test.tsx @@ -2,7 +2,7 @@ import { PassThrough } from "node:stream"; import { defineComponent, onScopeDispose } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, createApp, Text, useTerminalSize } from "@vue-tui/runtime"; +import { Box, createApp, Text, useWindowSize } from "@vue-tui/runtime"; // A TTY-like writable that we control directly (columns/rows + resize listeners) // — the @vue-tui/testing render() helper hides the underlying stdout, but the @@ -31,9 +31,9 @@ function makeFakeStdin(): NodeJS.ReadStream { return s; } -test("useTerminalSize reacts to resize event", async () => { +test("useWindowSize reacts to resize event", async () => { const App = defineComponent(() => { - const { columns, rows } = useTerminalSize(); + const { columns, rows } = useWindowSize(); return () => ( {columns.value}x{rows.value} @@ -48,9 +48,9 @@ test("useTerminalSize reacts to resize event", async () => { expect(lastFrame()).toContain("120x40"); }); -test("useTerminalSize returns initial terminal dimensions", async () => { +test("useWindowSize returns initial terminal dimensions", async () => { const App = defineComponent(() => { - const { columns, rows } = useTerminalSize(); + const { columns, rows } = useWindowSize(); return () => ( {columns.value}x{rows.value} @@ -62,10 +62,10 @@ test("useTerminalSize returns initial terminal dimensions", async () => { expect(lastFrame()).toContain("100x40"); }); -test("useTerminalSize removes resize listener on unmount", async () => { +test("useWindowSize removes resize listener on unmount", async () => { // After unmount, further resize events should not cause errors const App = defineComponent(() => { - const { columns, rows } = useTerminalSize(); + const { columns, rows } = useWindowSize(); return () => ( {columns.value}x{rows.value} @@ -82,9 +82,9 @@ test("useTerminalSize removes resize listener on unmount", async () => { await expect(terminal.resize(60, 20)).resolves.toBeUndefined(); }); -test("useTerminalSize does not crash when resize fires after unmount", async () => { +test("useWindowSize does not crash when resize fires after unmount", async () => { const App = defineComponent(() => { - const { columns, rows } = useTerminalSize(); + const { columns, rows } = useWindowSize(); return () => ( {columns.value}x{rows.value} @@ -122,7 +122,7 @@ test("layout responds to terminal width change", async () => { test("multiple consecutive resizes all take effect", async () => { const App = defineComponent(() => { - const { columns, rows } = useTerminalSize(); + const { columns, rows } = useWindowSize(); return () => ( {columns.value}x{rows.value} @@ -145,7 +145,7 @@ test("multiple consecutive resizes all take effect", async () => { test("terminal width decrease triggers rerender", async () => { const App = defineComponent(() => { - const { columns } = useTerminalSize(); + const { columns } = useWindowSize(); return () => {columns.value}; }); @@ -158,7 +158,7 @@ test("terminal width decrease triggers rerender", async () => { test("terminal width increase triggers rerender", async () => { const App = defineComponent(() => { - const { columns } = useTerminalSize(); + const { columns } = useWindowSize(); return () => {columns.value}; }); @@ -173,9 +173,9 @@ test("resize listener is cleaned up via onScopeDispose", async () => { let disposeCalled = false; const App = defineComponent(() => { - // useTerminalSize registers an onScopeDispose listener internally; + // useWindowSize registers an onScopeDispose listener internally; // we also register one to verify the scope is properly disposed on unmount. - useTerminalSize(); + useWindowSize(); onScopeDispose(() => { disposeCalled = true; }); @@ -193,14 +193,14 @@ test("resize listener is cleaned up via onScopeDispose", async () => { // count when stdout.columns is 0"). When the mount stdout reports columns 0, // resolveSize() falls through to the terminal-size package / 80 default, so the // captured value must be a positive number (never 0). -test("useTerminalSize falls back to a positive column count when stdout.columns is 0", async () => { +test("useWindowSize falls back to a positive column count when stdout.columns is 0", async () => { const stdout = makeTtyStream(0, 24); const stderr = makeTtyStream(0, 24); const stdin = makeFakeStdin(); let capturedColumns = -1; const App = defineComponent(() => { - const { columns } = useTerminalSize(); + const { columns } = useWindowSize(); capturedColumns = columns.value; return () => {String(columns.value)}; }); @@ -217,9 +217,9 @@ test("useTerminalSize falls back to a positive column count when stdout.columns }); // Mirrors Ink terminal-resize.tsx:43-64 ("removes resize listener on unmount"). -// The resize listener count must grow by mounting a useTerminalSize component +// The resize listener count must grow by mounting a useWindowSize component // and return exactly to baseline after unmount (no leaked listener). -test("useTerminalSize resize listener returns to baseline on unmount", async () => { +test("useWindowSize resize listener returns to baseline on unmount", async () => { const stdout = makeTtyStream(80, 24); const stderr = makeTtyStream(80, 24); const stdin = makeFakeStdin(); @@ -227,7 +227,7 @@ test("useTerminalSize resize listener returns to baseline on unmount", async () const baseline = stdout.listenerCount("resize"); const App = defineComponent(() => { - const { columns, rows } = useTerminalSize(); + const { columns, rows } = useWindowSize(); return () => ( {columns.value}x{rows.value} diff --git a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx index 27d152a..4e8c901 100644 --- a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx +++ b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx @@ -1,7 +1,7 @@ import { defineComponent, nextTick, ref, shallowRef, watchEffect, watchPostEffect } from "vue"; import { describe, expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text, useBoxMetrics, measureElement, useTerminalSize } from "@vue-tui/runtime"; +import { Box, Text, useBoxMetrics, measureElement, useWindowSize } from "@vue-tui/runtime"; describe("useBoxMetrics", () => { test("returns layout dimensions after render", async () => { @@ -379,7 +379,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => { const App = defineComponent(() => { const boxRef = ref(null); const { width } = useBoxMetrics(boxRef); - useTerminalSize(); + useWindowSize(); return () => ( Width: {width.value} @@ -407,7 +407,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => { }); const { height } = useBoxMetrics(trackedRef); - useTerminalSize(); + useWindowSize(); return () => ( @@ -636,7 +636,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => { const App = defineComponent(() => { const boxRef = ref(null); useBoxMetrics(boxRef); - useTerminalSize(); + useWindowSize(); return () => ( Hello diff --git a/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx b/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx index 49fed5b..73f760a 100644 --- a/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx +++ b/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx @@ -1,7 +1,8 @@ import { defineComponent } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Text, renderToString, useIsScreenReaderEnabled } from "@vue-tui/runtime"; +import { Text, useIsScreenReaderEnabled } from "@vue-tui/runtime"; +import { renderToStringWithScreenReader as renderToString } from "@vue-tui/runtime/internal"; // NOTE: tests that auto-detect SR via the process-GLOBAL env var // `INK_SCREEN_READER` live in use-screen-reader-env.sequential.test.tsx (the diff --git a/packages/runtime-tests/integration/public-api.test.ts b/packages/runtime-tests/integration/public-api.test.ts index 8325021..4598850 100644 --- a/packages/runtime-tests/integration/public-api.test.ts +++ b/packages/runtime-tests/integration/public-api.test.ts @@ -1,46 +1,47 @@ import { expect, test } from "vite-plus/test"; import * as api from "@vue-tui/runtime"; +import * as internalApi from "@vue-tui/runtime/internal"; -test("public API exposes documented members", () => { - for (const k of [ - // Entry point - "createApp", - // Components - "Box", - "Text", - "Newline", - "Spacer", - "Static", - "Transform", - // Composables - "useApp", - "useInput", - "useFocus", - "useFocusManager", - "useStdin", - "useStdout", - "useStderr", - "useTerminalSize", - "useWindowSize", - "useCursor", - "useIsScreenReaderEnabled", - "useAnimation", - "useBoxMetrics", - "measureElement", - "usePaste", - // Rendering - "renderToString", - "renderScreenReaderOutput", - // Kitty keyboard - "kittyFlags", - "kittyModifiers", - ]) { - expect(api).toHaveProperty(k); - } -}); +// The EXACT public runtime (value) export surface of `@vue-tui/runtime`. The test below snapshots +// it exhaustively: adding, removing, or renaming ANY value export fails — so every change to the +// public surface must be a deliberate edit here. Keep grouped + alphabetical-within-group for +// readable diffs. NOTE: type-only exports are erased at runtime and cannot be enumerated this way; +// they are guarded individually with `@ts-expect-error` (see the `ScreenReaderOptions` guard +// below). The type surface is therefore not exhaustively snapshotted. +const PUBLIC_VALUE_EXPORTS = [ + // Entry point + "createApp", + // Components + "Box", + "Newline", + "Spacer", + "Static", + "Text", + "Transform", + // Composables + "useAnimation", + "useApp", + "useBoxMetrics", + "useCursor", + "useFocus", + "useFocusManager", + "useInput", + "useIsScreenReaderEnabled", + "usePaste", + "useStderr", + "useStdin", + "useStdout", + "useWindowSize", + "measureElement", + // Rendering + "renderToString", + // Kitty keyboard + "kittyFlags", + "kittyModifiers", +]; -test("useWindowSize is an alias for useTerminalSize", () => { - expect(api.useWindowSize).toBe(api.useTerminalSize); +test("public API surface is exactly the documented value-export set", () => { + expect(Object.keys(api).sort()).toEqual([...PUBLIC_VALUE_EXPORTS].sort()); }); // Ink keeps its `measure-text` module internal and does not re-export it. vue-tui @@ -51,3 +52,22 @@ test("does not expose internal text-measurement helpers (Ink keeps them internal expect(api).not.toHaveProperty("measureText"); expect(api).not.toHaveProperty("measureTextNatural"); }); + +// `renderScreenReaderOutput` is the screen-reader linearizer — internal SR machinery, +// not a public API. Ink keeps its counterpart (`renderNodeToScreenReaderOutput`) +// module-internal and never re-exports it; we match that. It was never usefully +// callable from the public barrel anyway: its only parameter type (`TuiNode`) and the +// node-construction primitives needed to build one live only in +// `@vue-tui/runtime/internal`. It moves there. See .agents/docs/accessibility-api.md. +test("does not expose the screen-reader linearizer publicly (Ink keeps it internal)", () => { + expect(api).not.toHaveProperty("renderScreenReaderOutput"); + expect(internalApi).toHaveProperty("renderScreenReaderOutput"); +}); + +// Compile-time guard for the TYPE half of the contract (types are erased at runtime, so this +// can't be an `expect()`): `ScreenReaderOptions` is internal-only too. Importing it from the +// PUBLIC barrel must NOT type-check — if it is ever re-added there, this `@ts-expect-error` goes +// unused and `tsc --noEmit` fails. Same idiom as the prop-type fixtures in integration/pty/fixtures. +// It DOES type-check from `/internal`, which the runtime guard above already proves is the home. +// @ts-expect-error - ScreenReaderOptions is exported only from @vue-tui/runtime/internal +export type _ScreenReaderOptionsIsInternalOnly = import("@vue-tui/runtime").ScreenReaderOptions; diff --git a/packages/runtime-tests/integration/render-to-string.test.tsx b/packages/runtime-tests/integration/render-to-string.test.tsx index f7837d8..25e4611 100644 --- a/packages/runtime-tests/integration/render-to-string.test.tsx +++ b/packages/runtime-tests/integration/render-to-string.test.tsx @@ -18,7 +18,7 @@ import { useStderr, useCursor, usePaste, - useTerminalSize, + useWindowSize, useAnimation, useBoxMetrics, } from "@vue-tui/runtime"; @@ -650,7 +650,7 @@ describe("renderToString", () => { // StdinContext + a no-op AnimationScheduler (render-to-string.ts:93-96). The // existing suite covers useInput/useApp/useFocus/useFocusManager/useStdin/ // useStdout/useStderr. These pin the remaining terminal composables — - // useCursor, usePaste, useTerminalSize, useAnimation, useBoxMetrics — so that + // useCursor, usePaste, useWindowSize, useAnimation, useBoxMetrics — so that // rendering a component which CALLS them degrades to inert values instead of // throwing (they must still return a string). describe("terminal composables degrade to no-ops (do not throw)", () => { @@ -681,11 +681,11 @@ describe("renderToString", () => { expect(pasted).toBe(""); }); - test("useTerminalSize does not throw in renderToString", () => { + test("useWindowSize does not throw in renderToString", () => { const App = defineComponent(() => { // Resolves dimensions from ctx.stdout (process.stdout in the no-op // context) with the terminal-size fallback; never throws. - const { columns, rows } = useTerminalSize(); + const { columns, rows } = useWindowSize(); return () => size {columns.value > 0 && rows.value > 0 ? "ok" : "fallback"}; }); const output = renderToString(App); @@ -726,7 +726,7 @@ describe("renderToString", () => { const { setCursorPosition } = useCursor(); setCursorPosition({ x: 1, y: 0 }); usePaste(() => {}); - useTerminalSize(); + useWindowSize(); const { frame } = useAnimation({ interval: 30 }); const boxRef = shallowRef(null); useBoxMetrics(boxRef); diff --git a/packages/runtime/src/composables/useAnimation.ts b/packages/runtime/src/composables/useAnimation.ts index 8bc4301..169e21b 100644 --- a/packages/runtime/src/composables/useAnimation.ts +++ b/packages/runtime/src/composables/useAnimation.ts @@ -14,7 +14,7 @@ import { } from "../animation-scheduler.ts"; import { AnimationSchedulerKey } from "../context.ts"; -export interface AnimationOptions { +export interface UseAnimationOptions { /** * Time between ticks in milliseconds. * @@ -85,7 +85,7 @@ export interface UseAnimationReturn { * * ``` */ -export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn { +export function useAnimation(options: UseAnimationOptions = {}): UseAnimationReturn { const frame = shallowRef(0); const time = shallowRef(0); const delta = shallowRef(0); diff --git a/packages/runtime/src/composables/useTerminalSize.ts b/packages/runtime/src/composables/useWindowSize.ts similarity index 81% rename from packages/runtime/src/composables/useTerminalSize.ts rename to packages/runtime/src/composables/useWindowSize.ts index ac0bf22..153a524 100644 --- a/packages/runtime/src/composables/useTerminalSize.ts +++ b/packages/runtime/src/composables/useWindowSize.ts @@ -7,8 +7,8 @@ import { AppContextKey } from "../context.ts"; * (`columns`/`rows`, not `width`/`height`). * * Note the Vue-vs-React shape: Ink's `useWindowSize()` returns a `WindowSize` - * snapshot, whereas vue-tui's `useWindowSize()` / `useTerminalSize()` return - * reactive **refs** of these dimensions + * snapshot, whereas vue-tui's `useWindowSize()` returns reactive **refs** of + * these dimensions * (`{ columns: ShallowRef; rows: ShallowRef }`). The data shape * is the same; the reactivity wrapper is the framework difference. */ @@ -36,9 +36,9 @@ export function resolveSize(stdout: NodeJS.WriteStream): WindowSize { }; } -export function useTerminalSize(): { columns: ShallowRef; rows: ShallowRef } { +export function useWindowSize(): { columns: ShallowRef; rows: ShallowRef } { const ctx = inject(AppContextKey); - if (!ctx) throw new Error("useTerminalSize() must be called inside a vue-tui render tree"); + if (!ctx) throw new Error("useWindowSize() must be called inside a vue-tui render tree"); const initial = resolveSize(ctx.stdout); const columns = shallowRef(initial.columns); const rows = shallowRef(initial.rows); @@ -51,6 +51,3 @@ export function useTerminalSize(): { columns: ShallowRef; rows: ShallowR onScopeDispose(() => ctx.stdout.off("resize", onResize)); return { columns, rows }; } - -/** Alias for `useTerminalSize`. */ -export const useWindowSize = useTerminalSize; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2fb75f1..a4dd148 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -30,12 +30,12 @@ export { useFocusManager } from "./composables/useFocusManager.ts"; export { useStdin, type UseStdinReturn } from "./composables/useStdin.ts"; export { useStdout, type UseStdoutReturn } from "./composables/useStdout.ts"; export { useStderr, type UseStderrReturn } from "./composables/useStderr.ts"; -export { useTerminalSize, useWindowSize, type WindowSize } from "./composables/useTerminalSize.ts"; +export { useWindowSize, type WindowSize } from "./composables/useWindowSize.ts"; export { useCursor, type CursorPosition } from "./composables/useCursor.ts"; export { useIsScreenReaderEnabled } from "./composables/useIsScreenReaderEnabled.ts"; export { useAnimation, - type AnimationOptions, + type UseAnimationOptions, type UseAnimationReturn, } from "./composables/useAnimation.ts"; export { @@ -44,8 +44,6 @@ export { type BoxMetrics, type UseBoxMetricsReturn, } from "./composables/useBoxMetrics.ts"; -export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.ts"; -export type { DevState, DevErrorInfo } from "./hmr.ts"; export { kittyFlags, kittyModifiers, @@ -55,3 +53,7 @@ export { // `measureText` / `measureTextNatural` are deliberately NOT re-exported: Ink keeps // its `measure-text` module internal, and so do we. They remain internal helpers // (yoga.ts uses `measureTextNatural`). See .agents/docs/ink-divergences.md. +// `renderScreenReaderOutput` / `ScreenReaderOptions` are likewise NOT public: Ink keeps +// its SR linearizer (`renderNodeToScreenReaderOutput`) module-internal, and it was never +// usefully callable from here (its `TuiNode` argument type isn't public). It lives in +// `@vue-tui/runtime/internal`. See .agents/docs/accessibility-api.md. diff --git a/packages/runtime/src/internal.ts b/packages/runtime/src/internal.ts index b368915..880d2eb 100644 --- a/packages/runtime/src/internal.ts +++ b/packages/runtime/src/internal.ts @@ -10,6 +10,8 @@ export { type TuiNode, } from "./host/nodes.ts"; export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.ts"; +export { renderToStringWithScreenReader } from "./render-to-string.ts"; +export type { DevState, DevErrorInfo } from "./hmr.ts"; export type { AppContext } from "./context.ts"; export { createKittyKeyboardController, diff --git a/packages/runtime/src/render-to-string.ts b/packages/runtime/src/render-to-string.ts index 92b07e9..e9df454 100644 --- a/packages/runtime/src/render-to-string.ts +++ b/packages/runtime/src/render-to-string.ts @@ -28,6 +28,16 @@ export interface RenderToStringOptions { * @default 80 */ columns?: number; +} + +/** + * Options for the internal, screen-reader-capable render-to-string used by the + * accessibility test suite. NOT part of the public API: Ink likewise keeps + * screen-reader string rendering out of its public `renderToString` (layout + * only) and reaches it through a private test helper. Exposed via + * `@vue-tui/runtime/internal` as `renderToStringWithScreenReader`. + */ +interface RenderToStringInternalOptions extends RenderToStringOptions { /** * Enable screen reader mode. When enabled, the output is plain text * suitable for screen readers (no ANSI styling, with role/state annotations). @@ -58,6 +68,26 @@ export interface RenderToStringOptions { * caller after cleanup. */ export function renderToString(component: Component, options?: RenderToStringOptions): string { + return renderToStringInternal(component, options); +} + +/** + * Screen-reader-capable variant of {@link renderToString}, for the accessibility + * test suite only (exported from `@vue-tui/runtime/internal`). The public + * `renderToString` is layout-only, matching Ink, which keeps screen-reader + * string rendering in a private test helper rather than its public API. + */ +export function renderToStringWithScreenReader( + component: Component, + options?: RenderToStringInternalOptions, +): string { + return renderToStringInternal(component, options); +} + +function renderToStringInternal( + component: Component, + options?: RenderToStringInternalOptions, +): string { const columns = options?.columns ?? 80; const isScreenReaderEnabled = options?.isScreenReaderEnabled ?? false; diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 8dd6eac..96e3f69 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -45,7 +45,7 @@ import { import { devState, DevStateKey, initHmrBridge } from "./hmr.ts"; import { createDevOverlayWrapper } from "./overlay.ts"; import { ErrorOverview, messageForNonError } from "./components/error-overview.ts"; -import { resolveSize } from "./composables/useTerminalSize.ts"; +import { resolveSize } from "./composables/useWindowSize.ts"; export interface MountOptions { stdout?: NodeJS.WriteStream;