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:
@@ -1,6 +1,6 @@
|
||||
import { defineComponent, nextTick, shallowRef, type FunctionalComponent } from "vue";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { renderToString, Box, Text, Transform, Newline, Static, createApp } from "@vue-tui/runtime";
|
||||
import { Box, Text, Transform, Newline, Static, createApp } from "@vue-tui/runtime";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import {
|
||||
createRoot,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createTextLeaf,
|
||||
attachYoga,
|
||||
renderScreenReaderOutput,
|
||||
renderToStringWithScreenReader as renderToString,
|
||||
type AppContext,
|
||||
} from "@vue-tui/runtime/internal";
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineComponent, shallowRef, nextTick, h } from "vue";
|
||||
import { test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Box, Text, renderToString } from "@vue-tui/runtime";
|
||||
import { Box, Text } from "@vue-tui/runtime";
|
||||
import { renderToStringWithScreenReader as renderToString } from "@vue-tui/runtime/internal";
|
||||
|
||||
const BG_BLUE = "\x1b[44m";
|
||||
const BG_CYAN = "\x1b[46m";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PassThrough } from "node:stream";
|
||||
import process from "node:process";
|
||||
import { defineComponent } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { createApp, Text, useTerminalSize } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useWindowSize } from "@vue-tui/runtime";
|
||||
|
||||
function makeTtyStream(columns: number): NodeJS.WriteStream {
|
||||
const s = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||
@@ -36,7 +36,7 @@ function makeFakeStdin(): NodeJS.ReadStream {
|
||||
// when stdout.rows is missing"). With the mount stdout reporting columns 0 and
|
||||
// no rows, resolveSize() calls terminal-size, which — after we zero out the real
|
||||
// process.stdout/stderr dimensions — resolves rows from process.env.LINES.
|
||||
test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES when stdout.rows is missing", async () => {
|
||||
test.sequential("useWindowSize falls back to terminal-size rows from env.LINES when stdout.rows is missing", async () => {
|
||||
const stdout = makeTtyStream(0);
|
||||
const stderr = makeTtyStream(0);
|
||||
const stdin = makeFakeStdin();
|
||||
@@ -50,7 +50,7 @@ test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES
|
||||
|
||||
let capturedRows = -1;
|
||||
const App = defineComponent(() => {
|
||||
const { rows } = useTerminalSize();
|
||||
const { rows } = useWindowSize();
|
||||
capturedRows = rows.value;
|
||||
return () => <Text>{String(rows.value)}</Text>;
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PassThrough } from "node:stream";
|
||||
import { defineComponent, onScopeDispose } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Box, createApp, Text, useTerminalSize } from "@vue-tui/runtime";
|
||||
import { Box, createApp, Text, useWindowSize } from "@vue-tui/runtime";
|
||||
|
||||
// A TTY-like writable that we control directly (columns/rows + resize listeners)
|
||||
// — the @vue-tui/testing render() helper hides the underlying stdout, but the
|
||||
@@ -31,9 +31,9 @@ function makeFakeStdin(): NodeJS.ReadStream {
|
||||
return s;
|
||||
}
|
||||
|
||||
test("useTerminalSize reacts to resize event", async () => {
|
||||
test("useWindowSize reacts to resize event", async () => {
|
||||
const App = defineComponent(() => {
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const { columns, rows } = useWindowSize();
|
||||
return () => (
|
||||
<Text>
|
||||
{columns.value}x{rows.value}
|
||||
@@ -48,9 +48,9 @@ test("useTerminalSize reacts to resize event", async () => {
|
||||
expect(lastFrame()).toContain("120x40");
|
||||
});
|
||||
|
||||
test("useTerminalSize returns initial terminal dimensions", async () => {
|
||||
test("useWindowSize returns initial terminal dimensions", async () => {
|
||||
const App = defineComponent(() => {
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const { columns, rows } = useWindowSize();
|
||||
return () => (
|
||||
<Text>
|
||||
{columns.value}x{rows.value}
|
||||
@@ -62,10 +62,10 @@ test("useTerminalSize returns initial terminal dimensions", async () => {
|
||||
expect(lastFrame()).toContain("100x40");
|
||||
});
|
||||
|
||||
test("useTerminalSize removes resize listener on unmount", async () => {
|
||||
test("useWindowSize removes resize listener on unmount", async () => {
|
||||
// After unmount, further resize events should not cause errors
|
||||
const App = defineComponent(() => {
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const { columns, rows } = useWindowSize();
|
||||
return () => (
|
||||
<Text>
|
||||
{columns.value}x{rows.value}
|
||||
@@ -82,9 +82,9 @@ test("useTerminalSize removes resize listener on unmount", async () => {
|
||||
await expect(terminal.resize(60, 20)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("useTerminalSize does not crash when resize fires after unmount", async () => {
|
||||
test("useWindowSize does not crash when resize fires after unmount", async () => {
|
||||
const App = defineComponent(() => {
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const { columns, rows } = useWindowSize();
|
||||
return () => (
|
||||
<Text>
|
||||
{columns.value}x{rows.value}
|
||||
@@ -122,7 +122,7 @@ test("layout responds to terminal width change", async () => {
|
||||
|
||||
test("multiple consecutive resizes all take effect", async () => {
|
||||
const App = defineComponent(() => {
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const { columns, rows } = useWindowSize();
|
||||
return () => (
|
||||
<Text>
|
||||
{columns.value}x{rows.value}
|
||||
@@ -145,7 +145,7 @@ test("multiple consecutive resizes all take effect", async () => {
|
||||
|
||||
test("terminal width decrease triggers rerender", async () => {
|
||||
const App = defineComponent(() => {
|
||||
const { columns } = useTerminalSize();
|
||||
const { columns } = useWindowSize();
|
||||
return () => <Text>{columns.value}</Text>;
|
||||
});
|
||||
|
||||
@@ -158,7 +158,7 @@ test("terminal width decrease triggers rerender", async () => {
|
||||
|
||||
test("terminal width increase triggers rerender", async () => {
|
||||
const App = defineComponent(() => {
|
||||
const { columns } = useTerminalSize();
|
||||
const { columns } = useWindowSize();
|
||||
return () => <Text>{columns.value}</Text>;
|
||||
});
|
||||
|
||||
@@ -173,9 +173,9 @@ test("resize listener is cleaned up via onScopeDispose", async () => {
|
||||
let disposeCalled = false;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
// useTerminalSize registers an onScopeDispose listener internally;
|
||||
// useWindowSize registers an onScopeDispose listener internally;
|
||||
// we also register one to verify the scope is properly disposed on unmount.
|
||||
useTerminalSize();
|
||||
useWindowSize();
|
||||
onScopeDispose(() => {
|
||||
disposeCalled = true;
|
||||
});
|
||||
@@ -193,14 +193,14 @@ test("resize listener is cleaned up via onScopeDispose", async () => {
|
||||
// count when stdout.columns is 0"). When the mount stdout reports columns 0,
|
||||
// resolveSize() falls through to the terminal-size package / 80 default, so the
|
||||
// captured value must be a positive number (never 0).
|
||||
test("useTerminalSize falls back to a positive column count when stdout.columns is 0", async () => {
|
||||
test("useWindowSize falls back to a positive column count when stdout.columns is 0", async () => {
|
||||
const stdout = makeTtyStream(0, 24);
|
||||
const stderr = makeTtyStream(0, 24);
|
||||
const stdin = makeFakeStdin();
|
||||
|
||||
let capturedColumns = -1;
|
||||
const App = defineComponent(() => {
|
||||
const { columns } = useTerminalSize();
|
||||
const { columns } = useWindowSize();
|
||||
capturedColumns = columns.value;
|
||||
return () => <Text>{String(columns.value)}</Text>;
|
||||
});
|
||||
@@ -217,9 +217,9 @@ test("useTerminalSize falls back to a positive column count when stdout.columns
|
||||
});
|
||||
|
||||
// Mirrors Ink terminal-resize.tsx:43-64 ("removes resize listener on unmount").
|
||||
// The resize listener count must grow by mounting a useTerminalSize component
|
||||
// The resize listener count must grow by mounting a useWindowSize component
|
||||
// and return exactly to baseline after unmount (no leaked listener).
|
||||
test("useTerminalSize resize listener returns to baseline on unmount", async () => {
|
||||
test("useWindowSize resize listener returns to baseline on unmount", async () => {
|
||||
const stdout = makeTtyStream(80, 24);
|
||||
const stderr = makeTtyStream(80, 24);
|
||||
const stdin = makeFakeStdin();
|
||||
@@ -227,7 +227,7 @@ test("useTerminalSize resize listener returns to baseline on unmount", async ()
|
||||
const baseline = stdout.listenerCount("resize");
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const { columns, rows } = useWindowSize();
|
||||
return () => (
|
||||
<Text>
|
||||
{columns.value}x{rows.value}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineComponent, nextTick, ref, shallowRef, watchEffect, watchPostEffect } from "vue";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Box, Text, useBoxMetrics, measureElement, useTerminalSize } from "@vue-tui/runtime";
|
||||
import { Box, Text, useBoxMetrics, measureElement, useWindowSize } from "@vue-tui/runtime";
|
||||
|
||||
describe("useBoxMetrics", () => {
|
||||
test("returns layout dimensions after render", async () => {
|
||||
@@ -379,7 +379,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
|
||||
const App = defineComponent(() => {
|
||||
const boxRef = ref(null);
|
||||
const { width } = useBoxMetrics(boxRef);
|
||||
useTerminalSize();
|
||||
useWindowSize();
|
||||
return () => (
|
||||
<Box ref={boxRef}>
|
||||
<Text>Width: {width.value}</Text>
|
||||
@@ -407,7 +407,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
|
||||
});
|
||||
|
||||
const { height } = useBoxMetrics(trackedRef);
|
||||
useTerminalSize();
|
||||
useWindowSize();
|
||||
|
||||
return () => (
|
||||
<Box flexDirection="column">
|
||||
@@ -636,7 +636,7 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
|
||||
const App = defineComponent(() => {
|
||||
const boxRef = ref(null);
|
||||
useBoxMetrics(boxRef);
|
||||
useTerminalSize();
|
||||
useWindowSize();
|
||||
return () => (
|
||||
<Box ref={boxRef}>
|
||||
<Text>Hello</Text>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineComponent } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Text, renderToString, useIsScreenReaderEnabled } from "@vue-tui/runtime";
|
||||
import { Text, useIsScreenReaderEnabled } from "@vue-tui/runtime";
|
||||
import { renderToStringWithScreenReader as renderToString } from "@vue-tui/runtime/internal";
|
||||
|
||||
// NOTE: tests that auto-detect SR via the process-GLOBAL env var
|
||||
// `INK_SCREEN_READER` live in use-screen-reader-env.sequential.test.tsx (the
|
||||
|
||||
@@ -1,46 +1,47 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import * as api from "@vue-tui/runtime";
|
||||
import * as internalApi from "@vue-tui/runtime/internal";
|
||||
|
||||
test("public API exposes documented members", () => {
|
||||
for (const k of [
|
||||
// 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;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
useStderr,
|
||||
useCursor,
|
||||
usePaste,
|
||||
useTerminalSize,
|
||||
useWindowSize,
|
||||
useAnimation,
|
||||
useBoxMetrics,
|
||||
} from "@vue-tui/runtime";
|
||||
@@ -650,7 +650,7 @@ describe("renderToString", () => {
|
||||
// StdinContext + a no-op AnimationScheduler (render-to-string.ts:93-96). The
|
||||
// existing suite covers useInput/useApp/useFocus/useFocusManager/useStdin/
|
||||
// useStdout/useStderr. These pin the remaining terminal composables —
|
||||
// useCursor, usePaste, useTerminalSize, useAnimation, useBoxMetrics — so that
|
||||
// useCursor, usePaste, useWindowSize, useAnimation, useBoxMetrics — so that
|
||||
// rendering a component which CALLS them degrades to inert values instead of
|
||||
// throwing (they must still return a string).
|
||||
describe("terminal composables degrade to no-ops (do not throw)", () => {
|
||||
@@ -681,11 +681,11 @@ describe("renderToString", () => {
|
||||
expect(pasted).toBe("");
|
||||
});
|
||||
|
||||
test("useTerminalSize does not throw in renderToString", () => {
|
||||
test("useWindowSize does not throw in renderToString", () => {
|
||||
const App = defineComponent(() => {
|
||||
// Resolves dimensions from ctx.stdout (process.stdout in the no-op
|
||||
// context) with the terminal-size fallback; never throws.
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const { columns, rows } = useWindowSize();
|
||||
return () => <Text>size {columns.value > 0 && rows.value > 0 ? "ok" : "fallback"}</Text>;
|
||||
});
|
||||
const output = renderToString(App);
|
||||
@@ -726,7 +726,7 @@ describe("renderToString", () => {
|
||||
const { setCursorPosition } = useCursor();
|
||||
setCursorPosition({ x: 1, y: 0 });
|
||||
usePaste(() => {});
|
||||
useTerminalSize();
|
||||
useWindowSize();
|
||||
const { frame } = useAnimation({ interval: 30 });
|
||||
const boxRef = shallowRef(null);
|
||||
useBoxMetrics(boxRef);
|
||||
|
||||
Reference in New Issue
Block a user